React Native 推播通知功能實作

Ashley Hsueh
55 min readApr 2, 2023

--

Photo by Jamie Street on Unsplash

這次要實作的功能是在 app 推播通知,主要是透過 React Native Firebasenotifee 來進行。
環境設定如下:

System:
OS: macOS 13.0.1
CPU: (8) arm64 Apple M1
Memory: 67.52 MB / 16.00 GB
Shell: 5.8.1 - /bin/zsh
Binaries:
Node: 16.14.2 - ~/.nvm/versions/node/v16.14.2/bin/node
Yarn: 1.22.19 - /opt/homebrew/bin/yarn
npm: 8.19.2 - ~/.nvm/versions/node/v16.14.2/bin/npm
Watchman: 2022.07.04.00 - /opt/homebrew/bin/watchman
Managers:
CocoaPods: 1.11.3 - /opt/homebrew/bin/pod
SDKs:
iOS SDK:
Platforms: DriverKit 21.4, iOS 16.0, macOS 12.3, tvOS 16.0, watchOS 9.0
Android SDK:
API Levels: 27, 28, 29, 30, 31, 32, 33
Build Tools: 19.1.0, 20.0.0, 21.1.2, 22.0.1, 23.0.1, 23.0.2, 23.0.3, 24.0.0, 24.0.1, 24.0.2, 24.0.3, 25.0.0, 25.0.1, 25.0.2, 25.0.3, 26.0.0, 26.0.1, 26.0.2, 26.0.3, 27.0.0, 27.0.1, 27.0.2, 27.0.3, 28.0.0, 28.0.1, 28.0.2, 28.0.3, 29.0.0, 29.0.1, 29.0.2, 29.0.3, 30.0.0, 30.0.1, 30.0.2, 30.0.3, 31.0.0, 32.0.0, 32.1.0, 33.0.0
System Images: android-28 | Google APIs Intel x86 Atom_64, android-28 | Google ARM64-V8a Play ARM 64 v8a, android-29 | Intel x86 Atom_64, android-29 | Google APIs Intel x86 Atom, android-29 | Google Play Intel x86 Atom_64, android-30 | Intel x86 Atom_64, android-30 | Google APIs Intel x86 Atom_64, android-30 | Google Play ARM 64 v8a, android-30 | Google Play Intel x86 Atom, android-31 | Google Play ARM 64 v8a, android-31 | Google Play Intel x86 Atom_64, android-32 | Google APIs ARM 64 v8a, android-33 | Google APIs ARM 64 v8a, android-33 | Google APIs Intel x86 Atom_64, android-33 | Google Play ARM 64 v8a, android-33 | Google Play Intel x86 Atom_64, android-TiramisuPrivacySandbox | Google Play ARM 64 v8a, android-TiramisuPrivacySandbox | Google Play Intel x86 Atom_64
Android NDK: 22.1.7171670
IDEs:
Android Studio: 2021.2 AI-212.5712.43.2112.8609683
Xcode: 14.0.1/14A400 - /usr/bin/xcodebuild
Languages:
Java: 11.0.15 - /usr/bin/javac
npmPackages:
@react-native-community/cli: Not Found
react: 18.1.0 => 18.1.0
react-native: 0.70.6 => 0.70.6
react-native-macos: Not Found
npmGlobalPackages:
*react-native*: Not Found

環境設定:
安裝 React Native Firebase “app” & “messaging”

# Using npm
npm install --save @react-native-firebase/app

# Using Yarn
yarn add @react-native-firebase/app

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

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

設定 IOS 環境

  1. Firebase 新增一個專案

2. 專案建立完成即可開始在首頁中加入第一個 ios app

3. 下載 GoogleService-Info.plist file,並在 Xcode 中打開`/ios/{projectName}.xcodeproj file (or /ios/{projectName}.xcworkspace if using Pods).
把剛剛下載的 GoogleService-Info.plist 新增到專案中:

4. 新增 Firebase iOS SDK

5. 勾選 FirebaseMessaging

6. 打開 /ios/{projectName}/AppDelegate.mm file (或舊一點的RN版本是AppDelegate.m
在檔案上方 import the Firebase SDK

#import <Firebase.h>

再來找到 didFinishLaunchingWithOptions method 在裡面加入以下code:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Add me - - \/
[FIRApp configure];
// Add me - - /\
// …
}

7. 設定 frameworks

這裡會有個問題,目前使用 use_frameworks 根據 React Native 的版本不同會分別跟 Flipper, Hermes & Fabric 衝突
詳細設定跟討論可以參考 https://github.com/reactwg/react-native-releases/discussions/21#discussioncomment-2924919

8. 在 Xcode 中設定 Push Notifications
點選 project target > Signing & Capabilities > + Capabilities > Push Notifications

9. 除了要可以推播通知以外,我們也希望使用者可以在背景模式下可以收到訊息,這時就還要打開 Background Modes

勾選 Background fetch、Remote notifications 這兩個選項

10. 連結 APNs(Apple Push Notification service) 與 firebase message IOS

這裡會需要用到蘋果的開發者帳號(需付費)
詳細設定可以參考: https://rnfirebase.io/messaging/usage/ios-setup#linking-apns-with-fcm-ios

設定 Android 環境

  1. 新增 Android app 到 Firebase 專案

下載 google-services.json file 並放置於 /android/app/google-services.json

以下我的設定,供參考
android > app > build.gradle

apply plugin: "com.android.application"
apply plugin: "com.google.gms.google-services"
import com.android.build.OutputFile
import org.apache.tools.ant.taskdefs.condition.Os

/**
* The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets
* and bundleReleaseJsAndAssets).
* These basically call `react-native bundle` with the correct arguments during the Android build
* cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the
* bundle directly from the development server. Below you can see all the possible configurations
* and their defaults. If you decide to add a configuration block, make sure to add it before the
* `apply from: "../../node_modules/react-native/react.gradle"` line.
*
* project.ext.react = [
* // the name of the generated asset file containing your JS bundle
* bundleAssetName: "index.android.bundle",
*
* // the entry file for bundle generation. If none specified and
* // "index.android.js" exists, it will be used. Otherwise "index.js" is
* // default. Can be overridden with ENTRY_FILE environment variable.
* entryFile: "index.android.js",
*
* // https://reactnative.dev/docs/performance#enable-the-ram-format
* bundleCommand: "ram-bundle",
*
* // whether to bundle JS and assets in debug mode
* bundleInDebug: false,
*
* // whether to bundle JS and assets in release mode
* bundleInRelease: true,
*
* // whether to bundle JS and assets in another build variant (if configured).
* // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants
* // The configuration property can be in the following formats
* // 'bundleIn${productFlavor}${buildType}'
* // 'bundleIn${buildType}'
* // bundleInFreeDebug: true,
* // bundleInPaidRelease: true,
* // bundleInBeta: true,
*
* // whether to disable dev mode in custom build variants (by default only disabled in release)
* // for example: to disable dev mode in the staging build type (if configured)
* devDisabledInStaging: true,
* // The configuration property can be in the following formats
* // 'devDisabledIn${productFlavor}${buildType}'
* // 'devDisabledIn${buildType}'
*
* // the root of your project, i.e. where "package.json" lives
* root: "../../",
*
* // where to put the JS bundle asset in debug mode
* jsBundleDirDebug: "$buildDir/intermediates/assets/debug",
*
* // where to put the JS bundle asset in release mode
* jsBundleDirRelease: "$buildDir/intermediates/assets/release",
*
* // where to put drawable resources / React Native assets, e.g. the ones you use via
* // require('./image.png')), in debug mode
* resourcesDirDebug: "$buildDir/intermediates/res/merged/debug",
*
* // where to put drawable resources / React Native assets, e.g. the ones you use via
* // require('./image.png')), in release mode
* resourcesDirRelease: "$buildDir/intermediates/res/merged/release",
*
* // by default the gradle tasks are skipped if none of the JS files or assets change; this means
* // that we don't look at files in android/ or ios/ to determine whether the tasks are up to
* // date; if you have any other folders that you want to ignore for performance reasons (gradle
* // indexes the entire tree), add them here. Alternatively, if you have JS files in android/
* // for example, you might want to remove it from here.
* inputExcludes: ["android/**", "ios/**"],
*
* // override which node gets called and with what additional arguments
* nodeExecutableAndArgs: ["node"],
*
* // supply additional arguments to the packager
* extraPackagerArgs: []
* ]
*/

project.ext.react = [
enableHermes: true, // clean and rebuild if changing
]

apply from: "../../node_modules/react-native/react.gradle"

/**
* Set this to true to create two separate APKs instead of one:
* - An APK that only works on ARM devices
* - An APK that only works on x86 devices
* The advantage is the size of the APK is reduced by about 4MB.
* Upload all the APKs to the Play Store and people will download
* the correct one based on the CPU architecture of their device.
*/
def enableSeparateBuildPerCPUArchitecture = false

/**
* Run Proguard to shrink the Java bytecode in release builds.
*/
def enableProguardInReleaseBuilds = false

/**
* The preferred build flavor of JavaScriptCore.
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'org.webkit:android-jsc:+'

/**
* Whether to enable the Hermes VM.
*
* This should be set on project.ext.react and that value will be read here. If it is not set
* on project.ext.react, JavaScript will not be compiled to Hermes Bytecode
* and the benefits of using Hermes will therefore be sharply reduced.
*/
def enableHermes = project.ext.react.get("enableHermes", false);

/**
* Architectures to build native code for.
*/
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}

android {
ndkVersion rootProject.ext.ndkVersion

compileSdkVersion rootProject.ext.compileSdkVersion

defaultConfig {
applicationId "com.notify"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()

if (isNewArchitectureEnabled()) {
// We configure the CMake build only if you decide to opt-in for the New Architecture.
externalNativeBuild {
cmake {
arguments "-DPROJECT_BUILD_DIR=$buildDir",
"-DREACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
"-DREACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build",
"-DNODE_MODULES_DIR=$rootDir/../node_modules",
"-DANDROID_STL=c++_shared"
}
}
if (!enableSeparateBuildPerCPUArchitecture) {
ndk {
abiFilters (*reactNativeArchitectures())
}
}
}
}

if (isNewArchitectureEnabled()) {
// We configure the NDK build only if you decide to opt-in for the New Architecture.
externalNativeBuild {
cmake {
path "$projectDir/src/main/jni/CMakeLists.txt"
}
}
def reactAndroidProjectDir = project(':ReactAndroid').projectDir
def packageReactNdkDebugLibs = tasks.register("packageReactNdkDebugLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkDebugLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
def packageReactNdkReleaseLibs = tasks.register("packageReactNdkReleaseLibs", Copy) {
dependsOn(":ReactAndroid:packageReactNdkReleaseLibsForBuck")
from("$reactAndroidProjectDir/src/main/jni/prebuilt/lib")
into("$buildDir/react-ndk/exported")
}
afterEvaluate {
// If you wish to add a custom TurboModule or component locally,
// you should uncomment this line.
// preBuild.dependsOn("generateCodegenArtifactsFromSchema")
preDebugBuild.dependsOn(packageReactNdkDebugLibs)
preReleaseBuild.dependsOn(packageReactNdkReleaseLibs)

// Due to a bug inside AGP, we have to explicitly set a dependency
// between configureCMakeDebug* tasks and the preBuild tasks.
// This can be removed once this is solved: https://issuetracker.google.com/issues/207403732
configureCMakeRelWithDebInfo.dependsOn(preReleaseBuild)
configureCMakeDebug.dependsOn(preDebugBuild)
reactNativeArchitectures().each { architecture ->
tasks.findByName("configureCMakeDebug[${architecture}]")?.configure {
dependsOn("preDebugBuild")
}
tasks.findByName("configureCMakeRelWithDebInfo[${architecture}]")?.configure {
dependsOn("preReleaseBuild")
}
}
}
}

splits {
abi {
reset()
enable enableSeparateBuildPerCPUArchitecture
universalApk false // If true, also generate a universal APK
include (*reactNativeArchitectures())
}
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}

// applicationVariants are e.g. debug, release
applicationVariants.all { variant ->
variant.outputs.each { output ->
// For each separate APK per architecture, set a unique version code as described here:
// https://developer.android.com/studio/build/configure-apk-splits.html
// Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc.
def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4]
def abi = output.getFilter(OutputFile.ABI)
if (abi != null) { // null for the universal-debug, universal-release variants
output.versionCodeOverride =
defaultConfig.versionCode * 1000 + versionCodes.get(abi)
}

}
}
}

dependencies {
implementation platform("com.google.firebase:firebase-bom:31.1.0")
implementation platform("com.google.firebase:firebase-messaging:23.1.0")

implementation fileTree(dir: "libs", include: ["*.jar"])

//noinspection GradleDynamicVersion
implementation "com.facebook.react:react-native:+" // From node_modules

implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
implementation 'com.google.firebase:firebase-analytics'
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
exclude group:'com.facebook.fbjni'
}

debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
exclude group:'com.squareup.okhttp3', module:'okhttp'
}

debugImplementation("com.facebook.flipper:flipper-fresco-plugin:${FLIPPER_VERSION}") {
exclude group:'com.facebook.flipper'
}

if (enableHermes) {
//noinspection GradleDynamicVersion
implementation("com.facebook.react:hermes-engine:+") { // From node_modules
exclude group:'com.facebook.fbjni'
}
} else {
implementation jscFlavor
}
}

if (isNewArchitectureEnabled()) {
// If new architecture is enabled, we let you build RN from source
// Otherwise we fallback to a prebuilt .aar bundled in the NPM package.
// This will be applied to all the imported transtitive dependency.
configurations.all {
resolutionStrategy.dependencySubstitution {
substitute(module("com.facebook.react:react-native"))
.using(project(":ReactAndroid"))
.because("On New Architecture we're building React Native from source")
substitute(module("com.facebook.react:hermes-engine"))
.using(project(":ReactAndroid:hermes-engine"))
.because("On New Architecture we're building Hermes from source")
}
}
}

// Run this once to be able to run the application with BUCK
// puts all compile dependencies into folder libs for BUCK to use
task copyDownloadableDepsToLibs(type: Copy) {
from configurations.implementation
into 'libs'
}

apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)

def isNewArchitectureEnabled() {
// To opt-in for the New Architecture, you can either:
// - Set `newArchEnabled` to true inside the `gradle.properties` file
// - Invoke gradle with `-newArchEnabled=true`
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}

android > build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
ext {
buildToolsVersion = "31.0.0"
minSdkVersion = 21
compileSdkVersion = 31
targetSdkVersion = 31

if (System.properties['os.arch'] == "aarch64") {
// For M1 Users we need to use the NDK 24 which added support for aarch64
ndkVersion = "24.0.8215888"
} else {
// Otherwise we default to the side-by-side NDK version from AGP.
ndkVersion = "21.4.7075529"
}
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:7.2.1")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("de.undercouch:gradle-download-task:5.0.1")
classpath("com.google.gms:google-services:4.3.13")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
maven {
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
url("$rootDir/../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
url("$rootDir/../node_modules/jsc-android/dist")
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
// older versions over there.
content {
excludeGroup "com.facebook.react"
}
}
google()
maven { url 'https://www.jitpack.io' }
}
}

拆解通知功能

最基本的通知功能應用可以分成以下幾個階段:

  1. 檢查 App 是否開啟通知
  2. 取得該裝置的 FCM token
  3. 解析通知所傳送的資料
  4. 使用者關閉通知權限

檢查 App 通知的權限

  1. 安裝 notifee
# Using npm
npm install --save @notifee/react-native

# Using Yarn
yarn add @notifee/react-native

這裡必須特別注意Android 的 build.gradle 要設定好 sdkVersion 分別 compileSdkVersion = 33 、 targetSdkVersion = 33

buildToolsVersion = "33.0.0"
minSdkVersion = 21
compileSdkVersion = 33
targetSdkVersion = 33

2. 使用 notifee requestPermission method 去取得目前 app 在手機上的通知權限,確定有通知的權限才可以在用戶的裝置上顯示通知。

app/utilities/permisstion.ts

import notifee, {AuthorizationStatus} from '@notifee/react-native';

export const getNotifyPermission = async () => {
const settings = await notifee.requestPermission();
if (settings.authorizationStatus === AuthorizationStatus.DENIED) {
// App 未獲得推播通知的授權
console.log('User denied permissions request');
} else if (settings.authorizationStatus === AuthorizationStatus.AUTHORIZED) {
// App 獲得推播通知的授權
console.log('User granted permissions request');
} else if (settings.authorizationStatus === AuthorizationStatus.PROVISIONAL) {
//The app is currently authorized to post non-interrupting user notifications
console.log('User provisionally granted permissions request');
}
};

app/container/Home.tsx

import React, {useEffect} from 'react';
import {Button, Text, View} from 'react-native';
import {HomeProps} from '../navigation/AppNavigation';
import {getNotifyPermission} from '../utilities/permisstion';

const Home: React.FC<HomeProps> = ({navigation}) => {
useEffect(() => {
getNotifyPermission();
}, []);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<Text>Home Screen</Text>
<Button
title="Go to Detail"
onPress={() => navigation.navigate('Detail', {})}
/>
</View>
);
};
export default Home;

註冊 FMC Token

接下來我們要取的 FMC Token 來發送訊息。FCM Token 指的是 Firebase Messaging Cloud token,可以用Firebase Messaging 傳送 Android 和 Ios 通知。
每一個裝置上的 APP,只會有一組 FMC Token,並且是獨特、不重複的,當然 Token 也會過期。

app/utilities/notifyHelper.ts

import AsyncStorage from '@react-native-async-storage/async-storage';
import messaging from '@react-native-firebase/messaging';

export async function getFCMToken() {
//檢查裝置是否已有 fcmToken
const fcmToken = await AsyncStorage.getItem('@fcmToken');
if (!fcmToken || fcmToken === null) {
try {
//取得 fcmToken
let token = await messaging().getToken();
//儲存 fcmToken
await AsyncStorage.setItem('@fcmToken', token);
} catch (error) {
console.log(error, 'error in fcmToken');
}
}
}

app/utilities/permisstion.ts

import notifee, {AuthorizationStatus} from '@notifee/react-native';
import {getFCMToken} from './notifyHelper';

export const getNotifyPermission = async () => {
const settings = await notifee.requestPermission();
if (
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL
) {
//如果檢查裝置有通知的權限可以繼續取得 FCMToken
getFCMToken();
}
};

發送訊息

使用 Firebase Messaging 後台

設定通知的內容和標題

這邊的發布的通知並不是很即時,因此如果要測試通知發布的功能,我會使用下方的工具。

FCM Tester

取得 Server Key

在 Firebase 的專案設定>雲端通訊找到 Cloud Messaging API (舊版)點選右方的冒號啟用。

伺服器金鑰把他複製起來帶入Server Key

填好通知標題跟內容,再把通知發送出去,如果Server Key、FCM Registration Token (Device Token)設定正確的話應該就可以在手機上收到通知囉

取得通知的資料 event

可以發送通知後我們需要解析通知所傳送的資料,這裡依據 App 的開啟的狀況會有三種情境,分別是:

  1. Foreground state: App 正在裝置上打開
  2. background state: App 目前在手機的背景中
  3. Quit state: ios App 沒有被打開過且不在背景中

On Foreground state event

使用 messaging().onMessage method 可以在 app 開啟時解析通知所夾帶的資料。

App.tsx

import messaging, {
FirebaseMessagingTypes,
} from '@react-native-firebase/messaging';
import {NavigationContainer} from '@react-navigation/native';
import React, {useEffect} from 'react';
import {StatusBar, useColorScheme} from 'react-native';
import {Colors} from 'react-native/Libraries/NewAppScreen';
import Main from './app/navigation/AppNavigation';

const App = () => {
const isDarkMode = useColorScheme() === 'dark';

const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
};

useEffect(() => {
const unsubscribe = messaging().onMessage(
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
console.log('onMessage remoteMessage:', remoteMessage);
/**
* onMessage remoteMessage: {"collapseKey": "com.notify", "data": {}, "from": "631481450657", "messageId": "0:1669885365852292%4059385640593856", "notification": {"android": {}, "body": "測試", "title": "訊息通知"}, "sentTime": 1669885365830, "ttl": 2419200}
**/
},
);
return unsubscribe;
}, []);

return (
<NavigationContainer>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<Main />
</NavigationContainer>
);
};

export default App;

On background state event

messaging().setBackgroundMessageHandler
當 app 在背景時,透過這個 event 可以取得通知的資料,並執行。

index.js

import messaging from '@react-native-firebase/messaging';
import {AppRegistry} from 'react-native';
import App from './App';
import {name as appName} from './app.json';

messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log('Message handled in the background!:', remoteMessage);
/** Message handled in the background!: {"collapseKey": "com.notify", "data": {}, "from": "631481450657", "messageId": "0:1669886507036799%4059385640593856", "notification": {"android": {}, "body": "測試", "title": "background 訊息通知"}, "sentTime": 1669886507014, "ttl": 2419200} */
});

AppRegistry.registerComponent(appName, () => App);

App open events

https://notifee.app/react-native/docs/events#app-open-events
當 ios app 不在背景時,可以參考以下是做法取得通知的資料

import notifee from '@notifee/react-native';
import messaging, {
FirebaseMessagingTypes,
} from '@react-native-firebase/messaging';
import {NavigationContainer} from '@react-navigation/native';
import React, {useEffect, useState} from 'react';
import {StatusBar, useColorScheme} from 'react-native';
import {Colors} from 'react-native/Libraries/NewAppScreen';
import Main from './app/navigation/AppNavigation';

const App = () => {
const [loading, setLoading] = useState(true);
const isDarkMode = useColorScheme() === 'dark';

const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
};

useEffect(() => {
const unsubscribe = messaging().onMessage(
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
console.log('onMessage remoteMessage:', remoteMessage);
/**
* onMessage remoteMessage: {"collapseKey": "com.notify", "data": {}, "from": "631481450657", "messageId": "0:1669885365852292%4059385640593856", "notification": {"android": {}, "body": "測試", "title": "訊息通知"}, "sentTime": 1669885365830, "ttl": 2419200}
**/
},
);
return unsubscribe;
}, []);

// Bootstrap sequence function
async function bootstrap() {
const initialNotification = await notifee.getInitialNotification();

if (initialNotification) {
console.log(
'Notification caused application to open',
initialNotification.notification,
);
/**
* Notification caused application to open {"body": "測試", "data": {}, "id": "AA553D31-878F-4D1F-B5FA-1DE9E0517F09", "ios": {"categoryId": "", "launchImageName": "", "threadId": ""}, "remote": {"messageId": "1669888380306514", "senderId": "1040383152167"}, "title": "quit 訊息通知"}
*/
}
}

useEffect(() => {
bootstrap()
.then(() => setLoading(false))
.catch(console.error);
}, []);

if (loading) {
return null;
}

return (
<NavigationContainer>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<Main />
</NavigationContainer>
);
};

export default App;

刪除 FCM Token

當使用者關閉了通知權限,我們應該要把他的 FCM Token 給刪除並且通知 server 刪除 Token,才不會讓 server 持續發送收不到的通知,浪費效能。
一但在檢查通知權限時發現通知沒有被開啟,就要執行刪除。

app/utilities/permisstion.ts

import notifee, {AuthorizationStatus} from '@notifee/react-native';
import {deleteFCMToken, getFCMToken} from './notifyHelper';

export const getNotifyPermission = async () => {
const settings = await notifee.requestPermission();
if (
settings.authorizationStatus === AuthorizationStatus.AUTHORIZED ||
settings.authorizationStatus === AuthorizationStatus.PROVISIONAL
) {
getFCMToken();
} else {
deleteFCMToken();
}
};

app/utilities/notifyHelper.ts

import AsyncStorage from '@react-native-async-storage/async-storage';
import messaging from '@react-native-firebase/messaging';

export async function getFCMToken() {
//檢查裝置是否已有 fcmToken
const fcmToken = await AsyncStorage.getItem('@fcmToken');
if (!fcmToken || fcmToken === null) {
try {
//取得 fcmToken
let token = await messaging().getToken();
//儲存 fcmToken
await AsyncStorage.setItem('@fcmToken', token);
} catch (error) {
console.log(error, 'error in fcmToken');
}
}
}

export async function deleteFCMToken() {
//檢查裝置是否已有 fcmToken
const fcmToken = await AsyncStorage.getItem('@fcmToken');
if (fcmToken || fcmToken !== null) {
try {
//告訴 firebase server 此 fcmToken 已失效
await messaging().deleteToken();
// TODO: delete notificationToken api
await AsyncStorage.removeItem('@fcmToken');
} catch (error) {
console.log(error, 'error in fcmToken');
}
}
}

更新過期的 FCM Token

當 FCM Token 過期或 server 未驗證 FCM Token,使用 onTokenRefresh method,觸發了 event 後去刪除舊的 FCM Token 並將由 onTokenRefresh 所產生的新 FCM Token 儲存。

App.tsx

import notifee from '@notifee/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import messaging, {
FirebaseMessagingTypes,
} from '@react-native-firebase/messaging';
import {NavigationContainer} from '@react-navigation/native';
import React, {useEffect, useRef, useState} from 'react';
import {StatusBar, useColorScheme} from 'react-native';
import {Colors} from 'react-native/Libraries/NewAppScreen';
import Main from './app/navigation/AppNavigation';
import {deleteFCMToken} from './app/utilities/notifyHelper';

const App = () => {
const [loading, setLoading] = useState(true);
const unsubscribeTokenRefreshListenerRef = useRef<() => void>();
const isDarkMode = useColorScheme() === 'dark';

const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
};

useEffect(() => {
const unsubscribe = messaging().onMessage(
async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
console.log('onMessage remoteMessage:', remoteMessage);
/**
* onMessage remoteMessage: {"collapseKey": "com.notify", "data": {}, "from": "631481450657", "messageId": "0:1669885365852292%4059385640593856", "notification": {"android": {}, "body": "測試", "title": "訊息通知"}, "sentTime": 1669885365830, "ttl": 2419200}
**/
},
);
return unsubscribe;
}, []);

// Bootstrap sequence function
async function bootstrap() {
const initialNotification = await notifee.getInitialNotification();

if (initialNotification) {
console.log(
'Notification caused application to open',
initialNotification.notification,
);
/**
* Notification caused application to open {"body": "測試", "data": {}, "id": "AA553D31-878F-4D1F-B5FA-1DE9E0517F09", "ios": {"categoryId": "", "launchImageName": "", "threadId": ""}, "remote": {"messageId": "1669888380306514", "senderId": "1040383152167"}, "title": "quit 訊息通知"}
*/
}
}

useEffect(() => {
bootstrap()
.then(() => setLoading(false))
.catch(console.error);
}, []);

useEffect(() => {
unsubscribeTokenRefreshListenerRef.current?.();
const setNewFCMToken = async (fcmToken: string) => {
await AsyncStorage.setItem('@fcmToken', fcmToken);
};
unsubscribeTokenRefreshListenerRef.current = messaging().onTokenRefresh(
async newToken => {
await Promise.all([deleteFCMToken(), setNewFCMToken(newToken)]);
},
);
return () => {
unsubscribeTokenRefreshListenerRef.current?.();
};
}, []);

if (loading) {
return null;
}

return (
<NavigationContainer>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<Main />
</NavigationContainer>
);
};

export default App;

--

--