invertase / react-native-firebase

πŸ”₯ A well-tested feature-rich modular Firebase implementation for React Native. Supports both iOS & Android platforms for all Firebase services.
https://rnfirebase.io
Other
11.53k stars 2.18k forks source link

[πŸ›] IOS - Background notification received as foreground notification too #7836

Open sanduluca opened 2 weeks ago

sanduluca commented 2 weeks ago

Issue

We are encountering an issue where iOS users occasionally see twice the push notifications. First time the notification is displayed by the OS as we send a push notification with the notification key (not data only). The second time the notification is displayed using notifee but the problem is that we receive the same notification in messaging().onMessage after the app is opened.

We have configured a setBackgroundMessageHandler. We send a push notification with firebase admin sdk from our backend. The notification has title, body and data, content_available is true. We also have a listener for foreground push notification which looks like this:

useEffect(() => {
const unsubscribeFCMNotification = messaging().onMessage(message => {
    // ... some loging
    dispath(increment())

    // display the notification
    notifee.displayNotification({
        title: message.notification?.title,
        body: message.notification?.body,
        data: message.data,
        android: {
            sound: 'ding',
            channelId: 'general',
            smallIcon: 'ic_notification',
            color: 'red'
        },
        ios: {
            sound: 'ding.wav'
        }
        })
        .catch(() => {});
        });

return () => {
    // Clean up the event listeners
    unsubscribeFCMNotification();
};
}, [dispatch]);

// index.js
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
    await notifee.incrementBadgeCount()

    const displayedNotifications = await notifee.getDisplayedNotifications().catch(() => []);
    if (Platform.OS === 'ios') {
        // some async logic (workaroud for long sound on ios)
    }

    if (Platform.OS === 'andorid') {
        // some async logic (workaround: cancel older notification than X)
    }

    await keycloak.init(...)

    const { data, notification } = remoteMessage;

    if (data?.type === 'NEW_ORDER') {
        await api()
            .markOrderAsReceived({ orderId: data.orderId })
            .catch(() => {}); // <-------- here we make the http request
        return;
    }

    if (notification) {
        // If the message contains a notification property, it is automatically
        // handled by the OS when the app is not active. There is nothing you
        // can do to prevent that.
        return;
    }

    // Custom notification
    if (data?.notifee) {
        notifee.displayNotification(JSON.parse(data.notifee));
        return;
    }
});

Steps we use to reproduce (not consistently):

  1. Close the app (quit state)
  2. Send a push notification with title, body, data, content_available true
  3. Wait about 5-30 seconds (the sound ends and the notification goes to notification center) (our notification is 25s)
  4. Open the app (the notification may be shown again)

Remarks: If you close the phone screen on step 1, the chance to reproduce it seems higher


Project Files

Javascript

Click To Expand

#### `package.json`: ```json { "name": "app", "version": "0.0.1", "private": true, "dependencies": { "@gorhom/bottom-sheet": "^4.4.7", "@loadsmart/rn-salesforce-chat": "^3.4.1", "@miblanchard/react-native-slider": "^2.6.0", "@notifee/react-native": "^7.8.0", "@react-keycloak/native": "^0.6.4", "@react-native-async-storage/async-storage": "^1.19.3", "@react-native-clipboard/clipboard": "^1.12.1", "@react-native-community/art": "^1.2.0", "@react-native-community/netinfo": "^9.4.1", "@react-native-firebase/analytics": "^17.4.2", "@react-native-firebase/app": "^17.4.2", "@react-native-firebase/crashlytics": "^17.4.2", "@react-native-firebase/messaging": "^17.4.2", "@react-native-picker/picker": "^2.5.0", "@react-navigation/drawer": "^6.6.3", "@react-navigation/native": "^6.1.7", "@react-navigation/native-stack": "^6.9.13", "@react-navigation/stack": "^6.3.17", "@reduxjs/toolkit": "^1.9.7", "axios": "^1.3.6", "dotenv": "^16.3.1", "formik": "^2.4.3", "i18next": "^23.4.6", "immer": "^10.0.2", "jwt-decode": "^3.1.2", "lodash": "^4.17.21", "lottie-ios": "4.2.0", "lottie-react-native": "^6.2.0", "moment": "^2.29.1", "patch-package": "^8.0.0", "postinstall-postinstall": "^2.1.0", "react": "18.2.0", "react-error-boundary": "^4.0.11", "react-i18next": "^13.2.0", "react-native": "0.72.12", "react-native-background-fetch": "^4.2.0", "react-native-background-geolocation": "^4.12.1", "react-native-barcode-builder": "^2.0.0", "react-native-calendars": "^1.1300.0", "react-native-circular-progress": "^1.3.9", "react-native-code-push": "^8.1.0", "react-native-config": "^1.5.1", "react-native-device-info": "^10.8.0", "react-native-document-scanner-plugin": "^0.9.1", "react-native-encrypted-storage": "^4.0.2", "react-native-gesture-handler": "^2.16.0", "react-native-get-random-values": "^1.9.0", "react-native-image-crop-picker": "0.38.1", "react-native-inappbrowser-reborn": "^3.7.0", "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-map-link": "^2.11.2", "react-native-maps": "^1.7.1", "react-native-material-ripple": "^0.9.1", "react-native-modal-dropdown": "^1.0.2", "react-native-native-log": "^0.1.3", "react-native-notificated": "^0.1.5", "react-native-permissions": "^4.1.0", "react-native-picker-select": "^8.0.4", "react-native-reanimated": "^3.8.1", "react-native-safe-area-context": "^4.7.1", "react-native-screens": "^3.24.0", "react-native-simple-toast": "^3.1.0", "react-native-snap-carousel": "^3.9.1", "react-native-splash-screen": "^3.2.0", "react-native-svg": "^13.13.0", "react-native-svg-transformer": "^1.1.0", "react-native-swipe-list-view": "^3.2.9", "react-native-video": "^5.2.0", "react-redux": "^8.1.2", "reactotron-react-native": "^5.0.1", "reactotron-redux": "^3.1.3", "redux": "^4.1.0", "redux-persist": "^6.0.0", "redux-thunk": "^2.3.0", "reselect": "^4.1.8", "styled-components": "^6.0.7", "yarn": "^1.22.17", "yup": "^1.2.0" }, "devDependencies": { "@babel/core": "^7.22.11", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.22.11", "@react-native/eslint-config": "^0.72.2", "@react-native/metro-config": "^0.72.12", "@tsconfig/react-native": "^3.0.0", "@types/detox": "^18.1.0", "@types/jest": "^29.5.4", "@types/lodash": "^4.14.197", "@types/react": "^18.2.21", "@types/react-native": "^0.72.2", "@types/react-native-material-ripple": "^0.9.2", "@types/react-native-modal-dropdown": "^1.0.2", "@types/react-native-snap-carousel": "^3.8.5", "@types/react-native-vector-icons": "^6.4.14", "@types/react-native-video": "^5.0.15", "@types/react-test-renderer": "^18.0.0", "@types/styled-components": "^5.1.34", "@types/uuid": "^9.0.2", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", "babel-jest": "^29.6.4", "babel-plugin-module-resolver": "^5.0.0", "detox": "^20.18.5", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^3.6.0", "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-ft-flow": "^3.0.7", "eslint-plugin-import": "^2.28.1", "eslint-plugin-jest": "^27.2.3", "eslint-plugin-prettier": "^5.0.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-native": "^4.0.0", "form-data": "^4.0.0", "husky": "^8.0.3", "jest": "^29.6.4", "metro-react-native-babel-preset": "^0.76.9", "prettier": "3.0.2", "react-devtools": "^4.28.0", "react-test-renderer": "18.2.0", "typescript": "^5.1.6", "typescript-styled-plugin": "^0.18.3" }, "engines": { "node": ">=16" } } ``` #### `firebase.json` for react-native-firebase v6: ```json # N/A ```

iOS

Click To Expand

#### `ios/Podfile`: - [ ] I'm not using Pods - [x] I'm using Pods and my Podfile looks like: ```ruby # Resolve react_native_pods.rb with node to allow for hoisting def node_require(script) # Resolve script with node to allow for hoisting require Pod::Executable.execute_command('node', ['-p', "require.resolve( '#{script}', {paths: [process.argv[1]]}, )", __dir__]).strip end node_require('react-native/scripts/react_native_pods.rb') node_require('react-native-permissions/scripts/setup.rb') min_ios_version = "13.0" platform :ios, min_ios_version prepare_react_native_project! setup_permissions([ 'Camera', ]) source 'https://github.com/CocoaPods/Specs.git' source 'https://github.com/goinstant/pods-specs-public' # If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set. # because `react-native-flipper` depends on (FlipperKit,...) that will be excluded # # To fix this you can also exclude `react-native-flipper` using a `react-native.config.js` # ```js # module.exports = { # dependencies: { # ...(process.env.NO_FLIPPER ? { 'react-native-flipper': { platforms: { ios: null } } } : {}), # ``` flipper_config = ENV['NO_FLIPPER'] == "1" ? FlipperConfiguration.disabled : FlipperConfiguration.enabled linkage = ENV['USE_FRAMEWORKS'] if linkage != nil Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green use_frameworks! :linkage => linkage.to_sym end target 'App' do # React Native Maps dependencies rn_maps_path = '../node_modules/react-native-maps' pod 'react-native-google-maps', :path => rn_maps_path config = use_native_modules! # use_frameworks! :linkage => :static # Workaround for Firebase and Flipper to work together # https://github.com/invertase/react-native-firebase/issues/6425#issuecomment-1527949355 pod 'FirebaseCore', :modular_headers => true pod 'FirebaseCoreExtension', :modular_headers => true pod 'FirebaseInstallations', :modular_headers => true pod 'GoogleDataTransport', :modular_headers => true pod 'GoogleUtilities', :modular_headers => true pod 'nanopb', :modular_headers => true $RNFirebaseAsStaticFramework = true # Flags change depending on the env values. flags = get_default_flags() use_react_native!( :path => config[:reactNativePath], # Hermes is now enabled by default. Disable by setting this flag to false. # Upcoming versions of React Native may rely on get_default_flags(), but # we make it explicit here to aid in the React Native upgrade process. :hermes_enabled => flags[:hermes_enabled], :fabric_enabled => flags[:fabric_enabled], # Enables Flipper. # # Note that if you have use_frameworks! enabled, Flipper will not work and # you should disable the next line. :flipper_configuration => flipper_config, # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/.." ) # Workaround that lets us use both react-native-maps and firebase # Ref: https://github.com/react-native-maps/react-native-maps/pull/4446 $static_library = [ 'React', 'GoogleMaps', 'Google-Maps-iOS-Utils', 'react-native-maps', 'react-native-google-maps', ] # Workaround that lets us use both react-native-maps and firebase # Ref: https://github.com/react-native-maps/react-native-maps/pull/4446 pre_install do |installer| Pod::Installer::Xcode::TargetValidator.send(:define_method, :verify_no_static_framework_transitive_dependencies) {} installer.pod_targets.each do |pod| bt = pod.send(:build_type) if $static_library.include?(pod.name) puts "Overriding the build_type to static_library from static_framework for #{pod.name}" def pod.build_type; Pod::BuildType.static_library end end end installer.pod_targets.each do |pod| bt = pod.send(:build_type) puts "#{pod.name} (#{bt})" puts " linkage: #{bt.send(:linkage)} packaging: #{bt.send(:packaging)}" end end target 'AppTests' do inherit! :complete # Pods for testing end post_install do |installer| installer.pods_project.targets.each do |target| target.build_configurations.each do |config| # Disable arm64 builds for the simulator config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64' current_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] minimum_target = min_ios_version if current_target.to_f < minimum_target.to_f config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = minimum_target end # Workaround for having to manually assign team for certain pods if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle" target.build_configurations.each do |config| config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' end end end end react_native_post_install( installer, config[:reactNativePath], :mac_catalyst_enabled => false ) __apply_Xcode_12_5_M1_post_install_workaround(installer) end end ``` #### `AppDelegate.m`: ```objc #import "AppDelegate.h" #import #import #import #import "RNFBMessagingModule.h" #import #import #import @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ClearKeychainIfNecessary(); // react-native-encrypted-storage [GMSServices provideAPIKey:@""]; // google maps [FIRApp configure]; // firebase self.moduleName = @"App"; // 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 = [RNFBMessagingModule addCustomPropsToUserProps:nil withLaunchOptions:launchOptions]; bool didFinish=[super application:application didFinishLaunchingWithOptions:launchOptions]; [UIDevice currentDevice].batteryMonitoringEnabled = true; // react-native-device-info return didFinish; } - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { #if DEBUG return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; #else return [CodePush bundleURL]; #endif } // Deep linking - (BOOL)application:(UIApplication *)application openURL:(nonnull NSURL *)url options:(nonnull NSDictionary *)options { if ([RCTLinkingManager application:application openURL:url options:options]) { return YES; } return NO; } @end ```


Android

Click To Expand

#### Have you converted to AndroidX? - [ ] my application is an AndroidX application? - [ ] I am using `android/gradle.settings` `jetifier=true` for Android compatibility? - [ ] I am using the NPM package `jetifier` for react-native compatibility? #### `android/build.gradle`: ```groovy // N/A ``` #### `android/app/build.gradle`: ```groovy // N/A ``` #### `android/settings.gradle`: ```groovy // N/A ``` #### `MainApplication.java`: ```java // N/A ``` #### `AndroidManifest.xml`: ```xml ```


Environment

Click To Expand

**`react-native info` output:** ``` OUTPUT GOES HERE ``` - **Platform that you're experiencing the issue on**: - [x] iOS - [ ] Android - [ ] **iOS** but have not tested behavior on Android - [ ] **Android** but have not tested behavior on iOS - [ ] Both - **`react-native-firebase` version you're using that has this issue:** - `e.g. 5.4.3` - **`Firebase` module(s) you're using that has the issue:** - `e.g. Instance ID` - **Are you using `TypeScript`?** - `Y/N` & `VERSION`


sanduluca commented 2 weeks ago

Also had the case of getting a white screen on ios. I guess is it because of HeadlessCheck

function HeadlessCheck({ isHeadless }) {
    if (isHeadless) {
        // App has been launched in the background by iOS, ignore
        return null;
    }

    return <App />;
}
AppRegistry.registerComponent(appName, () => HeadlessCheck);

As a user, i open the app the same time the iOS launch it in background to run the setBackgroundMessageHandler

mikehardy commented 2 weeks ago

πŸ€” would be interesting to see all the handlers in play, also the JSON (or code that generates it) for the FCMs that trigger the problem - The notification has title, body and data, content_available is true. is a little vague unfortunately

Note that in all cases I'm aware of, if an iOS app is not foreground and JSON has notification key, the firebase-ios-sdk library will post a notification. So you must not post your own in that case later or you will double notify.

If you want to take control of behavior of notification posting for FCM with notification block while iOS app is in background you need to implement a notification extension helper, as documented in notifee repo

sanduluca commented 2 weeks ago

I have the both handlers posted in the first comment. Here is how we generate the push notification on backend (java)

private Message getMessage(String token, String title, String text, Map<String, String> data, long ttl,
            String sound, String channelId) {

        Notification notification = null;
        AndroidNotification androidNotification = null;

        if (text != null || title != null) {
            notification = Notification.builder().setBody(text).setTitle(title).build();

            androidNotification = AndroidNotification.builder()
                .setBody(text)
                .setTitle(title)
                .setSound(sound)
                .setColor(COLOR)
                .setIcon(ICON)
                .setChannelId(channelId)
                .setPriority(AndroidNotification.Priority.HIGH)
                .build();
        }

        HashMap<String, String> apnsHeaders = new HashMap<>();
        apnsHeaders.put("apns-expiration", String.valueOf(ZonedDateTime.now().plusSeconds(ttl).toEpochSecond()));
        ApnsConfig apnsConfig = ApnsConfig.builder()
            .setAps(Aps.builder()
                .setContentAvailable(true)
                .setSound(sound)
                .setAlert(ApsAlert.builder().setTitle(title).setBody(text).build())
                .build())
            .putAllHeaders(apnsHeaders)
            .build();

        AndroidConfig androidConfig = AndroidConfig.builder()
            .setTtl(ttl)
            .setPriority(AndroidConfig.Priority.HIGH)
            .setNotification(androidNotification)
            .build();

        if (data == null) {
            data = new HashMap<>();
        }
        data.put("reload", "true");

        return Message.builder()
            .setNotification(notification)
            .setToken(token)
            .setAndroidConfig(androidConfig)
            .setApnsConfig(apnsConfig)
            .putAllData(data)
            .build();
    }



So you must not post your own in that case later or you will double notify.

The idea is that I dont want to post it if the the firebase sdk already posted it. But in reality the firebase sdk post it and when the user opens the app the onMessage is triggered with the same notification and i have no idea how do i know that the firebase sdk didnt showed it already. I was expecting that ones it was posted its done with it but it seems that I get a race condition somehow (the notification is not discarded from some internal queue and it sends it to onMessage listeners)

Edit: Not sure if it is important but we use 2 custom sounds. One is 1 second long and one is 27 seconds long

matsura commented 6 days ago

Any news here - I have the same exact issue?