воскресенье, 2 декабря 2012 г.

Работаем с Google Cloud Messaging

Внимание: эта статья устарела! Гуглы довольно сильно перепилили свой GCM, и теперь всё не так. Но скрипт для отправки тестовых пушей всё ещё работает, поэтому убирать статью я пока не буду и постараюсь найти время её актуализировать.

Мы привыкли писать мобильные приложения, которые обращаются к некоторому API и получают оттуда данные. А ведь бывает и обратная ситуация: отправку данных клиенту может инициировать сам сервер. Обычно это используется для так называемых Push-нотификаций.

Мне известны два способа реализации Push-нотификаций. Во-первых это Google Cloud Messaging (бывший C2DM), во-вторых Urban Airship. Про последний я почти ничего не знаю (кроме отзывов коллеги о том, что использование его сильно сажает батарейку на телефоне). А про GCM пойдет речь в посте.

Вкратце процесс выглядит так:

Есть клиентское приложение, серверная часть и облачный сервис Google.

  1. Клиент регистрируется в облачном сервисе Google, получает токен
  2. Токен отправляется серверу
  3. Сервер, когда ему нужно, отправляет облаку запрос с этим токеном и какими-то данным
  4. Между облаком и девайсом происходит гугловая магия
  5. Клиенту приходит Intent с данными, которые отправил сервер.
  6. Клиент поступает с данными, как считает нужным (например, показывает нотификацию)

Я расскажу о своем опыте использования этой технологии и выложу рабочий пример.

Подключение GCM

Подключение GCM хорошо описано в документации, так что не буду особенно останавливаться на этом вопросе. Вкратце:

  1. Создаем Google-приложение
  2. Подключаем сервис GCM
  3. Генерируем Server Key
  4. Подключаем в наше приложение клиентскую библиотеку
  5. Не забываем выставить в манифесте требуемые разрешения и корректно указывать packageName (если что-то забыть, можно надолго зависнуть)

Из айдишников в нашей дальнейшей работе понадобятся:

  • Project Number (он же Sender ID). Он понадобится в Android-приложении, так что нужно завести соответствующую константу. Берем его тут:
    Sender ID
  • Server Key. В клиентском приложении не понадобится, но нужен будет на сервере и для отправки тестовых пушей:
    Server Key

Регистрация/удаление клиента в облаке

Регистрировать устройство в облаке нужно только один раз. Однажды зарегистрировашись, можно получать сколько угодно сообщений от сервера.

Регистрацию токена имеет смысл проводить вместе с каким-нибудь входом в систему. Или, например, когда пользователь даёт согласие на получение нотификаций. Удаление токена, соответственно, при выходе из системы или при отключении нотификаций.

Регистрация

public void register(Context context) {
    GCMRegistrar.checkDevice(context);
    GCMRegistrar.checkManifest(context);
    String gcmToken = GCMRegistrar.getRegistrationId(context);
    Log.v("RegistrationId=[%s]", pushToken);
    if (gcmToken.equals("")) {
        GCMRegistrar.register(context, Consts.GCM_SENDER_ID);
    } else {
        Log.w("Already registered");
    }
}

Удаление

public void unregister(Context context) {
    GCMRegistrar.unregister(context);
}

Получение сообщений из облака

Согласно документации, в приложение нужно добавить IntentService, унаследованный от GCMBaseIntentService, и он сразу начнет ловить всяческие сообщения от облака, включая служебные — о регистрации и удалении токена и об ошибках.

Ниже приведен пример сервиса, который принимает сообщения и показывает нотификации с полученным текстом.

Стоит обратить внимание на метод onMessage. Параметры, которые передает сервис, приходят в Extras.

GCMIntentService.java

public class GCMIntentService extends GCMBaseIntentService {
    private static final String KEY_MESSAGE = "message";

    @Override
    protected void onMessage(Context context, Intent intent) {
        Bundle extras = intent.getExtras();
        String message = extras.getString(KEY_MESSAGE);

        Intent pushIntent = new Intent(context, SomeActivity.class);
        pushIntent.putExtras(intent);
       
        PendingIntent pi = PendingIntent.getActivity(context, REQUEST_CODE, pushIntent, 0);

        NotificationManager nm = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
        Notification n = new Notification(R.drawable.ic_notification, message, System.currentTimeMillis());
        n.setLatestEventInfo(context, message, "", pi);
        n.flags |= Notification.FLAG_AUTO_CANCEL;

        nm.notify(R.id.push_notification, n);
    }

    @Override
    protected void onError(Context context, String errorId) {
        // об этом речь пойдет далее
    }

    @Override
    protected void onRegistered(Context context, String registrationId) {
        Log.v("Registered in GCM [%s]", registrationId);
        // TODO отправка запроса на регистрацию токена на нашем сервере
        // TODO сохранение токена в Shared Preferences
    }

    @Override
    protected void onUnregistered(Context context, String registrationId) {
        Log.v("Unregistered from GCM [%s]", registrationId);
        // TODO отправка запроса на удаление токена с нашего сервера
        // TODO удаление токена из Shared Preferences
    }

    @Override
    protected String[] getSenderIds(Context context) {
        return new String[] {
            Consts.GCM_SENDER_ID
        };
    }
}

Отправка тестовых сообщений

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

<?php
$url = 'https://android.googleapis.com/gcm/send';
$serverApiKey = "Your_server_API_key"; # см. регистрацию приложения
$reg = "Your_registration_key"; # сюда поставить Registration key, который выведется в логе при регистрации клиента в GCM

$headers = array(
    'Content-Type:application/json',
    'Authorization:key=' . $serverApiKey
);

$data = array(
    'registration_ids' => array($reg),
    'data' => array(
        'message' => 'Hello, World!'
));

print (json_encode($data) . "\n\n");

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
if ($headers)
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));

$response = curl_exec($ch);

curl_close($ch);
print ($response);

?>
  1. serverApiKey мы получили при регистрации приложения
  2. Registration key можем достать из LogCat (он выводится в GCMIntentService.onRegister).
  3. В массиве data переменной $data передаются данные, которые мы хотим переслать.

Обработка ошибок регистрации токена

Вот тут на настоящий момент (2 декабря 2012) документация проседает. Эта тема вроде как разобрана, но приведенные примеры относятся к устаревшей C2DM. В новой GCM так делать уже нельзя. Во-первых, метода handleRegistration уже нет (с этим жить можно, т.к. взамен есть onError). Во-вторых, (что серьезнее) onHandleIntent объявлен финальным в GCMBaseIntentService, так что переопределить его в наследниках нельзя. Так что приведенная инструкция совершенно неприменима.

Впрочем, решение у меня вполне получилось, ничего сверхъестественного в нем нет и оно довольно похоже на изначальную инструкцию:

  • При получении ошибки:
    • Формируем Intent на повторное выполнение регистрации
    • Планируем бродкаст этого интента по таймауту. Таймаут берется из какого-нибудь локального хранилища (например, Shared Preferences).
    • Увеличиваем значение таймаута в два раза и сохраняем в SharedPreferences. Таким образом, с каждой следующей ошибкой мы будем пытаться регистрироваться все реже и реже.
  • Отлавливаем упомянутый Intent специально обученным BroadcastReceiver-ом, в котором выполняем неудачную операцию снова.

А теперь код. Обращу внимание, это не есть копипаста, но только схема работы. Части, требующие реализации, отмечены комментарием TODO.

GCMIntentService

Переопределяем onError в GCMIntentService.

GCMIntentService.java

@Override
protected void onError(Context context, String errorId) {
    if (GCMConstants.ERROR_SERVICE_NOT_AVAILABLE.equals(errorId) ||
        GCMConstants.ERROR_AUTHENTICATION_FAILED.equals(errorId)) {

        long backoffTimeMs = ... // TODO: Получение отсрочки из Shared Preferences
        long retryToken = ... // TODO: Получение случайного числа и сохранение его в Shared Preferences

        long nextAttempt = SystemClock.elapsedRealtime() + backoffTimeMs;
        Intent retryIntent = new Intent(GCMConstants.INTENT_FROM_GCM_LIBRARY_RETRY);
        retryIntent.putExtra("retry_token", retryToken);

        PendingIntent retryPendingIntent = PendingIntent.getBroadcast(context, 0, retryIntent, 0);
        AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
        am.set(AlarmManager.ELAPSED_REALTIME, nextAttempt, retryPendingIntent);

        backoffTimeMs *= 2;

        // TODO сохранение нового таймаута в SharedPreferences
    }
}

GCMRetryReceiver

Итак, при возникновении ошибки SERVICE_NOT_AVAILABLE или AUTHENTICATION_FAILED мы отправили широковещательное сообщение. Осталось его отловить. Для этого нужен ресивер.

Пишем о нём его в манифесте:

AndroidManifest.xml

<receiver android:name=".GCMRetryReceiver">
    <intent-filter>
        <action android:name="com.google.android.gcm.intent.RETRY" />
    </intent-filter>
</receiver>

А вот и сам класс:

GCMRetryReceiver.java

public class GCMRetryReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();

        if (GCMConstants.INTENT_FROM_GCM_LIBRARY_RETRY.equals(action)) {
            long expectedRetryToken = ... //TODO получили сохраненный Retry token
            long actualRetryToken = intent.getLongExtra(KEY_RETRY_TOKEN, 0); 
            if (expectedRetryToken != actualRetryToken) {
                // если токен из интента не совпадаем с сохраненным — игнорируем интент
                return;
            }

            String gcmToken = sharedPrefs.getGCMToken(); // TODO получили сохраненный регистрационный токен

            if (TextUtils.isEmpty(gcmToken)) {
                // если он пуст, — значит, последней операцией была регистрация в облаке
                // TODO пробуем зарегистрироваться ещё раз
            } else {
                // последней операцией было удаление токена из облака
                // TODO пробуем удалить ещё раз
            }
        }
    }

Вот, собственно, и всё.

FAQ

Не приходит регистрационный токен от облака. Что не так?

Тут можно потратить много времени, но в первую очередь стоит проверить следующее:

  1. Правильно ли указан Sender ID
  2. Есть ли соединение
  3. Указаны ли необходимые разрешения в манифесте
  4. Везде ли в манифесте указано имя пакета там, где это нужно GCM

Как долго идёт сообщение от облака?

По моему опыту, обычно приходит сразу, но бывают и задержки до 15 минут.

Каковы ограничения на размер сообщения?

Это заморочки серверной части, но нам знать тоже полезно. Размер json-объекта с данными не должен превышать 4Кб.

Какие типы данных можно передавать в сообщения?

Хороший вопрос. Вот, что говорит документация:

The values could be any JSON object, but we recommend using strings, since the values will be converted to strings in the GCM server anyway. GCM Architectural Overview

Такая вот печаль. Передавать мы можем что угодно, но в Extras всё равно придут строки.

К примеру, пусть мы передаем следующий объект:

{   
    "message": "Hello, World!",
    "times":4,
    "numbers":[1,3,7,11]}
}

В Extras будет три строки:

    "message" => "Hello, World!"
    "times" => "4"
    "numbers" => "[1,3,7,11]"

Пришла ошибка AUTHENTICATION_FAILED, к чему бы это?

Это к тому, что следует проверить Google-аккаунта на устройстве. Вероятно, его нет или отвалился.

А если нет соединения?

Сообщение из облака не придет. Так что, если пуш критично важен, нужно предупредить пользователя, чтобы включил интернет.

Зачем нужен какой-то числовой токен при повторе попытки регистрации?

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

Мне кажется, можно решить это по-другому: использовать не стандарнтый экшен com.google.android.gcm.intent.RETRY, а определить свой.

Если кто-то знает, зачем ещё это может быть надо — пожалуйста, делитесь соображениями.

Пример

А вот он.

Обратите внимание на папку sender. Там лежит скрипт для отправки тестовых запросов и батничек для выполнения (да, я презренный виндузятник и использую батнички).

Заключение

GCM не страшен, это я просто пишу много. Прикручивать GCM к проекту — это задача на полдня даже при отсутствии такого опыта.

6 комментариев:

Анонимный комментирует...

Или они легче сделали, что можно за полдня справится или ... в общем, я чуть дольше промучился.

Что ещё заметил, то что ресивер должен обязательно лежать в ROOT-package приложения. Это тоже отняло время. Может, они сейчас это исправили.

Кстати, здесь (http://www.vogella.com/articles/AndroidCloudToDeviceMessaging/article.html) описано, как обойтись без PHP. "Серверное" приложение находится тоже на самом устройстве.

Aleksey комментирует...

Странно, почему UrbanAirship сажает батарейку, он базируется на все том-же GCM

https://docs.urbanairship.com/display/DOCS/Getting+Started:+Android:+GCM+Push

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

Unknown комментирует...

Даш, для пушей глянь еще QuickBlox.com это наш собственный продукт.

-Дима (Injoit Ltd)

darja комментирует...

Дима, здравствуй!
Пару раз пыталась понять, что такое ваш Quickblox, сейчас вроде начинает доходить. Это что-то вроде GCM, только с несколькими предустановленными форматами и более дружественной библиотекой, да?

Unknown комментирует...

Скажите, это же нужна поддержка GCM со стороны серверной части приложения, я та понимаю?Мы не можем отправлять сообщения без сервера, а с устройства на устройство?

darja комментирует...

@Евгений Ворона да, нужна серверная часть.