Привет, Хабр! Меня зовут Георгий, и я тимлид команды платформы в компании Купер. Мы специализируемся на разработке IT-приложений для маркетинга и бизнеса, включая кроссплатформенные мобильные решения на базе React Native. В этой статье я хочу поделиться опытом работы с пуш-уведомлениями. Расскажу, с чего мы начинали, какие проблемы встретили и к чему пришли на текущий момент. Все примеры возьму из свежего проекта, исходники которого доступны на GitHub — ссылку оставлю в конце.

Изначально, наше приложение на React Native было ориентировано только на Android с публикацией в Google Play. Для пушей мы выбрали React Native Firebase Messaging — популярную библиотеку, интегрирующую FCM. Без какого-либо кастомного интерфейса для отображения уведомлений в активном состоянии, мы сразу перенаправляли их в системную шторку через react-native-notifee (библиотека для кастомизации уведомлений). Для iOS использовали Apple Push Notification Service (APNS), тоже без дополнительных фич. И все было хорошо, но в один момент нам потребовалась поддержка HMS (Huawei mobile services).

Разделение сборок при помощи Product Flavors

Как React Native-разработчики сначала мы попробовали подключить community-библиотеки: оставить Firebase для Google и добавить HMS Core для Huawei. 

У нас было 2 типа сборки — debug и release.

Нам же требовалось расширить его, добавив два направления — для Google и Huawei, чтобы в результате для каждого из них появились варианты: googleRelease, googleDebug, huaweiRelease и huaweiDebug.

Задача решается при помощи механизма Product Flavors. Как указано в документации (Android Developers), он позволяет на одной кодовой базе создавать несколько версий приложения с разными конфигурациями. 

Как его настроить? Начнем с добавления имени flavor в конфигурацию. Все делается в файле build gradle модуля приложения. В проекте на React Native он обычно находится по пути android/app/build.gradle. Сначала регистрируем новое измерение, добавив его в массив flavorDimensions. Название может быть произвольным — главное, чтобы потом его можно было связать с productFlavors через свойство dimension. Для нашей ситуации (Google и Huawei) это выглядит так, как показано в примере ниже.

android {
      flavorDimensions += "store"

      productFlavors {
        huawei {
            dimension 'store'
        }

        google {
            dimension 'store'
        }
    }
}

Для каждого flavor мы добавим поле в BuildConfig.

android {
      flavorDimensions += "store"

      productFlavors {
        huawei {
            dimension 'store'
            buildConfigField "String", "StoreType", '"Huawei"'
        }

        google {
            dimension 'store'
            buildConfigField "String", "StoreType", '"Google"'
        }
    }
}

Давайте разберемся, что это такое. При сборке Android-приложения генерируется статический класс BuildConfig, который содержит константы, специфичные для билда. Добавлять их можно через метод buildConfigField в Gradle — это стандартный и удобный способ, описанный в документации Android. 

В нашем случае мы добавим новое поле StoreType. Оно позволит в JavaScript-коде определять текущий тип стора (Google или Huawei) без лишних проверок. 

После настройки flavors и синхронизации Gradle, структура директорий в Android-проекте изменится. В папке src теперь появятся не только main и debug, но и директории для новых flavors, таких как google и huawei. 

При сборке Gradle автоматически мержит ресурсы и код: для билда Google исключается все связанное с Huawei, и наоборот. Такой прием обеспечивает чистоту и оптимизацию — никаких ненужных зависимостей в финальном APK, что снижает его размер и повышает безопасность.

Теперь, когда мы добавили BuildConfigField в build.gradle (одно значение для Google, другое для Huawei), нужно передать эту информацию в JavaScript. Если вы используете библиотеку react-native-config, это произойдет автоматически: она рефлексивно парсит BuildConfig и экспортирует значения как константы в JS.

Если react-native-config не в вашем стеке, то альтернативы две: нативный модуль или передача через launch options. Мы выберем второе — сделаем через launch options. Для Android в классе MainActivity нужно переопределить метод getLaunchOptions в ReactActivityDelegate и вернуть Bundle с нужными значениями. Для iOS можно аналогично использовать initialProperties в AppDelegate.

Важно: все, что передается через getLaunchOptions (для Android) или initialProps (для iOS), попадет в корневой компонент, зарегистрированный в AppRegistry (метод registerComponent). 

Передавая через getLaunchOptions, наш класс MainActivity будет выглядеть так:

class MainActivity : ReactActivity() 
    /**
     * Returns the name of the main component registered from JavaScript. This is used to schedule
     * rendering of the component.
     */
    override fun getMainComponentName(): String = "RNNotifcations"

    /**
     * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
     * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
     */
    override fun createReactActivityDelegate(): ReactActivityDelegate =
        object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) {
            override fun getLaunchOptions(): Bundle = bundleOf(
                "storeType" to BuildConfig.StoreType
            )
        }
}

Аналогично для iOS, AppDelegate:

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  [FIRApp configure];
  self.moduleName = @"RNNotifcations";
  // You can add your custom initial props in the dictionary below.
  // They will be passed down to the ViewController used by React Native.
  self.initialProps = @{
    @"storeType": @"ios"
  };

  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [self bundleURL];
}

- (NSURL *)bundleURL
{
#if DEBUG
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
  return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}

@end

Или, если у вас swift и более свежая версия RN:

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  var reactNativeDelegate: ReactNativeDelegate?
  var reactNativeFactory: RCTReactNativeFactory?

  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
  ) -> Bool {
    let delegate = ReactNativeDelegate()
    let factory = RCTReactNativeFactory(delegate: delegate)
    delegate.dependencyProvider = RCTAppDependencyProvider()

    reactNativeDelegate = delegate
    reactNativeFactory = factory

    window = UIWindow(frame: UIScreen.main.bounds)

    // Добавляем здесь
    factory.startReactNative(
      withModuleName: "RNNotifications",
      in: window,
      initialProperties: ["storeType": "ios"],
      launchOptions: launchOptions,
    )

    return true
  }
}

class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
  override func sourceURL(for bridge: RCTBridge) -> URL? {
    self.bundleURL()
  }

  override func bundleURL() -> URL? {
#if DEBUG
    RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
    Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
  }
}

Теперь, в JS можно использовать так:

type Props = {
  storeType: StoreType;
};

const App: React.FC<Props> = ({storeType}) => {
  React.useEffect(() => {
    alert(storeType);
  }, []);

  return null;
};

И результатом будет алерт с текущим storeType, который определяется сборкой (для андроид).

Теперь, на андроиде, мы умеем отдельно делать сборки для Huawei и для Google: можно подключать библиотеки. Для реакт нейтив это react-native-firebase (@react-native-firebase/messaging для google) и react-native-hms-push (@hmscore/react-native-hms-push для huawei). 

Здесь нас ждет новая проблема: автоматическая линковка в RN не учитывает наши flavors— автоматически будут линковаться сразу все библиотеки вне зависимости от выбранного flavor. Единственный выход: отключить автолинковку и сделать все самостоятельно. Для этого в react-native.config.js добавим отключение автолинковки, предварительно установив пакеты через npm или yarn:

# install $ setup the app module
yarn add @react-native-firebase/app

# install the messaging module
yarn add @react-native-firebase/messaging

# install huawei messaging service
yarn add @hmscore/react-native-hms-push

#If you are developing your app using iOS, run this command
cd ios && pod install

И отключим автолинковку (в файле react-native.config.js):

module.exports = {
  dependencies: {
    '@react-native-firebase/app': {
      platforms: {
        android: null,
      },
    },
    '@react-native-firebase/messaging': {
      platforms: {
        android: null,
      },
    },
    '@hmscore/react-native-hms-push': {
      platforms: {
        android: null,
        ios: null,
      },
    },
  },
};

Для ios ничего делать не нужно, так как предполагается, что с ним будет использоваться firebase. Но, если в вашем случае требуется стандартное APNS решение, можно взять библиотеку и поступить аналогично.

В Android-проекте переходим к ручной настройке: в settings.gradle (android/settings.gradle) подключаем проекты из node_modules напрямую, как это делали раньше (react-native 0.59 и ниже).

include ':react-native-firebase_app'
project(':react-native-firebase_app').projectDir =
        new File(rootProject.projectDir, '../node_modules/@react-native-firebase/app/android')

include ':react-native-firebase_messaging'
project(':react-native-firebase_messaging').projectDir =
        new File(rootProject.projectDir, '../node_modules/@react-native-firebase/messaging/android')

include ':hmscore_react-native-hms-push'
project(':hmscore_react-native-hms-push').projectDir =
        new File(rootProject.projectDir, '../node_modules/@hmscore/react-native-hms-push/android')
  

Затем в build.gradle (файл app-модуля) добавляем зависимости через implementation, но с ключевым нюансом — указываем конкретный product flavor: например, для googleImplementation или huaweiImplementation. 

dependencies {
    // The version of react-native is set by the React Native Gradle Plugin
    implementation("com.facebook.react:react-android")

    googleImplementation project(path: ':react-native-firebase_app')
    googleImplementation project(path: ':react-native-firebase_messaging')

    huaweiImplementation project(path: ':hmscore_react-native-hms-push')

    if (hermesEnabled.toBoolean()) {
        implementation("com.facebook.react:hermes-android")
    } else {
        implementation jscFlavor
    }
}

Почти готово — синхронизируем Gradle (через Android Studio или CLI).

Далее, по правилам ручной линковки, нам необходимо добавить пакеты в packagesList (getPackages в MainApplication.reactNativeHost). Здесь важно учитывать, что для Google и Firebase у нас будут два разных пакета. Сделать это можно несколькими способами, но мы напишем просто extenstion на Kotlin (для Google и Huawei придется написать отдельно). Напомню, теперь у нас есть директории для Google и Huawei (мы настраивали flavors).

В каждой из директорий добавим kotlin файл. Далее, добавим extensions для Google (android/app/src/google/kotlin/com/rnnotifications/PackageList+pushStoreDependencies.kt):

package com.rnnotifications

import com.facebook.react.ReactPackage
import io.invertase.firebase.app.ReactNativeFirebaseAppPackage
import io.invertase.firebase.messaging.ReactNativeFirebaseMessagingPackage

fun ArrayList<ReactPackage>.pushStoreDependencies() {
    add(ReactNativeFirebaseAppPackage())
    add(ReactNativeFirebaseMessagingPackage())
}

и для Huawei (android/app/src/huawei/kotlin/com/rnnotifications/PackageList+pushStoreDependencies.kt):

package com.rnnotifications

import com.facebook.react.ReactPackage
import com.huawei.hms.rn.push.HmsPushPackage

fun ArrayList<ReactPackage>.pushStoreDependencies() {
    add(HmsPushPackage())
}

Теперь, в MainApplication.kt, нам нужно добавить в getPackages (в reactNativeHost) вызвать наш extension. Итоговый класс будет выглядеть так:

package com.rnnotifcations

import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.soloader.SoLoader
import com.rnnotifications.pushStoreDependencies

class MainApplication : Application(), ReactApplication {

    override val reactNativeHost: ReactNativeHost =
        object : DefaultReactNativeHost(this) {
            override fun getPackages(): List<ReactPackage> =
                PackageList(this).packages.apply {
                    // Вызываем extenstion
                    pushStoreDependencies()
                    // Packages that cannot be autolinked yet can be added manually here, for example:
                    // add(MyReactNativePackage())
                }

            override fun getJSMainModuleName(): String = "index"

            override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG

            override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
            override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
        }

    override val reactHost: ReactHost
        get() = getDefaultReactHost(applicationContext, reactNativeHost)

    override fun onCreate() {
        super.onCreate()
        SoLoader.init(this, OpenSourceMergedSoMapping)
        if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
            // If you opted-in for the New Architecture, we load the native entry point for this app.
            load()
        }
    }
}

На этом ручная настройка завершена. Мы можем использовать эти зависимости из JS, а как разделить код там, вариантов много, так как JS и так будет доступен (ведь пакеты JS все равно попадут в сборку), да и storeType мы добавили именно по этой причине. Дальше в репозитории будут примеры.

Как мы писали свое решение 

В процессе реализации нам пришлось столкнуться с несколькими проблемами. Во-первых, @hmscore/react-native-hms-push оказалась сильно забагованной — наши патчи в итоге выросли до размеров библиотеки. Было много итераций: от фиксов мелких ошибок до глубоких рефакторингов (это подчеркивает важность тщательного тестирования сторонних библиотек). 

Во-вторых, на тот момент мы переписывали все приложение на Jetpack Compose с использованием Kotlin, поэтому решили разработать собственное нативное решение. 

Перейдем к созданию нативного модуля, который будет работать как в React Native, так и в чистом Kotlin-коде, но перед тем как писать код, разберемся с зависимостями и API. 

Сравнивая Firebase и HMS, мы видим, что они функционируют идентично в ключевых аспектах: есть Service (фоновая задача) для обработки пуш-уведомлений на Android, механизм получения токена для регистрации устройства на сервере, способ отображения уведомлений в системной шторке и т. д. Нам лишь нужно интегрировать оба сервиса для каждого flavor и создать общий адаптер для взаимодействия с модулем. 

Для начала подключаем сами библиотеки. Аналогично React Native, используем googleImplementation и huaweiImplementation в Gradle для нативных зависимостей (это уже не node-модули, а полноценные Gradle-пакеты). 

dependencies {
    // The version of react-native is set by the React Native Gradle Plugin
    implementation("com.facebook.react:react-android")

    if (hermesEnabled.toBoolean()) {
        implementation("com.facebook.react:hermes-android")
    } else {
        implementation jscFlavor
    }

    googleImplementation platform('com.google.firebase:firebase-bom:33.5.1')
    googleImplementation('com.google.firebase:firebase-messaging:24.0.3')
    huaweiImplementation('com.huawei.hms:hmscoreinstaller:6.7.0.300')
    huaweiImplementation("com.huawei.hms:push:6.12.0.300")
}

Сразу опишем все необходимые адаптеры:

package com.rnnotifcations.notifications

data class Notification(
    val title: String,
    val body: String,
    val channelId: String
)

data class RemoteMessage(
    val id: String,
    val data: Map<String, String>,
    val notification: Notification? = null
)

sealed class TokenResult {
    data class Success(val token: String) : TokenResult()
    data class Failure(val exception: Exception?) : TokenResult()
}

interface RemoteMessagingService {
    fun receiveToken(onComplete: (result: TokenResult) -> Unit)
    fun tryReadRemoteMessage(intent: Intent): RemoteMessage?
}

sealed class RemoteMessagingEvent {
    class NewToken(val token: String) : RemoteMessagingEvent()
    class NewMessage(val message: RemoteMessage) : RemoteMessagingEvent()
}

interface RemoteMessagingSubscriber {
    fun onEvent(event: RemoteMessagingEvent)
}

class RemoteMessagingEventEmitter {
    companion object {
        val shared = RemoteMessagingEventEmitter()
    }

    private val subscribers = mutableListOf<RemoteMessagingSubscriber>()

    fun subscribe(subscriber: RemoteMessagingSubscriber) {
        subscribers.add(subscriber)
    }

    fun unsubscribe(subscriber: RemoteMessagingSubscriber) {
        subscribers.remove(subscriber)
    }

    fun notify(event: RemoteMessagingEvent) {
        subscribers.forEach {
            it.onEvent(event)
        }
    }
}

RemoteMessagingService будут реализовывать классы для каждого flavor, так как способ получения токена и извлечения initial notification отличаются для каждого сервиса. Получение initial notification происходит через Intent. 

  • Интент в Android — это асинхронное сообщение (действие), которое система или другие приложения отправляют вашему, чтобы инициировать обработку. 

Через них (интенты) мы получаем, например, начальные уведомления или клики по нотификациям в шторке — это фундаментальный механизм ОС, обеспечивающий интеграцию без постоянного polling'а. RemoteMessagingEventEmitter будет просто уведомлять RN модуль о каких-то событиях, а модуль уже будет дублировать эти события в JS. 

Сам эмиттер довольно прост и обрабатывает несколько ключевых событий:

  • newToken — когда токен обновляется через сервис (например, при регенерации);

  • newMessage — для входящих сообщений из сервиса;

Эмиттер будет единым для Huawei и Google, поскольку мы унифицируем всю логику под этот стандарт. Также сразу опишем модели уведомлений. В сервис (google, hms) нам приходит RemoteMessage, в шторку отображается Notification — простая, но достаточная модель, где уведомление может выглядеть как базовый объект с заголовком, текстом и данными.

Получение токена (receiveToken) в HMS и Firebase идентично по логике, но использует разные API. Поэтому в основной директории (main) пишем базовый сервис, а реализуем его в поддиректориях.

Для Google (android/app/src/google/kotlin/com/rnnotifications/notifications/RemoteMessagingServiceImpl.kt):

package com.rnnotifications.notifications

import android.content.Context
import android.content.Intent
import com.google.firebase.messaging.FirebaseMessaging
import com.rnnotifcations.notifications.RemoteMessage
import com.rnnotifcations.notifications.RemoteMessagingService
import com.rnnotifcations.notifications.TokenResult
import com.google.firebase.messaging.RemoteMessage as FBRemoteMessage

class RemoteMessagingServiceImpl(private val context: Context) : RemoteMessagingService {
    override fun receiveToken(onComplete: (TokenResult) -> Unit) {
        FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
            if (task.isSuccessful) {
                onComplete(TokenResult.Success(task.result))
            } else {
                onComplete(TokenResult.Failure(task.exception))
            }
        }
    }

    override fun tryReadRemoteMessage(intent: Intent): RemoteMessage? = intent.extras?.let {
        if (
            it.getString("google.message_id") != null ||
            it.getString("message_id") != null
        ) {
            return@let FBRemoteMessage(it).toRemoteMessage()
        }

        return null
    }
}

И для Huawei (android/app/src/huawei/kotlin/com/rnnotifications/notifications/RemoteMessagingServiceImpl.kt)

package com.rnnotifications.notifications

import android.content.Context
import android.content.Intent
import com.huawei.agconnect.AGConnectOptionsBuilder
import com.huawei.hms.aaid.HmsInstanceId
import com.rnnotifcations.notifications.RemoteMessage
import com.rnnotifcations.notifications.RemoteMessagingService
import com.rnnotifcations.notifications.TokenResult
import com.huawei.hms.push.RemoteMessage as HmsRemoteMessage

class RemoteMessagingServiceImpl(private val context: Context) : RemoteMessagingService {
    override fun receiveToken(onComplete: (result: TokenResult) -> Unit) {
        try {
            val appId =
                AGConnectOptionsBuilder().build(context).getString("client/app_id")

            val token = HmsInstanceId.getInstance(context).getToken(appId, "HSM")
            onComplete(TokenResult.Success(token))
        } catch (ex: Exception) {
            onComplete(TokenResult.Failure(ex))
        }
    }

    override fun tryReadRemoteMessage(intent: Intent): RemoteMessage? =
        intent.extras?.let {
            HmsRemoteMessage(it).toRemoteMessage()
        }
}

Следующий компонент — BackgroundMessagingHandler. По сигнатуре он похож на возможности сервисов. По сути, он просто вызывает методы эмиттера. Это удобно: вся логика будет написана в одном месте, а из сервиса мы вызовем только метод handlerа. Возможно, вам покажется это избыточным, у нас этот компонент используется для выполнения Background задач для поступивших уведомлений.

package com.rnnotifcations.notifications

class BackgroundMessagingHandler {
    fun onMessageReceived(message: RemoteMessage) {
        RemoteMessagingEventEmitter.shared.notify(RemoteMessagingEvent.NewMessage(message))
    }

    fun onNewToken(token: String) {
        RemoteMessagingEventEmitter.shared.notify(RemoteMessagingEvent.NewToken(token))
    }
}

Далее пишем сервисы — они выглядят достаточно просто для Firebase и HMS. В onMessageReceived сразу приводим входящее сообщение к нашему формату (функцию toRemoteMessage не показываю, но она просто тип RemoteMessage от Firebase и HMS к нашему, который мы описали выше). В onNewToken отправляем токен в эмиттер.

package com.rnnotifications.notifications

import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage as FBRemoteMessage
import com.rnnotifcations.notifications.BackgroundMessagingHandler

class BackgroundMessagingService : FirebaseMessagingService() {
    private val handler = BackgroundMessagingHandler()

    override fun onMessageReceived(message: FBRemoteMessage) {
        super.onMessageReceived(message)

        handler.onMessageReceived(message.toRemoteMessage())
    }

    override fun onNewToken(token: String) {
        handler.onNewToken(token)
    }
}

package com.rnnotifications.notifications

import com.huawei.hms.push.HmsMessageService
import com.rnnotifcations.notifications.BackgroundMessagingHandler
import com.huawei.hms.push.RemoteMessage as HmsRemoteMessage

class BackgroundMessagingService : HmsMessageService() {
    private val handler = BackgroundMessagingHandler()

    override fun onMessageReceived(message: HmsRemoteMessage?) {
        super.onMessageReceived(message)

        val preparedMessage = message?.toRemoteMessage() ?: return
        handler.onMessageReceived(preparedMessage)
    }

    override fun onNewToken(token: String?) {
        super.onNewToken(token)

        token ?: return
        handler.onNewToken(token)
    }
}

Для Huawei API чуть отличается: onMessageReceived здесь опциональный (в отличие от Firebase, где он обязателен), то же с onNewToken. Тем не менее общая логика у них есть, что и оправдывает отдельные классы. Каждый сервис регистрируем в AndroidManifest.xml. Их также можно создать отдельно для каждого flavor, в конечном итоге они все смерджатся в один. 

Для Google: 

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application tools:ignore="MissingApplicationIcon">
        <service
            android:name="com.rnnotifications.notifications.BackgroundMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>


Для Huawei: 

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application tools:ignore="MissingApplicationIcon">
        <service
            android:name="com.rnnotifications.notifications.BackgroundMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.huawei.push.action.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>

Наконец, пишем нативный модуль, у него всего два React-метода:

  • getToken — получает токен через сервис (реализованный для каждого стора в RemoteMessagingService);

  • getInitialNotification — аналогично, через tryReadRemoteMessage для чтения начального уведомления. 

Далее необходимо подписаться на EventEmitter, чтобы в JavaScript-части обрабатывать события вроде newMessage (для показа уведомлений) или notificationOpened (для реакции на открытие по пушу). Подписка реализуется в методах initialize (где мы подключаемся) и invalidate (где отписываемся). Для этого имплементируем интерфейс RemoteMessagingSubscriber — и, готово. 

И последнее, что нужно — научиться читать нотификацию, по которой пользователь открывает приложение. В MainActivity для этого используется метод onNewIntent, который отслеживает входящие интенты. React Native позволяет интегрировать его через модули, добавив интерфейс ActivityEventListener. 

Для реализации потребуется имплементировать onNewIntent (и опционально onActivityResult, но он нам не нужен). В onNewIntent обрабатываем интент: если в нем есть подходящее сообщение, отправляем событие в JS через sendEvent. 

Итоговый код:  

package com.rnnotifcations.notifications

import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context.NOTIFICATION_SERVICE
import android.content.Intent
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.rnnotifications.notifications.RemoteMessagingServiceImpl

class RemoteMessagingModule(reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext), RemoteMessagingSubscriber, ActivityEventListener {
    private val remoteMessaging = RemoteMessagingServiceImpl(reactContext)
    override fun getName(): String = "RemoteMessagingModule"

    @ReactMethod
    fun getToken(promise: Promise) {
        remoteMessaging.receiveToken { result ->
            when (result) {
                is TokenResult.Success -> {
                    promise.resolve(result.token)
                }

                is TokenResult.Failure -> {
                    promise.reject(result.exception ?: Exception("Token not received"))
                }
            }
        }
    }

    @ReactMethod
    fun getInitialNotification(promise: Promise) {
        currentActivity?.intent?.let {
            remoteMessaging.tryReadRemoteMessage(it)
        }?.let {
            promise.resolve(it.toWritableMap())
        }
    }

    private fun sendEvent(
        eventName: String,
        params: WritableMap?
    ) {
        if (!reactApplicationContext.hasActiveReactInstance()) {
            return
        }

        reactApplicationContext
            .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
            .emit(eventName, params)
    }

    override fun onEvent(event: RemoteMessagingEvent) {
        when (event) {
            is RemoteMessagingEvent.NewToken -> {
                sendEvent("NewToken", Arguments.createMap().apply {
                    putString("token", event.token)
                })
            }

            is RemoteMessagingEvent.NewMessage -> {
                sendEvent("NewMessage", event.message.toWritableMap())
            }
        }
    }

    @ReactMethod
    fun addListener(eventName: String?) {
    }

    @ReactMethod
    fun removeListeners(count: Int?) {
    }

    override fun onNewIntent(intent: Intent?) {
        intent ?: return

        remoteMessaging.tryReadRemoteMessage(intent)?.let { remoteMessage ->
            sendEvent("MessageOpened", remoteMessage.toWritableMap())
        }
    }

    override fun initialize() {
        super.initialize()
        RemoteMessagingEventEmitter.shared.subscribe(this)
    }

    override fun invalidate() {
        super.invalidate()
        RemoteMessagingEventEmitter.shared.unsubscribe(this)
    }

    override fun onActivityResult(p0: Activity?, p1: Int, p2: Int, p3: Intent?) {

    }

}

В React Native все выглядит просто: описываем типы событий, подключаем подписку через NativeEventEmitter и используем там, где нужно. 

import {NativeModules, NativeEventEmitter} from 'react-native';
import {RemoteMessage} from './types';

const {RemoteMessagingModule: RemoteMessagingModuleNative} = NativeModules;

const eventEmitter = new NativeEventEmitter(RemoteMessagingModuleNative);

interface IModule {
  requestPermissions(): Promise<boolean>;
  getToken(): Promise<string>;
  getInitialNotification(): Promise<RemoteMessage>;
  onTokenChanged: (cb: (newToken: string) => void) => () => void;
  onMessageOpenedApp: (
    cb: (remoteMessage: RemoteMessage) => void,
  ) => () => void;
  onNewMessage: (cb: (remoteMessage: RemoteMessage) => void) => () => void;
}

export const RemoteMessagingModule: IModule = {
  requestPermissions: () => RemoteMessagingModuleNative.requestPermissions(),
  getToken: () => RemoteMessagingModuleNative.getToken(),
  getInitialNotification: () =>
    RemoteMessagingModuleNative.getInitialNotification(),
  onTokenChanged: cb => {
    const sub = eventEmitter.addListener('NewToken', ({token}) => {
      cb(token);
    });

    return () => sub.remove();
  },
  onMessageOpenedApp: cb => {
    const sub = eventEmitter.addListener('MessageOpened', message => {
      cb(message);
    });

    return () => sub.remove();
  },
  onNewMessage: cb => {
    const sub = eventEmitter.addListener('NewMessage', message => {
      cb(message);
    });

    return () => sub.remove();
  },
};

Далее, раз мы пишем модуль на RN, нельзя забывать про iOS. Тем более мы мигрировали наше RN приложение c Swift UI, ребята уже использовали APNS, поэтому нужно было также его использовать. Мы тоже будем использовать Swift для написания функционала.

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

struct Notification {
  let title: String
  let body: String
}

struct RemoteMessage {
  let id: String
  let data: [AnyHashable: Any]
  let notification: Notification?
}

Делаем TokenHolder, который будет хранить APNS токен, так как его (токен) можно получить только через callback:

class TokenHolder {
  static let shared = TokenHolder()
  
  private var _token: String? = ""
  
  var token: String? {
    get {
      return _token
    }
    
    set {
      _token = newValue
    }
  }
}

Пишем EventEmitter, по аналогии с Android:

import Foundation

enum RemoteMessagingEvent {
  case newToken(String)
  case newMessage(RemoteMessage)
  case notificationOpened(RemoteMessage)
}

protocol RemoteMessagingObserver {
  var id: String { get }
  func onEvent(event: RemoteMessagingEvent)
}

class RemoteMessagingEventEmitter {
  static let shared = RemoteMessagingEventEmitter()
  
  private var observers: [RemoteMessagingObserver] = []
  
  func subscribe(_ emitter: RemoteMessagingObserver) {
    observers.append(emitter)
  }
  
  func unsubscribe(_ emitter: RemoteMessagingObserver) {
    observers = observers.filter { $0.id != emitter.id }
  }
  
  func notify(event: RemoteMessagingEvent) {
    observers.forEach { $0.onEvent(event: event) }
  }
}

Далее, если ваш AppDelegate написан на Objective С (как у нас) можно написать фасад для работы с ним (если у вас на swift, можно сразу писать там):

import Foundation
import UIKit

@objc(RemoteMessagingFacade)
class RemoteMessagingFacade : NSObject, UNUserNotificationCenterDelegate {
  @objc static let shared = RemoteMessagingFacade()
  
  @objc func setup(_ application: UIApplication) {
    UNUserNotificationCenter.current().delegate = self
    
    application.registerForRemoteNotifications()
  }
  
  @objc func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable : Any],
    fetchCompletionHandler completionHandler: @escaping (
      UIBackgroundFetchResult
    ) -> Void
  ) {
    RemoteMessagingEventEmitter.shared.notify(event: .newMessage(userInfo.toRemoteMessage()))
    completionHandler(.noData)
  }
  
  @objc func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
  ) {
    let tokenParts = deviceToken.map { data -> String in
      return String(format: "%02.2hhx", data)
    }
    
    let token = tokenParts.joined()
    TokenHolder.shared.token = token
    
    RemoteMessagingEventEmitter.shared.notify(event: .newToken(token))
  }
  
  @objc func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse,
    withCompletionHandler completionHandler: @escaping () -> Void
  ) {
    RemoteMessagingEventEmitter.shared.notify(event: .notificationOpened(response.notification.request.content.userInfo.toRemoteMessage()))
    completionHandler()
  }
}

fileprivate extension Dictionary<AnyHashable, Any> {
  private enum Keys {
    static let id = "notification_id"
    static let aps = "aps"
    static let alert = "alert"
    static let data = "data"
    static let notification = "notification"
  }
  
  func toRemoteNotification() -> Notification? {
    let title = self["title"] as? String
    let body = self["body"] as? String
    
    if title == nil, body == nil {
      return nil
    }
    
    return Notification(title: title ?? "", body: body ?? "")
  }
  
  func toRemoteMessage() -> RemoteMessage {
    let messageId = (self[Keys.id] as? String) ?? UUID().uuidString
    let data = self[Keys.data] as? [AnyHashable: Any]
    let notification = (self[Keys.aps] as? [AnyHashable: Any])?[Keys.alert] as? [AnyHashable: Any]
    
    return RemoteMessage(
      id: messageId,
      data: data ?? [:],
      notification: notification?.toRemoteNotification()
    )
  }
}
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <UserNotifications/UserNotifications.h>

NS_ASSUME_NONNULL_BEGIN

@interface RemoteMessagingFacade : NSObject

+ (instancetype)shared;

- (void)setup: (UIApplication*) application;

- (void)application:(UIApplication *)application
didReceiveRemoteNotification:(NSDictionary *)userInfo
fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler;

- (void)application:(UIApplication *)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken;

- (void) userNotificationCenter:(UNUserNotificationCenter *) center
 didReceiveNotificationResponse:(UNNotificationResponse *) response
          withCompletionHandler:(void (^)()) completionHandler;

@end

NS_ASSUME_NONNULL_END

В AppDelegate подключаем фасад: 

  • в didFinishLaunchingWithOptions вызываем setup для инициализации; 

  • в didReceiveRemoteNotification — обработка входящих уведомлений; 

  • в didRegisterForRemoteNotificationsWithDeviceToken — получение токена

#import "AppDelegate.h"

#import <React/RCTBundleURLProvider.h>
#import "RemoteMessagingFacade.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.moduleName = @"RNNotifcations";
  // You can add your custom initial props in the dictionary below.
  // They will be passed down to the ViewController used by React Native.
  self.initialProps = @{};
  [[RemoteMessagingFacade shared] setup: application];
  
  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [self bundleURL];
}

- (NSURL *)bundleURL
{
#if DEBUG
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
  return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
  [[RemoteMessagingFacade shared] application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [[RemoteMessagingFacade shared] application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

@end

Модуль для iOS имеет три метода — это больше, чем на Android: 

  • requestPermissions (поскольку в iOS пуши без пермишенов приходят, но не отображаются);

  • getToken;

  • getInitialNotification (последний добавим в репозитории). 

Подписка на события в startObserving/stopObserving происходит через RemoteMessagingEventEmitter:

import Foundation
import React

@objc(RemoteMessagingModule)
class RemoteMessagingModule : RCTEventEmitter, RemoteMessagingObserver {
  var id: String {
    get {
      return "RemoteMessagingModule"
    }
  }
  
  func onEvent(event: RemoteMessagingEvent) {
    switch event {
    case .newToken(let newToken):
      sendEvent(withName: "NewToken", body: ["token": newToken])
      break
    case .newMessage(let message):
      sendEvent(withName: "NewMessage", body: message.toDict())
      break
      
    case .notificationOpened(let message):
      sendEvent(withName: "MessageOpened", body: message.toDict())
      break
    }
  }
  
  @objc func requestPermissions(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
    UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { result, error in
      if let error {
        reject("Error", "Error request permissions", error)
      } else {
        resolve(result)
      }
    }
  }
  
  @objc func getToken(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
    resolve(TokenHolder.shared.token)
  }
  
  override func supportedEvents() -> [String]! {
    return ["NewToken", "NewMessage", "MessageOpened"]
  }
  
  override func startObserving() {
    RemoteMessagingEventEmitter.shared.subscribe(self)
  }
  
  override func stopObserving() {
    RemoteMessagingEventEmitter.shared.unsubscribe(self)
  }
  
  @objc override public static func requiresMainQueueSetup() -> Bool {
    return false
  }
}

Ссылка на репозиторий. В ветке native можно посмотреть решение на основе нативного модуля, в ветке rn_community — на основе open source решений, соответственно. Если у вас остались вопросы, с удовольствием на них отвечу. 

Комментарии (0)