Внедряем кросс-платформенные пуш-уведомления: дополнительные возможности +29


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



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


Батчинг


Поскольку в наших условиях один пользователь вполне может иметь несколько пуш-токенов, например, от мобильного приложения или браузера, то логично было бы отправлять весь набор пушей внутри одного запроса. В Huawei Messaging Service можно указать внутри одного запроса список токенов, но им всем в итоге придет одинаковый payload. В Firebase Cloud Messaging можно отправлять множество индивидуальных уведомлений в одном запросе. Но интересно тут даже не столько наличие такой функциональности, сколько её внутреннее устройство.


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


Следующий код
func (e *multipartEntity) Bytes() ([]byte, error) {
    var buffer bytes.Buffer
    writer := multipart.NewWriter(&buffer)
    writer.SetBoundary(multipartBoundary)
    for idx, part := range e.parts {
        if err := part.writeTo(writer, idx); err != nil {
            return nil, err
        }
    }

    writer.Close()
    return buffer.Bytes(), nil
}

func (p *part) writeTo(writer *multipart.Writer, idx int) error {
    b, err := p.bytes()
    if err != nil {
        return err
    }

    header := make(textproto.MIMEHeader)
    header.Add("Content-Length", fmt.Sprintf("%d", len(b)))
    header.Add("Content-Type", "application/http")
    header.Add("Content-Id", fmt.Sprintf("%d", idx+1))
    header.Add("Content-Transfer-Encoding", "binary")

    part, err := writer.CreatePart(header)
    if err != nil {
        return err
    }

    _, err = part.Write(b)
    return err
}

func (p *part) bytes() ([]byte, error) {
    b, err := json.Marshal(p.body)
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequest(p.method, p.url, bytes.NewBuffer(b))
    if err != nil {
        return nil, err
    }

    for key, value := range p.headers {
        req.Header.Set(key, value)
    }
    req.Header.Set("Content-Type", "application/json; charset=UTF-8")
    req.Header.Set("User-Agent", "")

    var buffer bytes.Buffer
    if err := req.Write(&buffer); err != nil {
        return nil, err
    }

    return buffer.Bytes(), nil
}

В запрос уходит содержимое, которое вернулось из multipartEntity.Bytes.


Что тут происходит: все отдельные сообщения целиком преобразуются в HTTP-запросы, пакуются в multipart/form-data и уходят в теле запроса.


Выглядит сформированный запрос


Примерно так
POST /batch/farm/v1 HTTP/1.1
Authorization: Bearer your_auth_token
Host: www.googleapis.com
Content-Type: multipart/mixed; boundary=batch_foobarbaz
Content-Length: total_content_length

--batch_foobarbaz
Content-Type: application/http
Content-ID: <item1:12930812@barnyard.example.com>

GET /farm/v1/animals/pony

--batch_foobarbaz
Content-Type: application/http
Content-ID: <item2:12930812@barnyard.example.com>

PUT /farm/v1/animals/sheep
Content-Type: application/json
Content-Length: part_content_length
If-Match: "etag/sheep"

{
  "animalName": "sheep",
  "animalAge": "5"
  "peltColor": "green",
}

--batch_foobarbaz
Content-Type: application/http
Content-ID: <item3:12930812@barnyard.example.com>

GET /farm/v1/animals
If-None-Match: "etag/animals"

--batch_foobarbaz--

Это уже пример из документации к Google Cloud Platform.


Хотя с первого взгляда это может быть похоже на атаку HTTP request smuggling. Ответ возвращается в таком же виде, а для соединения части ответа с частью запроса используется заголовок Content-ID.


Это довольно необычное решение, но не без недостатков, из которых можно выделить немалый оверхед (на заголовки в частях и разделители), а также сложность формирования запроса и разбора ответа, если не использовать библиотеки из firebase-admin-sdk. При использовании этого API крайне рекомендуется включать в запросы данные для отправки только на ту платформу, из которой прислан пуш-токен (к примеру, не отправлять данные для Android-приложения на токен для браузера), раздувать части-запросы ни к чему.


Массовые отправки


Помимо индивидуальных отправок пушей по токенам в Firebase и Huawei можно делать массовые отправки по именованным группам — топикам. Они могут быть сформированы двумя способами:


  • Клиент сам подписывается на нужное имя топика. Важно отметить, что это может сделать как авторизованный, так и неавторизованный в вашем приложении пользователь, так как в этот процесс сервер приложений никак не вовлечен. Как это можно использовать? К примеру, можно таким образом сгруппировать всех тех, кто еще не авторизован в приложении, но согласен на получение уведомлений. Тут есть одна особенность: по какой-то причине в firebase-js-sdk нет способов просто подписаться на топик, хотя в мобильных SDK она присутствует.
  • Можно сгруппировать имеющиеся токены в топики уже на сервере приложений. Например, группировать по выбранному в профиле пользователя региону. В моем демо-проекте это реализовано так: можно подписать пользователя (то есть все его токены) на топик и отписать от него.

Один и тот же топик можно наполнить обоими способами. Учитывайте это при проектировании и не отправляйте конфиденциальную информацию через топики.


Такая возможность позволит одним запросом отправить рассылку, не создавая дополнительной нагрузки на сервер приложений. Причем можно даже отправлять пуши одним запросом сразу в несколько топиков по условиям вида "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)". Однако важно учитывать то, что при отправке через топик задержка в доставке может быть гораздо больше, чем при индивидуальной отправке по токену.


«Под капотом» же управление топиками с сервера и клиентов происходит через службу Instance ID, нужные вызовы к которой удалось найти в firebase-ios-sdk. В аналогичном SDK для Android этe функциональность на себя берут «Сервисы Google Play».


Приоритет и TTL


При добавлении в приложение возможности получать звонки (webrtc) мы обнаружили, что довольно часто пуш, инициирующий показ уведомления со звонком, приходил слишком поздно, когда вызывающий уже завершал звонок или тот «переводился» на обычный голосовой (GSM). Проблем тут, как выяснилось, две: ненастроенная гарантия доставки по времени (пуш нужно отбросить, если он пришел позже определенного времени) и «засыпание» самого приложения (оно не может обработать пришедший пуш).


Такие пуши мы начали отправлять с выставленным TTL и высоким приоритетом (Firebase и Huawei (urgency)), что позволяет «пробудить» службу. Про высокий приоритет есть важное замечание: в результате его получения пользователь должен каким-либо образом повзаимодействовать с приложением, а при злоупотреблении высокий приоритет могут снять. Это указано в документации, но упомянуть в статье также стоит.


Ещё не следует при обработке таких пушей делать какие-либо потенциально долгие операции, например сетевые запросы. Это связано с особенностями управления standby-квотами в новых версиях Android. Рекомендуется отложить такие операции через планировщик задач.


Дедупликация


В оригинале это называется collapse, но я считаю, что термин «дедупликация» здесь подходит лучше, чем «схлопывание».


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


Для веб-пушей за дедупликацию на клиенте отвечает параметр tag в методе showNotification. Также следует обратить внимание на параметр renotify, управляющий тем, будет уведомление заменено «тихо» ли (т.е. без вибрации и звука).


Для Android этот метод применяется с параметром tag, при этом нужно учитывать, что дедупликация происходит по паре id и tag.


Для iOS всё снова несколько сложнее: рекомендуемый способ дедупликации — проставлять в запросе на отправку apns-collapse-id (именно так мы и делаем), однако можно использовать метод removeDeliveredNotifications, вызывая его в момент получения пуша.


Разница в поведении

Слева используется apns-collapse-id, справа — удаление старого уведомления через код в расширении.



Группировка


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


Android

Начиная с версии 7.0:



На версиях 6.x:



iOS 12+


Дополнительная функциональность с summary-arg (от кого сообщения):



Для того, чтобы собирать уведомления в группы, достаточно некоторого ключа, по которому и будет происходить группировка. Для iOS достаточно этот ключ просто пробросить как thread-id и уведомления сразу начнут группироваться. На Android в нашем случае реализовать это чуть сложнее. Так как мы отправляем data-пуши, необходимо было доработать приложение — для сборки в группы нужно использовать соответствующий метод в обработчике пушей.


Также рекомендуется именовать группы человеко-читаемым названием для лучшего пользовательского опыта. Отправлять его нужно вместе с ключом группировки: для iOS можно пробросить его в summary-arg (в firebase-admin-go оно почему-то не добавлено). В Android никак нельзя управлять группами напрямую с сервера, можно в обработчике data-пушей вызывать уже другой метод.


Картинки


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


Выглядит это примерно так

Android:



iOS:



Web (Chrome на Windows 10):



Поддерживается на всех платформах. Механизм примерно одинаковый: нужно прислать в payload ссылку на изображение и использовать при показе уведомления. Рекомендуется присылать ссылки на изображения небольшого размера (хорошая сводная таблица по ограничениям есть тут). Мы поступаем следующим образом: для уменьшения трафика обёртываем в ресайзер ссылки на изображения, отправляемые в пушах.


На Android достаточно в поле image прислать нужный URL или воспользоваться методом setStyle. С веб-пушами все аналогично: при вызове showNotifcation в аргументе options добавляется ключ image. Однако изображения из веб-пушей сейчас показываются только в браузерах на Windows или мобильных ОС.


С iOS всё немного сложнее: одной только установкой параметра в запрос не ограничиться (такого параметра просто нет), придется пользоваться расширением для скачивания картинки и добавления ее к уведомлению.


Примерный код
if let PusherNotificationData = request.content.userInfo["data"] as? NSDictionary {
            if let urlString = PusherNotificationData["image-url"] as? String, let fileUrl = URL(string: urlString) {
                URLSession.shared.downloadTask(with: fileUrl) { (location, response, error) in
                    if let location = location {
                        let options = [UNNotificationAttachmentOptionsTypeHintKey: kUTTypePNG]
                        if let attachment = try? UNNotificationAttachment(identifier: "", url: location, options: options) {
                            self.bestAttemptContent?.attachments = [attachment]
                        }
                    }

                    self.contentHandler!(self.bestAttemptContent!)
                    }.resume()
            }
        }

Кнопки-действия


На уведомление обычно можно кликнуть, чтобы перейти в определенный экран приложения или адрес в браузере. Уведомления можно сделать более интерактивными, добавив одну-две дополнительные кнопки. Этим мы также воспользовались для реализации приёма звонков в приложении. При поступлении звонка пользователю отправляется пуш, который показывает уведомление с двумя кнопками: принять или отклонить вызов.


Выглядит это так

Android:




Тут использована также стилизация уведомлений.


iOS:



Web:



Для реализации этой и следующих фич на мобильных платформах потребуется понять, какие категории уведомлений у вас отправляются, например «обычные», «звонки», «предложения» и прочее. Это необходимо, так как требует изменения кода приложения.


В Android можно добавить кнопки к любому уведомлению (то есть можно, скажем, присылать настройки в payload пуша).


iOS же оперирует понятием «категории». Они жестко задаются в приложении, и что-то поменять можно только выпустив новую версию приложения.


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


Быстрый ответ


Наверняка вы уже сталкивались с возможностью ответить на сообщение в мессенджере прямо из уведомления, например в Telegram. Сегодня эта возможность есть только на мобильных платформах или нативных десктопных приложениях. В браузере это невозможно, хотя в WHATWG соответствующее предложение висит аж с 2016 года.


Для пользователя это выглядит так

Android:



iOS:



Подробно расписывать не буду, так как это достаточно объемная тема. Приведу ссылки по платформам:


  • Android
  • iOS: Какого-то внятного официального руководства на Apple Developer найти не удалось, есть информация только про необходимый класс. Помог с реализацией вот этот ответ на StackOverflow.

Собственная мелодия и вибрация


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


Начнем с веб-пушей. Для задания собственного паттерна вибрации используется параметр vibrate, значение которого — массив целых чисел. Числа на четных позициях задают длительность работы вибромотора в миллисекундах, на нечетных — длительность паузы в миллисекундах. Например, паттерн [300,100,300,100,300] содержит три импульса по 300 мс с паузами по 100 мс. Со звуком всё сложнее. Свойство sound, в которое нужно записать ссылку на аудиофайл, описано в документации, но оно сейчас не поддерживается ни одним из браузеров. Я находил решение вида «сделать расширение для браузера, слушающее уведомления и играющее звук», но мы его не проверяли и оно кажется излишне сложным для такой простой задачи.


В Android всё, в целом, похоже. Паттерн вибрации выглядит так же и задается через setVibrate, мелодия — через setSound.


В iOS звуковой файл, который необходимо проиграть, нужно заранее упаковать в приложение: Preparing Custom Sound Alerts. Собственные паттерны вибрации, как в предыдущих случаях, задавать нельзя. Но с iOS 10 можно проигрывать заранее заданные паттерны.


Заключение


Я описал далеко не все способы «тюнинга» уведомлений. Например, локализацию, которая крайне необходима для многоязычных приложений, и так далее. При работе с уведомлениями помните главное: это инструмент, и им надо правильно пользоваться. В частности, разумно ограничивать их количество и возможное время суток, в которое их можно отправить, для конкретных пользователей.




К сожалению, не доступен сервер mySQL