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.7k stars 3.97k forks source link

🐛 [flutter_messaging] getInitialMessage() returns null unless it's called after a Future.delayed() #9906

Closed elenaconache closed 1 year ago

elenaconache commented 1 year ago

Bug report

Describe the bug The getInitialMessage() method does not seem reliable, at least on iOS which I'm using for testing this.

Steps to reproduce

Steps to reproduce the behavior:

  1. Replace the pipedream base url (where I wrote REPLACE_WITH_YOUR_BASE_URL in my code) with your own, generate one here: https://pipedream.com/@e/untitled-workflow-p_vQCQP9y/build
  2. Run the example app I'm attaching in release mode on iOS, it's from the firebase_messaging example without the flutter local notifications as they're not relevant for my issue.
  3. Click on the button in my screen and allow notifications
  4. Close app
  5. Send a notification from Postman by calling https://fcm.googleapis.com/fcm/send POST (with some Authorization key), with the following request body:
    {
    "to": "/topics/fcm_test",
    "content_available": true,
    "priority": "high",
    "notification": {
        "title": "Title",
        "body": "Body"
    },
    "data": {
        "mykey": "myvalue"
    }
    }
  6. Tap the notification received on your device
  7. Check pipedream to see the API calls that are performed
  8. Notice the background handler gets called when tapping the notification with the correct data.
  9. Notice the getInitialMessage() call from app's setupInteractedMessage() (called within initState()) has null data.
  10. Notice the same call to getInitialMessage() after a delay of a few seconds has the correct data.

Expected behavior

I would expect getInitialMessage() to work or to have a clear documentation on when I can call it without receiving null. Note: if I close my app, open some other random app on phone, send notification, tap it -> getInitialMessage() returns the correct data from the very first call, which is what I would expect. But if I don't open any other app and just close it and reopen [as detailed in the steps above], the bug is reproduced. Also, if I open some other app before tapping the notification, the background handler is not called. Not sure what the expected behavior for that is.

Sample project

import 'dart:async';
import 'dart:io';

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

@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  saveToPipedream(map: {'background handler': message.data});
}

@pragma('vm:entry-point')
Future<void> saveToPipedream({Map<String, dynamic>? map}) async {
  await Dio()
      .post('https://REPLACE_WITH_YOUR_BASE_URL.m.pipedream.net', data: map?.toString())
      .catchError((err, stack) {
    print('error while posting $err $stack');
  });
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MessagingExampleApp());
}

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

class Application extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _Application();
}

class _Application extends State<Application> {
  @override
  void initState() {
    super.initState();

    setupInteractedMessage(withDelay: false);
    Future.delayed(Duration(seconds: 6))
        .then((value) => setupInteractedMessage(withDelay: true));

    FirebaseMessaging.onMessage.listen(
      (event) {
        saveToPipedream(map: {'onMessage': event.data});
      },
    );
  }

  Future<void> setupInteractedMessage({required bool withDelay}) async {
    final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
    saveToPipedream(map: {
      'setupInteractedMessage': initialMessage?.data,
      'withDelay': withDelay.toString()
    });

    FirebaseMessaging.onMessageOpenedApp.listen((message) {
      saveToPipedream(map: {'onMessageOpenedApp': message.data});
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cloud Messaging'),
      ),
      body: SingleChildScrollView(
        child: Column(
          children: [
            MetaCard('Permissions', Permissions()),
          ],
        ),
      ),
    );
  }
}

class MetaCard extends StatelessWidget {
  final String _title;
  final Widget _children;

  MetaCard(this._title, this._children);

  @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 {
  @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 == TargetPlatform.iOS) ...[
        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

In my pubspec.yaml I've added under dependencies: firebase_messaging: ^14.0.4 firebase_core: ^2.1.1

I'm not disabling swizzling or anything like that in Info.plist as some docs were not recommending so.

Also, the delay duration for the second getInitialMessage() call is not really fixed, sometimes it worked with 3 seconds, other time with 6 seconds. Do I need to call await on some specific method from Firebase to make sure things are ready for that call? Why does it work after this delay?

On the example project from the firebase_messaging package, I noticed the getInitialMessage() call is done from a button press event, but it would be more useful to be able to call this from app launch, and not require user interaction in order to navigate to the correct screen.

I tested using an iPhone 11 Pro, with iOS 15.7.1

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.7, on macOS 12.6 21G115 darwin-arm, locale en-RO) [✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0) [✓] Xcode - develop for iOS and macOS (Xcode 14.1) [✓] Chrome - develop for the web [✓] Android Studio (version 2021.2) [✓] Connected device (2 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.4 Flutter SDK 3.3.7 1.1.0+14 dependencies: - dio 4.0.6 [http_parser path] - firebase_core 2.1.1 [firebase_core_platform_interface firebase_core_web flutter meta] - firebase_messaging 14.0.4 [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] transitive dependencies: - _flutterfire_internals 1.0.7 [cloud_firestore_platform_interface cloud_firestore_web 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 - cloud_firestore_platform_interface 5.8.4 [_flutterfire_internals collection firebase_core flutter meta plugin_platform_interface] - cloud_firestore_web 3.0.4 [_flutterfire_internals cloud_firestore_platform_interface collection firebase_core firebase_core_web flutter flutter_web_plugins js] - collection 1.16.0 - fake_async 1.3.1 [clock collection] - firebase_core_platform_interface 4.5.2 [collection flutter flutter_test meta plugin_platform_interface] - firebase_core_web 2.0.1 [firebase_core_platform_interface flutter flutter_web_plugins js meta] - firebase_messaging_platform_interface 4.2.5 [_flutterfire_internals firebase_core flutter meta plugin_platform_interface] - firebase_messaging_web 3.2.5 [_flutterfire_internals firebase_core firebase_core_web firebase_messaging_platform_interface flutter flutter_web_plugins js meta] - 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] - flutter_web_plugins 0.0.0 [flutter js characters collection material_color_utilities meta vector_math] - http_parser 4.0.2 [collection source_span string_scanner typed_data] - js 0.6.4 - 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.3 [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] - typed_data 1.3.1 [collection] - vector_math 2.1.2 ```

darshankawar commented 1 year ago

@elenacrst Thanks for the detailed report. From the code sample you shared above, I see that you are using dio which is third party plugin and also seem to be using third party api (pipedream). But in order to properly narrow down the behavior to firebase_messaging plugin only, since, we specifically only support messages received from the Firebase APIs as we cannot guarantee that messages received from third party packages will not have any unintended side-effects on other Firebase products such as messaging delivery reporting and Analytics data.

To help us triage and locate genuine issues in the plugin, please provide minimal code sample without any third party plugins or api usage so that we can take a look at it further.

elenaconache commented 1 year ago

@darshankawar Here is the updated code, using some Text widgets to display the data for each call. I removed pipedream and dio.

import 'dart:async';
import 'dart:io';

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

String? backgroundHandlerMessage;
String? foregroundHandlerMessage;

@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  backgroundHandlerMessage = message.data.toString();
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  runApp(MessagingExampleApp());
}

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

class Application extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _Application();
}

class _Application extends State<Application> {
  String? initialMessage;
  String? delayInitialMessage;
  String? openedAppMessage;
  bool calledOpenedApp = false;
  bool calledInitialMessageWithDelay = false;
  bool calledInitialMessage = false;

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

    setupInteractedMessage(withDelay: false);

    Future.delayed(Duration(seconds: 6)).then((value) {
      setupInteractedMessage(withDelay: true);
    });

    FirebaseMessaging.onMessageOpenedApp.listen((message) {
      setState(() {
        openedAppMessage = message.data.toString();
        calledOpenedApp = true;
      });
    });

    FirebaseMessaging.onMessage.listen(
      (event) {
        foregroundHandlerMessage = event.data.toString();
        if (mounted) {
          setState(() {});
        }
      },
    );
  }

  Future<void> setupInteractedMessage({required bool withDelay}) async {
    if (withDelay) {
      delayInitialMessage =
          (await FirebaseMessaging.instance.getInitialMessage())
              ?.data
              .toString();
      setState(() {
        calledInitialMessageWithDelay = true;
      });
    } else {
      initialMessage = (await FirebaseMessaging.instance.getInitialMessage())
          ?.data
          .toString();
      calledInitialMessage = true;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cloud Messaging'),
      ),
      body: SingleChildScrollView(
        child: Column(
          children: [
            MetaCard('Permissions', Permissions()),
            Text('called getInitialMessage() : $calledInitialMessage'),
            Text('result of getInitialMessage() : $initialMessage'),
            Text(
                'called getInitialMessage() after delay : $calledInitialMessageWithDelay'),
            Text(
                'result of getInitialMessage() after delay: $delayInitialMessage'),
            Text('called onMessageOpenedApp : $calledOpenedApp'),
            Text('result of onMessageOpenedApp listener : $openedAppMessage'),
            Text('background handler message : $backgroundHandlerMessage'),
            Text('foreground handler message: $foregroundHandlerMessage')
          ],
        ),
      ),
    );
  }
}

class MetaCard extends StatelessWidget {
  final String _title;
  final Widget _children;

  MetaCard(this._title, this._children);

  @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 {
  @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 == TargetPlatform.iOS) ...[
        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',
};

The result looks like this: 315272861_800496721238962_6662364747069265985_n

As you can see, the variable used for the first call to getInitialMessage() stays null, while the one initialized after a delay is not null.

darshankawar commented 1 year ago

Thanks for the update. Using code sample above, I am able to replicate the behavior. Keeping this issue open for insights from team on expected behavior.

elenaconache commented 1 year ago

If this would be the expected behavior, is it possible to create a feature request for finding a way to provide the initial message at app launch or via a listener?

For example if on notification tap I'd need to navigate automatically to some screen with the given data, currently I probably have to either:

  1. introduce the delay (which is not always the same and is not reliable) and get a very long splash screen display time even for users that just launched the app normally.
  2. navigate normally to some home screen and then out of nowhere when this delayed call says there is some data, take the user to the right screen. I think users might find it odd if they started using home screen then the app decided to push a new route.

Is there an alternative to these methods?

Lyokone commented 1 year ago

Hello @elenacrst, I'm going to investigate this

I can also reproduce

Lyokone commented 1 year ago

It will be fixed in next version released later today, thanks for the report :)

dehypnosis commented 1 year ago

Still having same problem in "14.1.4" 😭