Разработка черного списка sms для Android

в 7:40, , рубрики: android, android development, антиспам, черный список, я пиарюсь, метки: , , ,

Здравствуй, уважаемое читатели!

Основываясь на ответах на мой вопрос, публикую данный топик. «Антиспам» — несколько громкое название для сделанного приложения, так как на данном этапе оно представляет собой лишь черный список отправителей. Однако в будущем я планирую сделать действительно антиспам с автоматической фильтрацией. Материал, расположенный ниже, рассчитан на тех, кто хоть чуточку знаком с разработкой под Android и делал хоть какие-то шаги для разработки своего собственного приложения, так как я не буду рассказывать про создание всего приложения с нуля, а расскажу только о наиболее интересных и важных моментах. Кому интересно, добро пожаловать под кат.

Ты помнишь, как все начиналось?

Небольшое лирическое отступление. Началось все с того, что я где-то слил номер своего мобильного телефона. Я даже подозреваю где именно, так как моим друзьям, которые регистрировались на том же сайте, что и я, приходят те же спам сообщения в одно и то же время. Но сейчас не об этом. По пятницам мне стали приходит рекламы различных клубов и с каждой неделей поток сообщений только увеличивался. Поскольку я стал обладателем телефона на базе ОС Android, то я решил сделать приложение, которое будет бороться с этим безобразием. Программисты никогда не ищут легких путей – к мобильному оператору я обращаться не хотел, да и к тому же я давно хотел начать разрабатывать под Android.

Архитектура и схема работы приложения

Приложение состоит из трех частей:

  1. Собственно фильтр, принимающий и фильтрующий смс сообщения;
  2. База данных, которая хранит черный список отправителей и сообщения, полученные от них;
  3. Пользовательский интерфейс.

Как все это работает? Пользователь добавляет в черный список отправителей либо из «Входящих», либо из «Контактов». Включает фильтр и закрывает приложение. При получении сообщения приложение анализирует его отправителя, и если он находится в черном списке, то помещает сообщение в так называемое хранилище, чтобы пользователь впоследствии мог посмотреть отфильтрованные сообщения. Если же отправитель «чист», то СМС попадает в папку «Входящие».

Первый прототип

Сказано сделано. Однако реализация первого прототипа не прожила и дня. Дело в том, что я пытался ловить событие о получении текстового сообщения, и в обработчике этого события удалять из папки «Входящие» все сообщения, отправители которых находились в черном списке. Проблема заключается в том, что владелец телефона все равно слышал бы сигнал, информирующий о получении СМС, а при открытии папки с входящими ничего нового бы там не видел! И это как-то не хорошо. Поэтому я начал искать способ перехватить сообщение, еще до того, как оно попадет в папку «Входящие».

Перехват сообщений

ОС Android устроена так, что о таких событиях как получение СМС, включение WI-FI, подключение зарядки и тому подобное, система информирует приложения с помощью широковещательной рассылки. Подробнее об этом механизме и об архитектуре в целом Android можно почитать здесь (4 перевода). Для создания получателя (слушателя) такой системной широковещательной рассылки необходимо:

  1. Создать свой класс, унаследованный от BroadcastReceiver и перегрузить метод onReceive;
  2. Зарегистрировать его в системе и указать, какие типы рассылок мы хотим получать.

Создание слушателя

public class SMSReceiver extends BroadcastReceiver
{
	@Override
	public void onReceive(Context context, Intent intent)
	{
		Bundle bundle = intent.getExtras();
		Object[] pdus = (Object[]) bundle.get("pdus");
		
		if (pdus.length == 0)
		{
			return;
		}

		Sms sms = Sms.fromPdus(pdus, context);
		SmsDatabase db = null;
		try
		{
			db = SmsDatabase.open(context);
			if (Filter.filter(context).isSpam(sms, db))
			{
				abortBroadcast();
				db.messages().save(sms);
			}
		}
		finally
		{
			if (db != null) db.close();
		}
	}
}

Метод onReceive срабатывает каждый раз, когда на телефон приходит СМС сообщение. Из параметра intent можно извлечь всю необходимую информацию. Внимание, согласно документации, класс, унаследованный от BroadcastReceiver, актуален только во время выполнения метода onReceive. Это значит, что система может уничтожить экземпляр класса как только закончиться выполнение указанного метода. Также это значит, что не стоит хранить какую либо информацию в нестатических полях класса.

В первых двух строках мы извлекаем информацию о PDU. Грубо говоря, это СМС в «сыром» виде. После проверки на пустоту мы пытаемся извлечь информацию о сообщении с помощью статического метода fromPdus() в самописном классе Sms, который будет описан позднее.

Затем мы с помощью класса Filter проверяем, не находится ли отправитель только что полученного СМС сообщения в черном списке. Если находится, что мы сохраняем сообщение в БД и с помощью метода abortBroadcast() прерываем рассылку. Это значит, что все получатели с более низким приоритетом, зарегистрированные на получение уведомления о СМС, даже не узнают, что такое событие имело место быть. Нашему получателю мы установим самый высокий приоритет (даже выше получателя, который издает звуки и вибрирует устройством), чтобы не беспокоить пользователя в случае получения спам-сообщения. О приоритетах читайте чуть ниже.

В предыдущей версии приложения, в методе onReceive соединение с БД открывалось дважды: первый раз в классе Filter при проверке сообщения, а второй раз непосредственно при записи смс в БД. Однако я отказался от такого подхода и сделал код «чуть более неправильным» с точки зрения «красивости» кода, так как время выполнения метода onReceive ограничено 10ю секундами и открывать два соединения подряд не имеет смысла. Ведь если наш метод не уложиться в отведенное время, то Android вызовет метод следующего получателя и тогда пользователь будет проинформирован о получении смс.

Регистрация слушателя

Слушателя мы написали. Осталось зарегистрировать его системе. Сделать это можно двумя способами:

  1. Программно при помощи метода registerReceiver(). В этом случае получатель будет жить только пока жив компонент, который его зарегистрировал (как правило, это Activity);
  2. При помощи AndroidManifest. В этом случае получатель будет жить, даже если приложение будет закрыто, и более того, даже если телефон будет перезагружен!

Очевидно, что 2й вариант является более приемлемым. Давайте посмотрим, как его можно реализовать:

<application android:label="@string/app_name" android:icon="@drawable/app_icon">
……………. Здесь прочие теги …………………..
        <receiver android:name=".SMSReceiver" android:enabled="false">
                <intent-filter android:priority="1000">
                        <action android:name="android.provider.Telephony.SMS_RECEIVED" />
                 </intent-filter>
        </receiver>
</application>

Здесь все предельно просто. Указываем имя класса получателя (android:name), а затем с помощью тега intent-filter указываем приоритет (android:priority, 1000 – максимальное значение; у стандартного получателя, который вибрирует и издает звуки, приоритет 999) и на какие события мы подписываемся (android.provider.Telephony.SMS_RECEIVED).

Включение и выключение слушателя

Следует сказать, что получатель по умолчанию выключен. Отсюда вытекает то, что пользователь имеет возможность включать и выключать фильтр, путем активации и деактивации получателя СМС. В классе Filter для этого имеются соответствующие методы on(), off() и enabled().

Класс Filter, кстати говоря, является классом одиночкой (singleton), так как у нас должен быть один фильтр на все приложение, несмотря на то, что он зависит от контекста (Context). В классе SMSReceiver вы могли видеть как происходит доступ к экземпляру фильтра через статический метод filter(), который принимает экземпляр класса Context в качестве параметра.

private static Filter _filter;
	
public static Filter filter(Context context)
{
	if (_filter == null || !_filter._context.equals(context))
	{
		_filter = new Filter(context);
	}
	return _filter;
}

Я ни в коем случае не утверждаю, что такая реализация единственно верная. Если вы можете предложить более удачное решение, то я с удовольствием его рассмотрю.

В конструкторе класса Filter следует инициализировать необходимые для работы данные:

private final ComponentName componentName;
private final PackageManager packageManager;
private Context _context;

private Filter(Context context)
{
	_context = context;

	String packageName = context.getPackageName();
	String receiverComponent = packageName + ".SMSReceiver";
	componentName = new ComponentName(packageName, receiverComponent);
	packageManager = context.getPackageManager();
}

componentName – это полное имя компонента приложения, в данном случае получателя, которое включает в себя название пакета и имя класса (SMSReceiver).
packageManager – из названия ясно, что это класс для управления компонентами приложения.

Рассмотрим метод, который включает фильтр:

public void on()
{
	if (!enabled())
	{
		packageManager.setComponentEnabledSetting(componentName,
				PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
				PackageManager.DONT_KILL_APP);
	}
	else
	{
		Util.showMessage(_context.getString(R.string.alreadyStarted), _context);
	}
}

Тут все просто. Если компонент уже включен (enabled() – самописный метод, рассмотрим его чуть позже), то сообщаем пользователю об этом, если же выключен – включаем. Статический класс Util является самописным и включает в себя различные вспомогательные функции. В данном случае метод showMessage использует стандартный класс Toast для отображения сообщений на экран.

Метод off(), отключающий фильтр, полностью аналогичен методу on() за исключением того, что используется флаг PackageManager.COMPONENT_ENABLED_STATE_DISABLED, а в случае, если фильтр уже выключен, выводится соответствующее сообщение.

Метод, проверяющий состояние фильтра, выглядит еще проще:

public boolean enabled()
{
	int enabled = packageManager.getComponentEnabledSetting(componentName);
	return (enabled == PackageManager.COMPONENT_ENABLED_STATE_ENABLED);
}

Проверка на вшивость

В классе Filter остался еще один метод, который не был описан. Это метод isSpam(), который, собственно, и выполняет основную задачу приложения. Метод крайне прост. Он извлекает из смс сообщения его отправителя и пытается найти его в БД. Если таковой имеется, то сообщение считается спамом.

public boolean isSpam(Sms sms, SmsDatabase db)
{
	Sender sender = sms.sender();
	return db.senders().exists(sender);
}

Класс Sms

Класс Sms упоминался уже дважды. Надо бы рассказать про него несколько подробнее. Этот класс служит представлением СМС сообщений в приложении. Он содержит следующие поля:

private String _body;
private Sender _sender;
private long _timestamp;

где _body – тело сообщения, _sender – отправитель сообщения _timestamp – время получения сообщения в виде UNIX’ового timestamp’а.

Класс Sender не является стандартным для Android. Он написан руками и умеет хранить телефон отправителя, а также по необходимости извлекать имя отправителя из телефонной книги. Подробно мы не будем его рассматривать.

Мы уже знаем, что у класса Sms есть статический метод fromPdus(), извлекающий информацию о сообщении из PDU. Посмотрим на его код:

public static Sms fromPdus(Object[] pdus, Context context)
{
	Sms result = new Sms();
	for (int i = 0; i < pdus.length; i++)
	{
		SmsMessage sms = SmsMessage.createFromPdu((byte[]) pdus[i]);
		result._body += sms.getMessageBody();
	}

	SmsMessage first = SmsMessage.createFromPdu((byte[]) pdus[0]);
	result._sender = new Sender(first.getOriginatingAddress(), context);
	result._timestamp = first.getTimestampMillis();

	return result;
}

На первый взгляд метод может показаться сложным. Однако это не так. Единственная сложность заключается в том, что одно логическое СМС сообщение, может быть разбито на несколько физических. Думаю, что в этом плане Америку я для Вас не открыл. Этот спецэффект знаком всем, кто всегда пишем без пробелов, чтобы впихнуть как можно больше информации в одну СМС и сэкономить свои кровные.

Таким образом, мы в цикле собираем тело сообщения по кусочкам, а потом из самого первого СМС сообщения извлекаем необходимую информацию. В общем-то, с классом Sms на это можно закончить.

Немного о базе данных

Черный список наглых спамеров, а также результаты их непризнанного творчества хранятся в базе данных. Это необходимо для того, чтобы пользователь мог посмотреть, что ему там понаприходило. Ведь теоретически пользователь может добавить в черный список и телефонный номер из своих контактов, а после того как помирится с его обладателем, может захотеть почитать полученные от него СМС.

Нам повезло, ведь в Android’е прямо из коробки идет СУБД SQLite. Всю логику работы с БД я реализовал с использованием стандартных JAVA классов и запросов на SQL. По закону подлости, после такого, как я закончил эту часть приложения, я прочитал на Хабре статью про ORM для Android.

Работа с БД у меня построена по принципу: открыл соединение – поработал с данными – закрыл соединение. Эта практика особенно хороша для SQLite, так как эта СУБД является файловой и может возникнуть блокировка при операции чтения или записи. Поэтому я стараюсь не держать долгоживущих соединений.

Очень не хотелось бы рассматривать все методы–обертки для БД, так как их достаточно много, а статья не резиновая. К тому же все они однотипны, поэтому для понимания сути рассмотрим только метод, использование которого мы видели в методе onReceive класса SMSReceiver.

Запись «db.messages().save(sms)» говорит нам о том, что в рамках соединения db мы обращаемся к таблице Messages, которая хранит отфильтрованные сообщения, и добавляем в нее новое сообщение sms при помощи метода save(). Повторюсь, что все классы и методы самописны.

public void save(Sms sms)
{
	if (!Preferences.get(_context).storeSms()) return;
	int maxCount = Preferences.get(_context).storedSmsAmount();
	if (count() >= maxCount) trim(maxCount - 1);

	SQLiteStatement insert = _db.compileStatement(
			"INSERT INTO Messages(Phone, Timestamp, Body)" +
			"VALUES (?, ?, ?)");
	insert.bindString(1, sms.sender().phone());
	insert.bindLong(2, sms.timestamp());
	insert.bindString(3, sms.body());
	insert.execute();
}

Первой строкой кода мы получаем доступ к настройкам приложения и проверяем флаг, отвечающий за желание пользователя хранить отфильтрованные сообщения. Если пользователь отказался от хранения, то просто выходим.

Затем, опять же из настроек, извлекаем установленный пользователем размер хранилища отфильтрованных СМС. И если текущее количество сообщений больше этого размера, удаляем из хранилища старые сообщения с учетом порядка их получения. То есть первыми будут удалены сообщения, которые первыми и были получены. Классическая очередь.

После этих телодвижений формируем запрос на вставку данных в таблицу Messages и с помощью типизированных методов bind() привязываем данные к сформированному запросу. В конце исполняем запрос.

Тестирование и отладка

Вроде все готово. Остается маленький шажок. Вопрос в том, как нам убедится, что приложение правильно функционирует? Что получатель вообще хоть что-то получает, а БД это что-то сохраняет? Про возможности Unit-тестирования приложений под Android я рассказывать не буду, так как это тема отдельного большого топика. Расскажу лишь про то, как сымитировать получение СМС на эмуляторе.

Для этого откроем любой telnet-клиент. В зависимости от настроения, я использую либо Putty, либо стандартный клиент в Windows 7 (который сначала необходимо включить). Подключаемся к localhost, а в качестве порта указываем порт, отображаемый в заголовке окна с эмулятором. Как правило, это 5554. Само собой разумеется, что при этом эмулятор должен быть запущен. После прочтения приветствия можно начинать вводить команды.

Команд достаточно много. Их список можно получить, набрав команду «help». С помощью этих команд можно имитировать многие события, которые могут происходить с телефоном. Однако нас интересует только имитация получения СМС сообщения.

Чтобы это сымитировать, достаточно набрать команду

sms send номер_телефона текст_сообщения

где номер_телефона — любая последовательность цифр, которая может начинаться со знака «+», а текст_сообщения – содержимое сообщения, включая пробелы и другие символы, которые мы обычно пишем в СМС.

В случае успешной имитации мы увидим сообщение “OK” в терминале telnet-клиента.

Заключение

В данном топике я постарался раскрыть основные технические подробности моего приложения, обратить внимание на подводные камни и некоторые возможности Android платформы. Последнюю версию приложения можно бесплатно скачать с Google Play.

Если возникли какие-то вопросы или у Вас есть какие-то предложения, то я буду благодарен, если Вы напишите об этом в комментариях или в личные сообщения. Конструктивная критика только приветствуется. Спасибо за внимание!

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

Автор: int02h

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js