Погружаемся в пуши. Создаём свою альтернативу сервисам рассылки Push

от автора

Всем привет! На связи Вадим, старший разработчик компании STM Labs. Хотите избавиться от ограничений пуш-сервисов и взять пуш-уведомления под полный контроль?

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

Задумывались ли вы о том, какие могут быть риски использования внешних пуш-сервисов в крупных проектах? Что делать, если ваш защищенный контур отрезан от интернета, но пуши всё равно нужны? И можно ли обойтись без внешних API и при этом гарантированно доставлять уведомления?

Чтобы ответить на эти вопросы, давайте заглянем под капот пуш-уведомлений: как они попадают на устройство, какие механизмы задействованы и почему инфраструктура Google и Apple играет решающую роль в обработке и доставке push-уведомлений на подавляющем количестве мобильных устройств.

Как работают push-уведомления

В классическом понимании push-уведомление — это любое сообщение, передаваемое через сервисы доставки уведомлений.

Чаще всего используются следующие сервисы:

  1. Google Firebase Cloud Messaging (FCM);

  2. Служба Push-уведомлений Apple (APNS);

  3. Huawei Push Kit.

Сервис

Описание

Поддерживаемые ОС

Firebase Cloud Messaging
(FCM)

Кроссплатформенное решение для обмена сообщениями.

Android, iOS, macOS, tvOS, watchOS, Web

Служба Push- уведомлений Apple (APNS)

Облачная платформа, позволяющая сторонним разработчикам приложений отправлять оповещения на устройства Apple. Является основным и единственным способом доставки push-уведомлений на устройства Apple.

iOS, macOS, tvOS, watchOS

Huawei Push Kit

Облачная служба рассылки уведомлений. Изначально была создана как альтернатива сервису FCM.

Android, HarmonyOS, iOS, Web

Существует множество других сервисов: OneSignal, ASNS (Amazon), система push-сообщений «Аврора Центра» и прочие. Однако большинство из них работают по одному и тому же транспортному уровню доставки сообщений на целевое устройство.

Пуши в Android

Классическим способом доставки сообщений на целевое устройство Android является использование Google Services, с помощью которого реализуется взаимодействие с транспортным уровнем Android — Android Transport Layer (ATL).

Основным инструментом работы с транспортным уровнем является Firebase Cloud Messaging.

Рассмотрим подробнее, как происходит взаимодействие Android-устройства с транспортным уровнем при получении сообщения от FCM:

Всё, что мы знаем о Android Transport Layer, — что это long-live TCP-соединение между GS и целевым устройством. Когда наше соединение закрывается, маршрутизатор отправляет специальный сигнал FIN (или RST) для подтверждения закрытия соединения. Таким образом GS узнают о потере связи и пытаются восстановить соединение.

Однако стоит учитывать, что транспортные уровни не обслуживаются сервисами FCM, так как регулируются условиями, специфичными для определенной платформы, и подпадают под условия обслуживания Google API.

Исходя из схемы взаимодействия, мы можем сделать пару интересных выводов:

  1. FCM SDK не использует службу уведомлений ОС Android для генерации регистрационного токена. SDK отправляет запрос с деталями нашего Firebase-проекта (Sender ID, App ID) на сервер для получения push-токена.

  2. FCM никак не связан с нашим устройством, он лишь отправляет сообщения, передаваемые по транспортному слою (ATL).

Преимущества этого подхода:

  1. Экономия батарейки и трафика. Поскольку данная технология не использует polling или longpolling, устройство не выполняет периодические задачи в фоне.

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

Теперь переходим к самому интересному: можем ли мы отправлять сообщения через ATL в обход FCM, чтобы реализовать свой пуш-сервис? Ответ прост — нет. Так как ATL регулируются условиями обслуживания, этот слой закрыт от разработчиков. Но есть другое решение, позволяющее заменить ATL — о нём мы поговорим позже.

Пуши в iOS

В iOS и других операционных системах Apple для доставки push- уведомлений традиционно используется сервис Apple Push Notification Service (APNS), API которого, как правило, интегрируется с помощью провайдера с использованием соединения HTTP/2 & TLS 1.2 и аутентификацией по SSL-сертификату провайдера.

Стоит учитывать, что на iOS нельзя полноценно заменить APNs, так как Apple строго ограничивает работу приложений в фоне, тем самым сокращая возможность получения фоновых уведомлений без использования APNs. Однако есть обходные пути:

  1. Инициализация VoIP-приложения: данное решение позволяет удерживать приложения в фоне, поскольку VoIP-приложения должны оставаться запущенными, чтобы принимать входящие звонки. Система автоматически перезапускает приложение, если оно завершается с ненулевым кодом выхода. Однако данное решение считается устаревшим, так как Apple запрещает злоупотреблять VoIP-уведомлениями.

  2. Добавление режима «Background Fetch»: фоновая активность позволит извлекать обновленный контент в фоне. Однако данный метод не даст реализовать полноценный пуш-сервис, если приложение будет закрыто.

Выводы таковы: Apple требует, чтобы приложения, использующие push-уведомления, применяли официальные API и соответствовали установленным стандартам. Попытки обхода APNs могут привести к нарушению общих принципов руководства, что может повлечь за собой reject приложения при проверке.

Так как все пуш-уведомления отправляются на устройства через официальные сервисы Google & Apple, возникает явная зависимость работы пушей от этих сервисов. В связи с чем появляются риски:

  1. Пуши работают до тех пор, пока работают сервисы. Если сервисы нас заблокируют (например, мы попадем под региональную блокировку), мы перестанем получать пуш-уведомления.

  2. В случае, если наш проект работает в изолированной сети (без интернета), мы также не сможем отправлять пуш-уведомления на наши устройства.

  3. Метаданные уведомлений (например, время отправки и/или информация об устройстве) проходят через серверы Google & Apple, что в некоторых случаях может быть опасно для конфиденциальности в проектах с высокими требованиями к безопасности.

Создаём альтернативный модуль для работы с пушами

Рассмотрев, как устроена доставка push-уведомлений в ОС Android и iOS, перейдем к основному вопросу — созданию своего клиентского модуля для работы с пуш- уведомлениями.

Для этого проработаем требования, которые должен выполнять плагин:

  1. Библиотека должна реализовывать механизм доставки push-уведомлений по WebSocket-соединению как основной транспортный канал, полностью или частично заменяя стандартные решения на базе FCM или APNs.

  2. Библиотека должна обеспечивать интеграцию с текущими интерфейсами работы с push-уведомлениями, предоставляя API, схожий с нынешними схемами работы.

  3. Библиотека должна поддерживать интеграцию как с Cross-platform-проектами, так и с Native.

  4. Идентификация клиента при подключении по WebSocket должна осуществляться исключительно по случайно сгенерированному на клиенте токену.

  5. Библиотека должна предоставлять API для получения актуального push-токена, а также поддерживать механизм генерации нового токена с возможностью сброса активного соединения.

  6. Библиотека должна предоставлять API для конфигурации параметров соединения, чтобы можно было работать с разными точками доступа.

  7. WebSocket-соединение должно поддерживаться в фоне.

Шаг 1. Определяем базовый интерфейс работы с библиотекой

Метод

Тип данных

Описание

getPushToken()

String

Получение push-токена в случае его существования. Если push-токен не был создан ранее, будет создан новый push-токен и возвращен в методе

deletePushToken()

void

Удаление push-токена в случае его существования и генерация нового push-токена

connect( String?
notificationChannelName, String webSocketUrl, String? channelId
)

void

Метод позволяет установить соединение с конечной точкой (WebSocket-сервисом) для получения пуш-уведомлений. Опциональные аргументы notificationChannelName и channelId позволяют настроить пользовательскую конфигурацию. В случае существования активного соединения активное соединение будет сброшено и заменено

Таким образом, итоговыми артефактами в разработке будут:

  1. Библиотека .aar;

  2. Flutter-плагин с интегрированной библиотекой.

Шаг 2. Делаем свой .aar-модуль

Библиотека .aar — это обыкновенный архив в формате Android Library Project.

Схема взаимодействия библиотеки состоит из нескольких этапов:

  1. Взаимодействие библиотеки с нативными функциями (подключение по WebSocket, генерация push-токенов).

  2. Взаимодействие Flutter-плагина с нативной библиотекой с помощью методов API.

  3. Взаимодействие Flutter-приложения с Flutter-плагином.

Для реализации базового функционала нам необходимо реализовать следующие классы:

Класс

Методы класса

Описание класса

TokenManager

public String getPushToken(Context) — позволяет получить текущий push-токен
в случае его существования. В случае
его отсутствия позволяет сгенерировать
и вернуть новый push-токен.

public void deleteAndRegenerateToken(Context) —
позволяет сбросить текущий пуш-токен
и сгенерировать новый.

private String generateNewToken(Context) — приватный метод на генерацию нового пуш-токена

Класс, отвечающий за управление токенами авторизации

WebSocketManager

public void connect(String webSocketUrl, String pushToken, WebSocketCallback callback) — реализует подключение к WebSocket с помощью аргумента webSocketUrl, в query подставляет аргумент pushToken и вызывает callback при получении сообщения с помощью интерфейса WebSocketCallback.

public void disconnect() — В случае
существования экземпляра WebSocket, отключается от сокетов.

private void scheduleReconnect() — Реализует автоматическое переподключение в случае разрыва соединения

Класс, который отвечает за управление WebSocket- соединением и обеспечивает взаимодействие с сервером

SdkResultCallback

void onSuccess(T value) — Удачное завершение метода с передачей результата.

void onError(String error) — Неудачное завершение метода с передачей
описания ошибки

Интерфейс описывает колбэк для асинхронного получения результата

WebSocketCallback

void onConnected() — Успешное подключение к сокетам.

void onDisconnected(String error) — Разрыв соединения с сокетами с передачей ошибки.

void onMessageReceived(String message)
— Получение сообщения от сокета с передачей содержимого сообщения

Интерфейс используется для получения событий от WebSocket- соединения

NotificationService

Наследует методы класса Service.

public void onCreate() — создает экземпляр класса WebSocketManager.

public int onStartCommand(Intent intent, int
flags, int startId) — получает дополнительные данные из Intent, создает канал уведомлений, а также выполняет подключение к сокетам.

public void onDestroy() — выполняет разрыв соединения веб-сокетов.

private void createNotificationChannel() — приватный метод, выполняющий
создание канала уведомлений с определенным channelId и channelName.

private void handlePushMessage(String message) — выполняет обработку полученного через сокеты сообщения и отображает пуш-уведомление.

private void showPushNotification(String title, String text) — выполняет создание и отображение пуш-уведомления

Класс, отвечающий за обработку входящих push- уведомлений через WebSocket и передачу их в систему уведомлений Android

PushNotificationModule

getInstance() — выполняет получение экземпляра (синглтон).

public void getPushToken(Context context, SdkResultCallback callback) — выполняет получение пуш-токена из TokenManager и возвращает результат в callback.

public void deletePushToken @NonNulll Context context, @NonNull SdkResultCallback callback) — выполняет удаление пуш-токена. Также останавливает работу сервиса уведомлений
в случае его существования, возвращает результат работы в callback.

public void
connectToWebSocket@NonNull Context context, @NonNull String notificationChannelName, @NonNull String webSocketUrl, @NonNull String channelId)
— создаёт сервис пуш- уведомлений. В случае существования сервиса производит остановку старого сервиса.

private void
startNotificationService @NonNull Context context, @NonNull String notificationChannelName, @NonNull String webSocketUrl, @NonNull String channelId)
— приватный метод, создающий сервис уведомлений.

private void stopNotificationService @NonNull Context context) — приватный метод, останавливающий сервис уведомлений

Главный класс библиотеки, который предоставляет публичный API для интеграции push-уведомлений

Шаг 3. TokenManager

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

Пример реализации:

public class TokenManager {   private static final String STORAGE_KEY = "push_token_storage";    private static final String PUSH_TOKEN_KEY = "push_token";   private static SharedPreferences _spInstance(@NotNull Context context) {      return context.getSharedPreferences(context.getPackageName() + STORAGE_KEY Context.MODE_PRIVATE);   }   public static String getPushToken(Context context) {     SharedPreferences sharedPreferences = _spInstance(context);     String token = sharedPreferences.getString(PUSH_TOKEN_KEY, null);      if (token == null) {         token = generateNewToken(context);     }     return token;   }   public static void deleteAndRegenerateToken(Context context) {      SharedPreferences sharedPreferences = _spInstance(context);      sharedPreferences.edit().remove(PUSH_TOKEN_KEY).apply();     generateNewToken(context);   }   private static String generateNewToken(Context context) {      String newToken = UUID.randomUUID().toString();     SharedPreferences sharedPreferences = _spInstance(context);     sharedPreferences.edit().putString(PUSH_TOKEN_KEY, newToken).apply();      return newToken;   } }

Шаг 4. WebSocketManager

Менеджер управления сокетами должен принимать в себя push-токен (для вставки его в параметры запроса), URL для инициализации, а также интерфейс с callback для реагирования на изменение состояния сокета.

  1. Для подключения создадим простейшую реализацию подключения к сокетам на основе OkHttp3 в методе connect().

  2. В методе disconnect() реализуем проверку существования экземпляра сокета и в случае его существования произведём разрыв соединения.

  3. Разрыв соединения будет выполняться с кодом «1000» в соответствии с RFC 6455 —это индикатор, указывающий на нормальное закрытие соединения, цель которого выполнена.

  4. В приватном методе scheduleReconnect() реализуем отложенное переподключение сокетов.

Пример:

public class WebSocketManager {      private WebSocket webSocket;     private static final long INITIAL_RECONNECT_DELAY = 5000; // 5 секунд      private static final long MAX_RECONNECT_DELAY = 60000; // 60 секунд     private long reconnectDelay = INITIAL_RECONNECT_DELAY;     private WebSocketCallback callback;     private final Handler reconnectHandler = new Handler();      private boolean isConnected = false;     private String pushToken;     private String webSocketUrl;     public void connect(            @NonNull String webSocketUrl,             @NonNull String pushToken,            @NonNull WebSocketCallback callback     ) {         if (isConnected) return;         this.pushToken = pushToken;         this.callback = callback;         this.webSocketUrl = webSocketUrl;         OkHttpClient client = new OkHttpClient();         Request request = new Request.Builder()                .url(webSocketUrl + "?token=" + pushToken)                .build();         webSocket = client.newWebSocket(request, new WebSocketListener()            {@Override           public void onOpen(                  @NonNull WebSocket webSocket,                  @NonNull okhttp3.Response response           ) {              isConnected = true;              reconnectDelay = INITIAL_RECONNECT_DELAY;              callback.onConnected();           }           @Override           public void onMessage(                  @NonNull WebSocket webSocket,                  @NonNull String text           ) {               callback.onMessageReceived(text);           }           @Override           public void onFailure(                  @NonNull WebSocket webSocket,                  @NonNull Throwable t,                  okhttp3.Response response           ){             callback.onDisconnected(t.getMessage());             isConnected = false;             scheduleReconnect();           });     }       public void disconnect() {         if (webSocket != null) {         reconnectHandler.removeCallbacksAndMessages(null);         webSocket.close(1000, null);         webSocket = null;        }   }   private void scheduleReconnect() {      if (reconnectDelay > MAX_RECONNECT_DELAY) {          reconnectDelay = MAX_RECONNECT_DELAY;       }       reconnectHandler.postDelayed(() -> {           connect(webSocketUrl, pushToken, callback);             reconnectDelay *= 2;       }, reconnectDelay);     } }

Шаг 5. SdkResultCallback

Интерфейс с коллбэками работы метода будет использоваться в PushNotificationModule, а результат будет передаваться в кросс-платформу. Тип аргумента с результатом работы представляет из себя Generic для упрощения типизации итоговых значений.

Пример:

public interface SdkResultCallback<T> {      void onSuccess(T result);     void onError(String error); } 

Шаг 6. WebSocketCallback

Интерфейс с коллбэками для веб-сокета примерно идентичен SdkResultCallback, за исключением того, что мы не используем Generic, так как коллбэк всегда возвращает сырые данные, переданные по сокетам.

Пример:

public interface WebSocketCallback {    void onConnected();   void onDisconnected(String error);   void onMessageReceived(String message); }

Шаг 7. NotificationService

Данный сервис отвечает за обработку сообщений, полученных от веб-сокета. Сервис запускается в фоновом режиме и не зависит от текущего состояния приложения.

  1. В методе onCreate() создаётся экземпляр менеджера веб-сокетов.

  2. В методе onStartCommand() получаем базовую конфигурацию и выполняем необходимые действия: создаём канал уведомлений с заданным channelId и channelName.

Особенностью работы сервиса является то, что после его запуска в течение 5 секунд необходимо вызвать метод startForeground() в соответствии с документацией Google:

Система позволяет приложениям вызывать Context.startForegroundService(), даже если приложение находится в фоновом режиме. Однако приложение должно вызвать метод startForeground() этой службы в течение пяти секунд после ее создания.

Для этого мы напишем небольшой хак, который рассмотрим далее.

3. После запуска сервиса запрашиваем push-токен из TokenManager и выполняем подключение к веб-сокетам.

Результатом работы onStartCommand будет константа START_STICKY. Она означает, что сервис будет восстановлен после уничтожения.

Также будут использованы следующие вспомогательные методы:

  • createNotificationChannel() для создания канала уведомлений,

  • handlePushMessage() для обработки push-сообщений,

  • showPushNotification() для вывода уведомления на устройство.

Пример:

public class NotificationService extends Service {    private String channelId = "";   private WebSocketManager webSocketManager;   private String channelName = ""; private String webSocketUrl = "";    @Override   public void onCreate() {        super.onCreate();       webSocketManager = new WebSocketManager();   }   @Override   public int onStartCommand(Intent intent, int flags, int startId) {      if (intent != null) {         channelId = intent.getStringExtra("CHANNEL_ID");         channelName = intent.getStringExtra("CHANNEL_NAME");         webSocketUrl = intent.getStringExtra("WEBSOCKET_URL");     }     createNotificationChannel();     /// Хак для метода startForeground()     Notification notification = new NotificationCompat.Builder(this, channelId)            .setContentTitle("")            .setContentText("")            .setAutoCancel(true)            .build();    startForeground(1, notification);    String pushToken = TokenManager.getPushToken(this);    webSocketManager.connect(webSocketUrl, pushToken, new WebSocketCallback() {       @Override      public void onConnected() {}       @Override      public void onDisconnected(String error) {}       @Override      public void onMessageReceived(String message) {         handlePushMessage(message); } });    return START_STICKY; } @Nullable @Override public IBinder onBind(Intent intent) {    return null; } @Override public void onDestroy() {    super.onDestroy();   if (webSocketManager != null) {     webSocketManager.disconnect();    } } private void createNotificationChannel() { NotificationChannel serviceChannel = new NotificationChannel(         channelId, channelName, NotificationManager.IMPORTANCE_HIGH);  getSystemService(NotificationManager.class).createNotificationChannel(serviceChanne l);   }   private void handlePushMessage(String message) {        // Пример JSON: {"title": "Заголовок", "text": "Сообщение"}         try {             org.json.JSONObject json = new org.json.JSONObject(message);          String title = json.optString("title", "");           String text = json.optString("text", "");          showPushNotification(title, text);        } catch (Exception e) {        } } private void showPushNotification(String title, String text) {      Notification notification = new NotificationCompat.Builder(this, channelId)              .setContentTitle(title)              .setContentText(text)              .setSmallIcon(android.R.drawable.ic_dialog_alert)              .setAutoCancel(false)              .build();      NotificationManager manager = getSystemService(NotificationManager.class);       manager.notify(1, notification);     } }

Шаг 8. PushNotificationModule

Данный модуль объединяет в себе функции, описанные ранее. Класс выступает в качестве публичного API, который в дальнейшем будет интегрирован в наш Flutter-плагин.

Для этого класса будут реализованы базовые методы, описанные ранее. Доступ к методам будет осуществляться с помощью метода Singleton getInstance().

Пример:

public class PushNotificationModule {    private static PushNotificationModule instance;    public static synchronized PushNotificationModule getInstance() {       if (instance == null) {          instance = new PushNotificationModule();      }      return instance;    }    public void getPushToken(           @NonNull Context context,           @NonNull SdkResultCallback<String> callback   ) {     try {          String token = TokenManager.getPushToken(context);           callback.onSuccess(token);     } catch (Exception e) {       callback.onError(e.getMessage());     } } public void deletePushToken(        @NonNull Context context,       @NonNull SdkResultCallback<Boolean> callback ) {      try {           stopNotificationService(context);           TokenManager.deleteAndRegenerateToken(context);            callback.onSuccess(true);       } catch (Exception e) {         callback.onError(e.getMessage());       } } public void connectToWebSocket(        @NonNull Context context,       @NonNull String notificationChannelName,        @NonNull String webSocketUrl,       @NonNull String channelId,       @NonNull SdkResultCallback<Boolean> callback ) {     try {          stopNotificationService(context);           startNotificationService(                      context,                      notificationChannelName,                       webSocketUrl,                      channelId         );         callback.onSuccess(true);     } catch (Exception e) {         callback.onError(e.getMessage());     } } private void startNotificationService(            @NonNull Context context,           @NonNull String notificationChannelName,           @NonNull String webSocketUrl,            @NonNull String channelId ) {    Intent serviceIntent = new Intent(context, NotificationService.class);     serviceIntent.putExtra("CHANNEL_NAME", notificationChannelName);    serviceIntent.putExtra("WEBSOCKET_URL", webSocketUrl);     serviceIntent.putExtra("CHANNEL_ID", channelId);    context.startForegroundService(serviceIntent); }  private void stopNotificationService(          @NonNull Context context ) {     Intent serviceIntent = new Intent(context, NotificationService.class);      context.stopService(serviceIntent);  } }

Решение для iOS

Так как Apple на iOS не поддерживает long-running-сервисы, осуществлять работу веб-сокетов в фоновом режиме практически невозможно — ОС быстро приостанавливает такие процессы.

Однако для поддержки платформы iOS мы интегрируем возможность получения APNs-токена в плагине — рассмотрим этот способ ниже.

Шаг 1. Интеграция с Flutter

Создаем шаблон Flutter-плагина с помощью команды:

flutter create —org com.example.push.plugin.flutter_push —template=plugin — platforms=android,ios -a java -i swift flutter_push

  • с помощью параметра «-org» мы указали идентификатор нашего пакета,

  • с помощью параметров «-a» и «-i» мы указали предпочтительные языки на нативной стороне.

Перейдем к созданию PlatformChannel с помощью пакета pigeon. Для этого добавляем в dev зависимости пакеты «pigeon» и «build_runner»:

flutter pub add -d pigeon

flutter pub add -d build_runner

  1. В папке lib создадим папку src, чтобы ненужные методы не индексировались во Flutter-проекте.

  2. Перенесём файлы flutter_push.dart, flutter_push_method_channel.dart и flutter_push_platform_interface.dart в папку src.

  3. Далее экспортируем только необходимый для нас интерфейс: в папке lib создадим файл flutter_push_plugin.dart и укажем, что мы экспортируем файл flutter_push.dart:

library flutter_push export 'src/flutter_push.dart';

Файловая структура выглядит следующим образом:

4. Теперь опишем интерфейс платформы и добавим необходимые для нас методы, которые мы будем вызывать во Flutter-проекте.

В файле flutter_push_platform_interface.dart укажем следующие методы:

Future<String> getPushToken() async {   throw UnimplementedError('getPushToken() has not been implemented.'); } Future<void> deletePushToken() async {   throw UnimplementedError('deletePushToken() has not been implemented.'); } Future<void> connectToWebSocket({   required String notificationChannelName,    required String webSocketUrl,   required String channelId, }) async {   throw UnimplementedError('connectToWebSocket() has not been implemented.'); }

В файле flutter_push_plugin.dart, который мы недавно создали, определим эти методы:

class FlutterPush {   Future<String> getPushToken() {     return FlutterPushPlatform.instance.getPushToken();   }   Future<void> deletePushToken() {     return FlutterPushPlatform.instance.deletePushToken();   }   Future<void> connectToWebSocket({     required String notificationChannelName,      required String webSocketUrl,     required String channelId,   }) {     return FlutterPushPlatform.instance.connectToWebSocket(      notificationChannelName: notificationChannelName,     webSocketUrl: webSocketUrl, channelId: channelId,   );  } }

5. Прежде чем приступить к реализации интерфейса и вызову методов в MethodChannel, рассмотрим процесс вызова типизированных методов на нативной стороне с использованием pigeon:

1) В папке src создадим файл native_api.dart. В этом файле будет интерфейс, который мы в дальнейшем реализуем как на нативной стороне, так и на кроссплатформенной.

2) В файл native_api.dart добавим абстрактный класс native_api.dart и несколько аннотаций:
-@ConfigurePigeon() — конфигуратор генератора нативных интерфейсов,
-@HostApi() — указание абстрактного класса как хоста для работы с платформенным каналом.

3)В абстрактном классе укажем методы, которые будем использовать на нативной стороне. Каждый из методов аннотируем с помощью @async: таким образом данные методы будут возвращать Future T.

Пример:

@ConfigurePigeon(    PigeonOptions(     dartOut: 'lib/src/native_api.g.dart',     swiftOut: 'ios/Classes/NativeApi.g.swift',      dartOptions: DartOptions(        sourceOutPath: 'lib/src/native_api.g.dart',    ),    javaOut: 'android/src/main/java/com/example/push/plugin/flutter_push/NativeApi.java',   javaOptions: JavaOptions(     package: 'com.example.push.plugin.flutter_push',  ),  dartPackageName: 'com.example.push.plugin.flutter_push', ), ) @HostApi() abstract class NativeHostApi {    @async   String getPushToken();   @async   void deletePushToken();   @async   void connectToWebSocket({   required String notificationChannelName,    required String webSocketUrl,   required String channelId, }); }

6. Теперь сгенерируем наш интерфейс с помощью команды:

dart run pigeon —input lib/src/native_api.dart

После генерации в папках android и iOS будут созданы файлы NativeApi.java и NativeApi.swift соответственно.

Вернёмся к файлу flutter_push_method_channel.dart, в котором мы указываем MethodChannel-методы. Теперь, после генерации интерфейса, нам необязательно вызывать метод класса MethodChannel(), достаточно вызвать метод из NativeHostApi.

Как было раньше:

await methodChannel.invokeMethod<String>('getPushToken')

Как будет теперь:

class MethodChannelFlutterPush extends FlutterPushPlatform {   final NativeHostApi _native = NativeHostApi();   @override   Future<String> getPushToken() async {     final pushToken = await _native.getPushToken();      return pushToken;   } ...

Теперь перейдем к самому интересному — интегрируем нашу .aar библиотеку и реализуем NativeHostApi-класс на нативной стороне.

Шаг 2. Интегрируем .aar-библиотеку

1. Для сборки ранее разработанного решения в папке с проектом выполним команду

./gradlew flutterpush:assembleRelease

Команда собирает release-версию .aar-файла и кладет её в папку output.

2. В папке с плагином переходим в папку android и создаем папку libs. Туда мы будем складывать все наши .aar-библиотеки.

3. Теперь нам нужно включить нашу библиотеку в наш плагин. Для этого на уровне build.gradle добавляем в список репозиториев flatDir и указываем, из какой директории будем получать зависимости.

Пример:

rootProject.allprojects {    repositories {     google()     mavenCentral()      flatDir {       dirs project(':Идентификатор_плагина_flutter').file('libs') } }}

4. Остается только внедрить нашу зависимость в проект. Для этого в том же файле build.gradle объявляем внешнюю зависимость:

implementation(name: ‘Название_вашего_aar_файла’, ext: ‘aar’)

Шаг 3. Интегрируем методы библиотеки в плагин

Переходим в файл FlutterPushPlugin.java, видим, что наш класс реализует интерфейсы FlutterPlugin и MethodCallHandler. В данном случае MethodCallHandler нам больше не
нужен, так как у нас уже есть платформенный канал на основе интерфейса NativeHostApi.

1. Вместо MethodCallHandler реализуем интерфейс ActivityAware (для получения контекста) и наш новый интерфейс NativeHostApi.

Пример:

public class FlutterPushPlugin implements FlutterPlugin, ActivityAware,  NativeApi.NativeHostApi

2. В методе onAttachedToEngine определяем контекст и инициализируем экземпляр NativeHostApi для обработки сообщений binaryMessenger.

Пример:

public void onAttachedToEngine(        @NonNull FlutterPluginBinding flutterPluginBinding ) {    channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(),  "flutter_push");    NativeApi.NativeHostApi.setUp(flutterPluginBinding.getBinaryMessenger(), this);     context = flutterPluginBinding.getApplicationContext(); }

3. После указания NativeHostApi в качестве интерфейса, методы которого мы будем реализовывать, наш FlutterPushPlugin получил доступ к тем методам, которые мы ранее генерировали с помощью pigeon. Нам лишь остается вызывать методы .aar-библиотеки в переопределенных методах и возвращать ответ в кроссплатформу.

Пример:

@Override public void getPushToken(        @NonNull NativeApi.Result<String> result ) {    PushNotificationModule.getInstance().getPushToken(             context,            new SdkResultCallback<String>() {                @Override               public void onSuccess(String s) {                       result.success(s);               }           }); }

Шаг 4. Дополнительные зависимости в Flutter-проекте

После интеграции всех необходимых методов добавим дополнительные зависимости в плагин.

1. В build.gradle проекта добавим новые зависимости.

Пример:

dependencies {

implementation(«com.squareup.okhttp3:okhttp:4.12.0») implementation(«androidx.appcompat:appcompat:1.7.0»);

}

Но почему мы интегрируем okhttp3 и appcompact, хотя ранее внедряли эти зависимости внутри .aar библиотеки?

Дело в том, что .aar библиотека в текущей реализации не экспортирует зависимости, указанные в ней при разработке, поэтому все зависимости, которые мы указывали в .aar- библиотеке, указываем и в Flutter-плагине.

2. Теперь переходим в наш example проект, который находится в директории с Flutter- плагином. Открываем папку android/app и в build.gradle добавляем новую зависимость.

Пример:

dependencies {     implementation fileTree(dir: 'libs', include: '*.aar') } 

3. Однако для корректной работы этого всё еще недостаточно. Для корректной работы фоновых сервисов нам нужно задекларировать наш сервис, а также добавить пермишены для работы уведомлений в AndroidManifest.xml.

Пример:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <application>     <serviceandroid:name="com.example.<Путь к NotificationService в .aar-  библиотеке>"         android:foregroundServiceType="remoteMessaging"         android:exported="false"/> </application>

Шаг 5. Проверяем результат

После проделанной работы зайдём в файл main.dart в example-проекте. Теперь нам доступны методы, которые мы разрабатывали ранее:

await FlutterPush().getPushToken(); await FlutterPush().deletePushToken(); await FlutterPush().connectToWebSocket(      notificationChannelName: 'name',     webSocketUrl: 'wss://...',      channelId: 'id' );

Пробуем подключить веб-сокеты и отправить уведомление на устройство:

Шаг 6. Подключаем APNs к iOS версии

Для получения APNs-токена (пуш-токена) нам необходимо включить функцию push- уведомлений в XCode (в разделе Signing & Capabilities), а также зарегистрировать приложение в APNs. После регистрации приложения мы сможем получить глобальный уникальный токен устройства, который в дальнейшем сможем использовать для отправки пуш-уведомлений.

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

Зарегистрировать приложение и получить push-токен можно с помощью следующих функций:

func application(    _ application: UIApplication,    didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey:  Any]?) -> Bool {    UIApplication.shared.registerForRemoteNotifications() return true } func application(     _ application: UIApplication,     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data     ) {     // deviceToken - Наш пуш-токен }

Хотя Apple не рекомендует кэшировать токены устройств из-за их частой сменяемости, push-токен подобным методом хранит даже FCM SDK:

/// FLTFirebaseAuthPlugin.m - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { _apnsToken = deviceToken; } 

Далее реализуем интерфейс NativeHostApi в нативном файле swift нашего плагина в директории iOS по аналогии с Android. Однако в отличие от Android, в момент обращения за пуш-токеном он может быть ещё не зарегистрирован. В подобном случае мы можем возвращать ошибку в обёртку Flutter.

Пример:

public class FlutterPushPlugin: NSObject, FlutterPlugin, NativeHostApi {     public static func register(with registrar: FlutterPluginRegistrar) {        let messenger : FlutterBinaryMessenger = registrar.messenger()        let api : NativeHostApi & NSObjectProtocol = FlutterPushPlugin.init()         NativeHostApiSetup.setUp(binaryMessenger: messenger, api: api)    }    func getPushToken(       completion: @escaping (Result<String, any Error>) -> Void    ) {      if(deviceToken.isEmpty) {          completion(        .failure(             PigeonError(                code: "getPushToken",               message: "Ошибка получения токена",                details: ""             )          )        )      } else {           completion(.success(deviceToken));          }      }  }    

Итоги и выводы

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

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

Основные препятствия в разработке были связаны с ограничениями платформ, не дающими реализовать стабильное WebSocket-соединение в фоновом режиме, с автоматическим переподключением, без лишней нагрузки на батарею и с корректным отображением уведомлений даже при закрытом приложении. Мы справились с этой проблемой с помощью foreground service, экспоненциальной задержки при переподключении и системы управления токенами через SharedPreferences.

Главная особенность решения: работа push-уведомлений не зависит от Firebase, что делает его удобным для приложений, уделяющих большое внимание безопасности.

Стоит особенно отметить разработанную библиотеку: готовая библиотека легко встраивается как в Native, так и в Cross-Platform, что делает её более гибкой и универсальной.


ссылка на оригинал статьи https://habr.com/ru/articles/908540/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *