Всем привет! На связи Вадим, старший разработчик компании 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>
    <service	android: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, что делает её более гибкой и универсальной.