вторник, 26 июня 2012 г.

CallMeNotifications - уведомления о просьбе перезвонить

Вступление


Наверное многие знают об услуге "перезвони мне" мобильных операторов (в простонародье также называемые "попрошайками" или "бомжами"). Меня всегда раздражало то, что такие СМСки копятся во входящих, мешая искать более информативные сообщения. И в один прекрасный день я решил чуточку упростить себе жизнь, а попутно познакомится с программированием под Android.

Основные цели

  • Вывод сообщения о просьбе перезвонить в область уведомлений, при нажатии на которое, будет вызван абонент, приславший его
  • Удаление сообщения из входящих 
В статье мало теории, и если будет что-либо непонятно, советую почитать документацию или заглянуть на хабр.




 И так, приступим, создадим в Eclipse новый проект под Android, назовём его CallMeNotifications. Соответственно, главная Activity у нас будет называться CallMeNotificationsActivity. Её мы трогать не будем, так как пользовательского интерфейса в нашем приложении практически не будет. (Я лишь добавил туда описание приложения). Теперь нам нужно отловить все СМС, которые приходят на наш мобильный.

Получение входящих СМС

В Android события, например включение Wi-Fi или входящий звонок, порождают широковещательные сообщения, таким образом, подписавшись на одно из них, мы можем среагировать на событие. В нашем случае - это входящее СМС. Для этого нам понадобится класс, наследующийся от BroadcastReceiver. Его задачей будет прослушивать широковещательные сообщения и реагировать на них в методе onReceive.
// Эти импорты понадобятся нам в будущем
import android.content.BroadcastReceiver;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Bundle;
import android.provider.BaseColumns;
import android.provider.ContactsContract;
import android.telephony.SmsMessage;
public class SmsReceiver extends BroadcastReceiver {

    // Идентификатор уведомления, понадобится нам позже
    public static int NOTIFY_ID = 0;

    @Override
    public void onReceive(Context context, Intent intent) {

    }
}

context - Представляет контекст выполнения нашего приложения, через него мы будем получать доступ к необходимым нам методам.
Объект indent хранит в себе информацию о перехваченном  широковещательном сообщении.

Теперь важный момент, для того чтобы наш Receiver вызывался, нужно его где-то зарегистрировать. Есть два пути сделать это, первый прямо в коде нашего Activity:
    IntentFilter filter = new IntentFilter(
        "android.provider.Telephony.SMS_RECEIVED");

    filter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY);
    registerReceiver(new SmsReceiver(), filter);
И второй, зарегистрировать его в AndroidManifest.xml проекта, добавив следующие строки:
    <receiver android:name=".SmsReceiver" android:exported="true" >
        <intent-filter android:priority="1000"> 
            <action android:name="android.provider.Telephony.SMS_RECEIVED" />
        </intent-filter>
    </receiver>
Последний способ более предпочтителен, потому что в этом случае наш Receiver будет срабатывать даже тогда, когда наше приложение выгружено из памяти.
Еще один важный момент, приоритет Receiver`у выставляется максимальный (1000), это нужно для того чтобы получать СМС раньше системного приложения менеджмента СМС.

Теперь нам нужно получить само СМС сообщение:
    Bundle extras = intent.getExtras();

    if (extras != null) {
        Object[] smsExtra = (Object[]) extras.get("pdus");

        // Берём только первую СМС
        SmsMessage sms = SmsMessage.createFromPdu((byte[]) smsExtra[0]);
    }
Как вы, думаю, знаете, одна СМС в Android, может состоят из нескольких физических, с помощью конструкции smsExtra[0] мы получаем лишь первую, так как больше нам и не нужно. Если вы пишете похожий код, но хотите получить полный текст СМС, то вы можете организовать здесь цикл.

Разрешения

При установке Android приложений, вы наверняка, замечали, что снизу выводятся какие разрешения просят приложения, например, доступ к личной информации или доступ в сеть. Нашему приложению для нормальной работы, так же будут нужны разрешения. Их декларации хранятся в файле AndroidManifest.xml, в корне вашего проекта. Теперь прикинем, какие нам из них понадобятся:


  • WRITE_SMS, READ_SMS Работа с СМС
  • RECEIVE_SMS приём СМС
  • CALL_PHONE вызов абонента
  • READ_CONTACTS чтение имени контакта




    <uses-permission android:name="android.permission.WRITE_SMS" />
    <uses-permission android:name="android.permission.READ_SMS" />
    <uses-permission android:name="android.permission.RECEIVE_SMS" />
    <uses-permission android:name="android.permission.CALL_PHONE"/>
    <uses-permission android:name="android.permission.READ_CONTACTS"/>
Прописываем разрешения в manifest, после строки
<uses-sdk android:minsdkversion="10"/>

Парсер

Настало время подумать об обработке СМС.  Самым очевидным способом распознания сообщения с просьбой перезвонить, являются регулярные выражения. Но поскольку у операторов мобильной связи нет единого текста уведомления, то нам придётся писать регулярку для каждого из них. Вот два примера такого рода СМС.
Мегафон:
Абонент +9370000000 просит Вас ему перезвонить
Билайн:
Этот абонент просит Вас перезвонить ему

Здесь видно, что у каждого оператора свои способы передачи номера абонента. Например, у Билайна СМС приходят от номера самого абонента, а у Мегафона номер передаётся в тексте СМС. Это нужно учитывать при написании регулярок.
Так же хранить регулярки в самом коде, не очень то и удобно, затрудняется их редактирование, плюс, после любого изменения, приходится пересобирать проект. Так что хранить регулярки мы будем в xml файле, в директории raw/res, нашего проекта. Содержимое parsersettings.xml:
<?xml version="1.0" encoding="UTF-8"?>
<settings>
    <operator name="Megafon">
        <match-pattern>(?:Абонент )((:?(:?8|\+7)[\- ]?)?(?:\(?\d{3}\)?[\- ]?)?[\d\- ]{7,10})(:? просит Вас ему перезвонить).*</match-pattern>
        <use-sender-number>0</use-sender-number>
    </operator>
    <operator name="MTS">
        <match-pattern>Перезвоните мне, пожалуйста.*Отправлено.*</match-pattern>
        <use-sender-number>1</use-sender-number>
    </operator>
    <operator name="Beeline">
        <match-pattern>Этот абонент просит Вас перезвонить ему.*</match-pattern>
        <use-sender-number>1</use-sender-number>
    </operator>
    <operator name="TELE2">
        <match-pattern>(?:Вас срочно просит перезвонить абонент )(:?(8|\+7)? ?\(?(\d{3})\)? ?(\d{3})[ -]?(\d{2})[ -]?(\d{2,3})).*</match-pattern>
        <use-sender-number>0</use-sender-number>
    </operator>
    <operator name="NSS">
        <match-pattern>Пожалуйста, перезвоните мне.*</match-pattern>
        <use-sender-number>1</use-sender-number>
    </operator>
</settings>

Элемент use-sender-number используется для указания парсеру не искать номер телефона в СМС, вместо этого воспользовавшись номером отправителя.
Добавляем класс SmsParser в проект. Его код достаточно очевиден для людей знакомых с Java.
import java.io.InputStream;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import android.telephony.SmsMessage;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import java.util.regex.Pattern;
import java.util.regex.Matcher;

public class SmsParser {

    private SmsMessage message = null;
    private InputStream settings = null;
    private Boolean isCallMeSmsFlag = false;
    private String subscriberPhoneNumber = null;

    public SmsParser(SmsMessage message, InputStream settings) {
        this.message = message;
        this.settings = settings;
        parse();
    }
    private String xmlGetItem(Element element, String name) {
        return element.getElementsByTagName(name).item(0).getChildNodes()
                .item(0).getNodeValue();
    }
    private void parse() {
        try {
            DocumentBuilderFactory factory=DocumentBuilderFactory.newInstance();
            DocumentBuilder builder = factory.newDocumentBuilder();
            Document doc = builder.parse(this.settings);
            doc.getDocumentElement().normalize();

            // Старайтесь закрывать всё что открыли, во избежание утечек памяти
            this.settings.close();

            NodeList nodes = doc.getElementsByTagName(doc.getDocumentElement()
                    .getChildNodes().item(1).getNodeName());

            for (int i = 0; i &lt; nodes.getLength(); i++) {
                Node node = nodes.item(i);

                if (node.getNodeType() == Node.ELEMENT_NODE) {

                    Element element = (Element) node;
                    Pattern pattern = Pattern.compile(xmlGetItem(element,
                            "match-pattern"));

                    Integer useSenderNumber = new Integer(xmlGetItem(element,
                            "use-sender-number"));

                    // Получаем тело сообщения и номер телефона отправителя
                    String smsBody = message.getMessageBody().toString();
                    String fromNumber = message.getOriginatingAddress();

                    Matcher match = pattern.matcher(smsBody);

                    if (match.find()) {
                        isCallMeSmsFlag = true;

                        if (useSenderNumber != 0) {
                            subscriberPhoneNumber = fromNumber;
                        } else {
                            // При составлении регулярного выражения, для
                            // определения номера телефона используется группа 1
                            subscriberPhoneNumber = match.group(1);
                        }
                        return;
                    }
                }
            }
        } catch (Exception e) {
            throw new RuntimeException(e.getLocalizedMessage());
        }

    }
    public String getPhoneNumber() {
        if (subscriberPhoneNumber != null) {
            return subscriberPhoneNumber.replace(" ", "");
        } else {
            return null;
        }
    }
    public boolean isCallMeSms() {
        return isCallMeSmsFlag;
    }
}

Оповещение пользователя

Для того чтобы информировать пользователя о приходе сообщения с просьбой перезвонить, мы будем использовать оповещения в области уведомлений. Данное действие в Android реализует класс Notification.
В класс SmsReceiver добавим метод выводящий уведомления
    
    private void sendNotification(Context context, String number, String text) {

        Notification notify = new Notification(R.drawable.ic_launcher, text,
                System.currentTimeMillis());

        NotificationManager notifier = (NotificationManager) context
                .getSystemService(Context.NOTIFICATION_SERVICE);

        // Регистрируем ответное событие на нажатие
        // им у нас будет вызов абонента
        Intent toLaunch = new Intent(Intent.ACTION_CALL, Uri.parse("tel:"
                + number));
        PendingIntent intentBack = PendingIntent.getActivity(context, 0,
                toLaunch, 0);

        // Флаг закрытия уведомления, после того как на него нажали
        notify.flags |= Notification.FLAG_AUTO_CANCEL;
        // Проигрываем стандартную мелодию для оповещений
        notify.defaults |= Notification.DEFAULT_SOUND;
        notify.defaults |= Notification.DEFAULT_VIBRATE;
        notify.setLatestEventInfo(context, text,
              "Нажмите чтобы перезвонить" + number), intentBack);

        notifier.notify(NOTIFY_ID, notify);

        // Увеличиваем идентификатор, для того чтобы каждое уведомление
        // было уникальным, а не перезаписывалось предыдущим с таким же ID
        NOTIFY_ID += 1;
    }

Нам понадобится еще один вспомогательный метод для поиска абонента в телефонной книге по его номеру
    private String getContactDisplayNameByNumber(Context context, String number) {
        Uri uri = Uri.withAppendedPath(
                ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
                Uri.encode(number));

        String name = "";
        ContentResolver resolver = context.getContentResolver();
        Cursor lookup = resolver.query(uri, new String[] { BaseColumns._ID,
                ContactsContract.PhoneLookup.DISPLAY_NAME }, null, null, null);

        try {
            if (lookup != null && lookup.getCount() > 0) {
                lookup.moveToNext();
                name = lookup.getString(lookup
                        .getColumnIndex(ContactsContract.Data.DISPLAY_NAME));
            }
        } finally {
            if (lookup != null) {
                lookup.close();
            }
        }
        return name;
    }

Собираем всё вместе

Осталось реализовать всю логику в методе OnReceive нашего SmsReceiver
    @Override
    public void onReceive(Context context, Intent intent) {
        Bundle extras = intent.getExtras();

        if (extras != null) {
            Object[] smsExtra = (Object[]) extras.get("pdus");

            // Берём только первую СМС
            SmsMessage sms = SmsMessage.createFromPdu((byte[]) smsExtra[0]);
            // Открываем файл с регулярными выражениями
            InputStream settings = context.getResources().openRawResource(
                    R.raw.parsersettings);

            SmsParser parser = new SmsParser(sms, settings);
            if (parser.isCallMeSms()) {

                String phoneNumber = parser.getPhoneNumber();
                String who = getContactDisplayNameByNumber(context, phoneNumber);
                if (who.isEmpty()) {
                    who = phoneNumber;
                }
                String text = who + "просит перезвонить";
                sendNotification(context, phoneNumber, text);
                this.abortBroadcast();

                playNotificationSound(context);
            }
        }
    
Важный момент, вызывая this.abortBroadcast(), мы отменяем широковещятельное сообщение, а значит, оно не дойдёт до системного менеджера СМС и вам не придёт уведомление о получении. В общем, всё будет так, как будто этого сообщения не было вообще.

Код на  github.

3 комментария:

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

    ОтветитьУдалить
    Ответы
    1. Доброго времени суток, насколько я помню, мне эту проблему так и не удалось решить, тестировал на живом устройстве.

      Удалить