firebase / flutterfire

🔥 A collection of Firebase plugins for Flutter apps.
https://firebase.google.com/docs/flutter/setup
BSD 3-Clause "New" or "Revised" License
8.74k stars 3.98k forks source link

[firebase_messaging] `getInitialMessage` returns null if the app is terminated on iOS and notifications contains `content_available` key #10737

Closed sidnei-polo closed 1 year ago

sidnei-polo commented 1 year ago

Bug report

Describe the bug On iOS, when sending a notification with the content_available key, the getInitialMessage method is returning null. Adding a delay before calling getInitialMessage seems to solve the problem, but it is not ideal.

Steps to reproduce

Steps to reproduce the behavior:

  1. Get the FCM token from the app
  2. Send a notification using the FCM token using the following payload:
    {
    "message":{
      "token": <fcm_token>,
      "notification":{
         "body":"Body of Your Notification",
         "title":"Title of Your Notification"
      },
      "data":{
         "key_1":"Value for key_1",
         "key_2":"Value for key_2"
      },
      "apns":{
         "payload":{
            "aps":{
               "content-available":1
            }
         }
      }
    }
    }
  3. With the app in terminated state, tap on the notification

Expected behavior

getInitialMessage should wait and return the notification even when the notification contains the content_available key.

Sample project

Here's a simple project where the issue is reproducible. It obtains the FCM token and displays the initial message when starting the app. If the app gets started from a notification tap on a notification that contains content_available:1, it does not display the initial message as the getInitialMessage returns null.

import 'dart:io';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MessagingExampleApp());
}

/// Entry point for the example application.
class MessagingExampleApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Messaging Example App',
      theme: ThemeData.dark(),
      routes: {
        '/': (context) => Application(),
      },
    );
  }
}

/// Renders the example application.
class Application extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _Application();
}

class _Application extends State<Application> {
  String? initialMessage;
  bool _resolved = false;

  @override
  void initState() {
    super.initState();

    FirebaseMessaging.instance.getInitialMessage().then(
          (value) => setState(
            () {
              _resolved = true;
              initialMessage = value?.data.toString();
            },
          ),
        );
  }

  Future<void> onActionSelected(String value) async {
    switch (value) {
      case 'get_apns_token':
        {
          print('FlutterFire Messaging Example: Getting FCM token...');
          String? token = await FirebaseMessaging.instance.getToken();
          print('FlutterFire Messaging Example: Got FCM token: $token');
        }
        break;
      default:
        break;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cloud Messaging'),
        actions: <Widget>[
          PopupMenuButton(
            onSelected: onActionSelected,
            itemBuilder: (BuildContext context) {
              return [
                const PopupMenuItem(
                  value: 'get_apns_token',
                  child: Text('Get APNs token (Apple only)'),
                ),
              ];
            },
          ),
        ],
      ),
      body: SingleChildScrollView(
        child: Column(
          children: [
            MetaCard('Permissions', Permissions()),
            MetaCard(
              'Initial Message',
              Column(
                children: [
                  Text(_resolved ? 'Resolved' : 'Resolving'),
                  Text(initialMessage ?? 'None'),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// UI Widget for displaying metadata.
class MetaCard extends StatelessWidget {
  final String _title;
  final Widget _children;

  // ignore: public_member_api_docs
  const MetaCard(this._title, this._children, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      margin: const EdgeInsets.only(left: 8, right: 8, top: 8),
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: [
              Container(
                margin: const EdgeInsets.only(bottom: 16),
                child: Text(_title, style: const TextStyle(fontSize: 18)),
              ),
              _children,
            ],
          ),
        ),
      ),
    );
  }
}

class Permissions extends StatefulWidget {
  const Permissions({super.key});

  @override
  State<StatefulWidget> createState() => _Permissions();
}

class _Permissions extends State<Permissions> {
  bool _requested = false;
  bool _fetching = false;
  late NotificationSettings _settings;

  Future<void> requestPermissions() async {
    setState(() {
      _fetching = true;
    });

    final settings = await FirebaseMessaging.instance.requestPermission(
      announcement: true,
      carPlay: true,
      criticalAlert: true,
    );
    await FirebaseMessaging.instance.subscribeToTopic('fcm_test');

    setState(() {
      _requested = true;
      _fetching = false;
      _settings = settings;
    });
  }

  Future<void> checkPermissions() async {
    setState(() {
      _fetching = true;
    });

    final settings = await FirebaseMessaging.instance.getNotificationSettings();

    setState(() {
      _requested = true;
      _fetching = false;
      _settings = settings;
    });
  }

  Widget row(String title, String value) {
    return Container(
      margin: const EdgeInsets.only(bottom: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text('$title:', style: const TextStyle(fontWeight: FontWeight.bold)),
          Text(value),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    if (_fetching) {
      return const CircularProgressIndicator();
    }

    if (!_requested) {
      return ElevatedButton(onPressed: requestPermissions, child: const Text('Request Permissions'));
    }

    return Column(children: [
      row('Authorization Status', statusMap[_settings.authorizationStatus]!),
      if (Platform.isIOS) ...[
        row('Alert', settingsMap[_settings.alert]!),
        row('Announcement', settingsMap[_settings.announcement]!),
        row('Badge', settingsMap[_settings.badge]!),
        row('Car Play', settingsMap[_settings.carPlay]!),
        row('Lock Screen', settingsMap[_settings.lockScreen]!),
        row('Notification Center', settingsMap[_settings.notificationCenter]!),
        row('Show Previews', previewMap[_settings.showPreviews]!),
        row('Sound', settingsMap[_settings.sound]!),
      ],
      ElevatedButton(onPressed: checkPermissions, child: const Text('Reload Permissions')),
    ]);
  }
}

const statusMap = {
  AuthorizationStatus.authorized: 'Authorized',
  AuthorizationStatus.denied: 'Denied',
  AuthorizationStatus.notDetermined: 'Not Determined',
  AuthorizationStatus.provisional: 'Provisional',
};

const settingsMap = {
  AppleNotificationSetting.disabled: 'Disabled',
  AppleNotificationSetting.enabled: 'Enabled',
  AppleNotificationSetting.notSupported: 'Not Supported',
};

const previewMap = {
  AppleShowPreviewSetting.always: 'Always',
  AppleShowPreviewSetting.never: 'Never',
  AppleShowPreviewSetting.notSupported: 'Not Supported',
  AppleShowPreviewSetting.whenAuthenticated: 'Only When Authenticated',
};

Additional context

It looks like the getInitialMessage is not waiting for the notification to be processed when the app starts. If a delay before calling getInitialMessage is added, it returns the notification properly.


Flutter doctor

Run flutter doctor and paste the output below:

Click To Expand ``` Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, 3.3.10, on macOS 12.6.1 21G217 darwin-arm, locale en-DE) [✓] Android toolchain - develop for Android devices (Android SDK version 33.0.1) [✓] Xcode - develop for iOS and macOS (Xcode 14.1) [✓] Chrome - develop for the web [✓] Android Studio (version 2021.3) [✓] VS Code (version 1.75.0) [✓] Connected device (3 available) [✓] HTTP Host Availability • No issues found! ```

Flutter dependencies

Run flutter pub deps -- --style=compact and paste the output below:

Click To Expand ``` Dart SDK 2.18.6 Flutter SDK 3.3.10 messaging_test 1.0.0+1 dependencies: - cupertino_icons 1.0.5 - firebase_core 2.9.0 [firebase_core_platform_interface firebase_core_web flutter meta] - firebase_messaging 14.4.0 [firebase_core firebase_core_platform_interface firebase_messaging_platform_interface firebase_messaging_web flutter meta] - flutter 0.0.0 [characters collection material_color_utilities meta vector_math sky_engine] dev dependencies: - flutter_lints 2.0.1 [lints] - flutter_test 0.0.0 [flutter test_api path fake_async clock stack_trace vector_math async boolean_selector characters collection matcher material_color_utilities meta source_span stream_channel string_scanner term_glyph] transitive dependencies: - _flutterfire_internals 1.1.0 [collection firebase_core firebase_core_platform_interface flutter meta] - async 2.9.0 [collection meta] - boolean_selector 2.1.0 [source_span string_scanner] - characters 1.2.1 - clock 1.1.1 - collection 1.16.0 - fake_async 1.3.1 [clock collection] - firebase_core_platform_interface 4.6.0 [collection flutter flutter_test meta plugin_platform_interface] - firebase_core_web 2.3.0 [firebase_core_platform_interface flutter flutter_web_plugins js meta] - firebase_messaging_platform_interface 4.3.0 [_flutterfire_internals firebase_core flutter meta plugin_platform_interface] - firebase_messaging_web 3.3.0 [_flutterfire_internals firebase_core firebase_core_web firebase_messaging_platform_interface flutter flutter_web_plugins js meta] - flutter_web_plugins 0.0.0 [flutter js characters collection material_color_utilities meta vector_math] - js 0.6.4 - lints 2.0.1 - matcher 0.12.12 [stack_trace] - material_color_utilities 0.1.5 - meta 1.8.0 - path 1.8.2 - plugin_platform_interface 2.1.4 [meta] - sky_engine 0.0.99 - source_span 1.9.0 [collection path term_glyph] - stack_trace 1.10.0 [path] - stream_channel 2.1.0 [async] - string_scanner 1.1.1 [source_span] - term_glyph 1.2.1 - test_api 0.4.12 [async boolean_selector collection meta source_span stack_trace stream_channel string_scanner term_glyph matcher] - vector_math 2.1.2 ```

darshankawar commented 1 year ago

Thanks for the report @sidnei-polo Per documentation:

There are a few preconditions which must be met before the application can receive message payloads via FCM:

The application must have opened at least once (to allow for registration with FCM).
On iOS, if the user swipes away the application from the app switcher, it must be manually reopened for background messages to start working again.

So, if your app is terminated, can you manually reopen and then check if you still get it as null ?

Also, In the plugin example, the keyword is `https://github.com/firebase/flutterfire/blob/f36785770a6d2c3de981d5bd339166a22af617f4/packages/firebase_messaging/firebase_messaging/example/scripts/send-message.js#L32

Whereas, you have it as content-available. Can you try with contentAvailable and see if it helps ?

sidnei-polo commented 1 year ago

Thanks for the reply @darshankawar.

I'm using the FCM V1 API to send the notification, and according to the documentation, I have to send the platform-specific payload following the documentation from the platform. In this case content-available. But, it is also reproducible using the legacy API with the "content_available": true key.

I also understand for content-available to work, the app must not be terminated with the user swipe action, and if the app is in the background, it will use the FirebaseMessaging.onBackgroundMessage callback. That is working fine with the "content-available":1. But, if the app is indeed terminated with the user swipe action when the user taps on a notification that opens the app, the getInitialMessage should return that notification, even if the notification contains content-available, right?

And from my tests, it does return the tapped notification, but only if I add a delay before calling getInitialMessage on the app startup.

darshankawar commented 1 year ago

Thanks for the feedback. Using the code sample provided, seeing the same behavior as reported.

/cc @russellwheatley

russellwheatley commented 1 year ago

@sidnei-polo I used your code sample. I also used the Node.js Firebase admin library to send the requests. I received the initial message 100% of the time after pressing the notification from a terminated state. I tried roughly ten times. Here is the code I used to send:

admin
.messaging()
.sendToDevice(
  [iosToken],
  {
    data: {
      some: 'data',
    },
    notification: {
      title: 'A great title',
      body: 'Great content',
    },
  },
  {
    contentAvailable: true,
    priority: 'high',
  }
)

If it works for me, then I can only presume there is something wrong with the way it is handled using the REST API or perhaps something wrong with the way the shape of the request being sent. Could you try using the Firebase admin SDK and let me know if it works for you?

google-oss-bot commented 1 year ago

Hey @sidnei-polo. We need more information to resolve this issue but there hasn't been an update in 7 weekdays. I'm marking the issue as stale and if there are no new updates in the next 7 days I will close it automatically.

If you have more information that will help us get to the bottom of this, just add a comment!

sidnei-polo commented 1 year ago

Hey @russellwheatley, I didn't have the opportunity to use the Node.js SDK yet. But this is also happening when we use Firebase Admin Python SDK, so I guess it is most probably an issue on the app side when handling the income message.

Payload used with python SDK:

{
   "validate_only":false,
   "message":{
      "notification":{
         "title":"title",
         "body":"message"
      },
      "token":"fcm_token",
      "data":{
         "click_action":"FLUTTER_NOTIFICATION_CLICK"
      },
      "apns":{
         "payload":{
            "aps":{
               "content-available":1
            }
         }
      }
   }
}
n3rdkid commented 1 year ago

Any update on this?

Facing similar issue, with Firebase Admin Python SDK.

pranavo72bex commented 1 year ago

Payload used with python SDK:

How did you manage to fix the bug?

ziqq commented 1 year ago

I find the solution for my case. I use Node.js to send notifications.

From official Apple docs. You should use

{
   "aps" : {
      "content-available" : 1
   },
}
russellwheatley commented 1 year ago

I'd encourage you all to use the example Nodejs admin script that we have setup here: https://github.com/firebase/flutterfire/blob/91d8aa72f627620245fe15df48f2d68f204e3d10/packages/firebase_messaging/firebase_messaging/example/scripts/send-message.js

Let me know if that works.

ziqq commented 1 year ago

Let me know if that works.

I use this package for send notifications from backend

Like this not working for me

"contentAvailable": true

But like this work perfectly

"content-available" : 1
russellwheatley commented 1 year ago

Closing out, this is not an issue. Please use nodejs admin SDK script to send notifications if you're struggling to receive messages. See example script here