Всем привет! На связи Вадим, старший разработчик компании STM Labs. Хотите избавиться от ограничений пуш-сервисов и взять пуш-уведомления под полный контроль?
В этой статье мы глубоко погрузимся в процессы работы пуш-уведомлений, рассмотрим пример создания своего транспорта пушей и создадим Flutter-плагин для поддержки
собственного решения.
Задумывались ли вы о том, какие могут быть риски использования внешних пуш-сервисов в крупных проектах? Что делать, если ваш защищенный контур отрезан от интернета, но пуши всё равно нужны? И можно ли обойтись без внешних API и при этом гарантированно доставлять уведомления?
Чтобы ответить на эти вопросы, давайте заглянем под капот пуш-уведомлений: как они попадают на устройство, какие механизмы задействованы и почему инфраструктура Google и Apple играет решающую роль в обработке и доставке push-уведомлений на подавляющем количестве мобильных устройств.
Как работают push-уведомления
В классическом понимании push-уведомление — это любое сообщение, передаваемое через сервисы доставки уведомлений.
Чаще всего используются следующие сервисы:
- Google Firebase Cloud Messaging (FCM); 
- Служба Push-уведомлений Apple (APNS); 
- Huawei Push Kit. 
| Сервис | Описание | Поддерживаемые ОС | 
| Firebase Cloud Messaging | Кроссплатформенное решение для обмена сообщениями. | 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.
Исходя из схемы взаимодействия, мы можем сделать пару интересных выводов:
- FCM SDK не использует службу уведомлений ОС Android для генерации регистрационного токена. SDK отправляет запрос с деталями нашего Firebase-проекта (Sender ID, App ID) на сервер для получения push-токена. 
- FCM никак не связан с нашим устройством, он лишь отправляет сообщения, передаваемые по транспортному слою (ATL). 
Преимущества этого подхода:
- Экономия батарейки и трафика. Поскольку данная технология не использует polling или longpolling, устройство не выполняет периодические задачи в фоне. 
- Эффективное использование сетевых ресурсов. Сообщения передаются по выделенному пути, что позволяет ускорить доставку сообщения на целевое устройство. 
Теперь переходим к самому интересному: можем ли мы отправлять сообщения через ATL в обход FCM, чтобы реализовать свой пуш-сервис? Ответ прост — нет. Так как ATL регулируются условиями обслуживания, этот слой закрыт от разработчиков. Но есть другое решение, позволяющее заменить ATL — о нём мы поговорим позже.
Пуши в iOS
В iOS и других операционных системах Apple для доставки push- уведомлений традиционно используется сервис Apple Push Notification Service (APNS), API которого, как правило, интегрируется с помощью провайдера с использованием соединения HTTP/2 & TLS 1.2 и аутентификацией по SSL-сертификату провайдера.

Стоит учитывать, что на iOS нельзя полноценно заменить APNs, так как Apple строго ограничивает работу приложений в фоне, тем самым сокращая возможность получения фоновых уведомлений без использования APNs. Однако есть обходные пути:
- Инициализация VoIP-приложения: данное решение позволяет удерживать приложения в фоне, поскольку VoIP-приложения должны оставаться запущенными, чтобы принимать входящие звонки. Система автоматически перезапускает приложение, если оно завершается с ненулевым кодом выхода. Однако данное решение считается устаревшим, так как Apple запрещает злоупотреблять VoIP-уведомлениями. 
- Добавление режима «Background Fetch»: фоновая активность позволит извлекать обновленный контент в фоне. Однако данный метод не даст реализовать полноценный пуш-сервис, если приложение будет закрыто. 
Выводы таковы: Apple требует, чтобы приложения, использующие push-уведомления, применяли официальные API и соответствовали установленным стандартам. Попытки обхода APNs могут привести к нарушению общих принципов руководства, что может повлечь за собой reject приложения при проверке.
Так как все пуш-уведомления отправляются на устройства через официальные сервисы Google & Apple, возникает явная зависимость работы пушей от этих сервисов. В связи с чем появляются риски:
- Пуши работают до тех пор, пока работают сервисы. Если сервисы нас заблокируют (например, мы попадем под региональную блокировку), мы перестанем получать пуш-уведомления. 
- В случае, если наш проект работает в изолированной сети (без интернета), мы также не сможем отправлять пуш-уведомления на наши устройства. 
- Метаданные уведомлений (например, время отправки и/или информация об устройстве) проходят через серверы Google & Apple, что в некоторых случаях может быть опасно для конфиденциальности в проектах с высокими требованиями к безопасности. 
Создаём альтернативный модуль для работы с пушами
Рассмотрев, как устроена доставка push-уведомлений в ОС Android и iOS, перейдем к основному вопросу — созданию своего клиентского модуля для работы с пуш- уведомлениями.
Для этого проработаем требования, которые должен выполнять плагин:
- Библиотека должна реализовывать механизм доставки push-уведомлений по WebSocket-соединению как основной транспортный канал, полностью или частично заменяя стандартные решения на базе FCM или APNs. 
- Библиотека должна обеспечивать интеграцию с текущими интерфейсами работы с push-уведомлениями, предоставляя API, схожий с нынешними схемами работы. 
- Библиотека должна поддерживать интеграцию как с Cross-platform-проектами, так и с Native. 
- Идентификация клиента при подключении по WebSocket должна осуществляться исключительно по случайно сгенерированному на клиенте токену. 
- Библиотека должна предоставлять API для получения актуального push-токена, а также поддерживать механизм генерации нового токена с возможностью сброса активного соединения. 
- Библиотека должна предоставлять API для конфигурации параметров соединения, чтобы можно было работать с разными точками доступа. 
- WebSocket-соединение должно поддерживаться в фоне. 
Шаг 1. Определяем базовый интерфейс работы с библиотекой
| Метод | Тип данных | Описание | 
| getPushToken() | String | Получение push-токена в случае его существования. Если push-токен не был создан ранее, будет создан новый push-токен и возвращен в методе | 
| deletePushToken() | void | Удаление push-токена в случае его существования и генерация нового push-токена | 
| connect(                  String? | void | Метод позволяет установить соединение с конечной точкой (WebSocket-сервисом) для получения пуш-уведомлений. Опциональные аргументы notificationChannelName и channelId позволяют настроить пользовательскую конфигурацию. В случае существования активного соединения активное соединение будет сброшено и заменено | 
Таким образом, итоговыми артефактами в разработке будут:
- Библиотека .aar; 
- Flutter-плагин с интегрированной библиотекой. 
Шаг 2. Делаем свой .aar-модуль
Библиотека .aar — это обыкновенный архив в формате Android Library Project.

Схема взаимодействия библиотеки состоит из нескольких этапов:
- Взаимодействие библиотеки с нативными функциями (подключение по WebSocket, генерация push-токенов). 
- Взаимодействие Flutter-плагина с нативной библиотекой с помощью методов API. 
- Взаимодействие Flutter-приложения с Flutter-плагином. 
Для реализации базового функционала нам необходимо реализовать следующие классы:
| Класс | Методы класса | Описание класса | 
| TokenManager | public String getPushToken(Context) — позволяет получить текущий 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() — В случае 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 public void onDestroy() — выполняет разрыв соединения веб-сокетов. private void createNotificationChannel() — приватный метод, выполняющий 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) — выполняет удаление пуш-токена. Также останавливает работу сервиса уведомлений public void private void 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 для реагирования на изменение состояния сокета.
- Для подключения создадим простейшую реализацию подключения к сокетам на основе OkHttp3 в методе connect(). 
- В методе disconnect() реализуем проверку существования экземпляра сокета и в случае его существования произведём разрыв соединения. 
- Разрыв соединения будет выполняться с кодом «1000» в соответствии с RFC 6455 —это индикатор, указывающий на нормальное закрытие соединения, цель которого выполнена. 
- В приватном методе 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
Данный сервис отвечает за обработку сообщений, полученных от веб-сокета. Сервис запускается в фоновом режиме и не зависит от текущего состояния приложения.
- В методе onCreate() создаётся экземпляр менеджера веб-сокетов. 
- В методе 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
- В папке lib создадим папку src, чтобы ненужные методы не индексировались во Flutter-проекте. 
- Перенесём файлы flutter_push.dart, flutter_push_method_channel.dart и flutter_push_platform_interface.dart в папку src. 
- Далее экспортируем только необходимый для нас интерфейс: в папке 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.NativeHostApi2. В методе 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, что делает её более гибкой и универсальной.
Комментарии (4)
 - egribanov12.05.2025 14:39- Получилось что для андроид мы поднимаем вебсокет и переподключаемся, если приложение выгрузилось, верно? А для яблок используем инфраструктуру самой эпл? (Попадаем под ограничения и пуши пропадают как и при рекомендуемом официальном способе? Зачем тогда городить велосипед) - Итогом жертвуем батарейкой, но доставляем пуши?  - DevUnit Автор12.05.2025 14:39- Ваше замечание справедливо, постоянное поддержание WS в фоне действительно может сказаться на ресурсах устройства, но в данном контексте стоит учитывать, что любое собственное решение, исключающее официальные методы работы с пушами, будет иметь определенные минусы и в то же время плюсы. В пример можно взять те же RuStore-пуши, в первых версиях они также расходовали ресурс (что является минусом), но плюсом являлось то, что они не зависят от сервисов Гугл. 
 С iOS-пушами согласен, Apple накладывают ограничения на создание подобных собственных решений, и даже если попытаться обойти APN-сервисы и написать свой хак (попытаться сделать фоновый сервис), велик риск получить Reject приложения.
 В данных вопросах важно также подчеркнуть, что суть статьи заключается в том, чтобы "раскрыть" исходную модель работы с пушами и на основе полученных знаний реализовать собственный интерфейс работы с пушами без FCM, RuStore и других провайдеров. Думаю, что теория работы с энергоэффективностью и улучшениями данного решения - это предмет более глубокого исследования и рассмотрения в отдельной статье.
 
 
           
 
Mastersland
А как быть с flutter Web? Это, конечно, специфично)) в этом методе, как я понял, не будет ограничений с количеством уведомлений, который выдаёт firebase например?
DevUnit Автор
Хороший вопрос. Интеграция «самописных» пушей под каждую платформу сводится к тому, что мы через единый интерфейс вызываем специфичные под требуемую платформу методы, поэтому для каждой платформы это действительно специфично.
Для Web, к примеру, можно зарегистрировать Service Worker и далее реализовывать логику отображения пуша в package.
В узле WS можно передавать практически любые данные (в том числе конфигурации, счетчики и ограничения) и явных ограничений на пуши, соответственно, нет, однако любые ограничения можно реализовать, модифицировав решение под конкретную систему/проект/задачу, в этом как раз преимущество самописного решения