Skip to content

[firebase_messaging][Android] Terminated launch delivers the same message to both getInitialMessage() and onMessageOpenedApp (still reproduces on main; iOS already guards against this) #18661

Description

@YuuWoods

Is there an existing issue for this?

  • I have searched the existing issues.

Which plugins are affected?

Messaging

Which platforms are affected?

Android

Description

On Android, when the app is launched from a terminated state by tapping a notification, the same message is delivered to both getInitialMessage() and onMessageOpenedApp. Following the official documentation therefore causes the handler to run twice for a single tap.

This is a re-report of #18490. That issue was closed as fixed by #18122, but it still reproduces on current main (with #18122 applied). #18490 is now locked so I cannot comment there.

For transparency: I am the reporter of #18490. I commented there that 2a66cfd had resolved it — that was my mistake. I had switched to monorepo main while also adding an unrelated 1-second delay, and I attributed the resulting change to that commit. Apologies for the noise.

The key point: iOS already has the correct behaviour

FLTFirebaseMessagingPlugin.m (L563-567) contains an explicit guard:

// We only want to handle FCM notifications and stop firing `onMessageOpenedApp()` when app is
// coming from a terminated state.
if (_notificationOpenedAppID != nil &&
    ![_initialNotificationID isEqualToString:_notificationOpenedAppID]) {
  [_channel invokeMethod:@"Messaging#onMessageOpenedApp" arguments:notificationDict];
}

If the messageId matches the one stored as initialMessage, iOS does not invoke onMessageOpenedApp. Android has no equivalent check, so the two platforms behave differently.

Root cause (current code on main)

FlutterFirebaseMessagingPlugin.java:

  • onAttachedToActivity (L117-129) calls handleNotificationIntent() (L127) when the launch intent has extras.
  • handleNotificationIntent (L703-746) does both:
    • initialMessage = remoteMessage; (L733) — stores it for getInitialMessage()
    • channel.invokeMethod("Messaging#onMessageOpenedApp", message); (L743) — feeds the stream

The call site already distinguishes the two states, and the code's own comments say so:

Call site Situation API that should own it
onAttachedToActivity (L117) Activity newly created = launch from terminated getInitialMessage()
onNewIntent (L628) Activity already existed = resume from background onMessageOpenedApp

Mismatch with the documentation

Handling interaction assigns exactly one API per app state:

  • Terminated → getInitialMessage()
  • Background → onMessageOpenedApp

The implementation feeds both on a terminated launch. Handling both as the documented setupInteractedMessage sample does therefore processes one tap twice.

Note: the symptom is timing-dependent

Whether onMessageOpenedApp fires depends on a race — whether the Dart subscription is in place before the native invokeMethod. Measured on a physical device (Pixel, Android 14):

Where onMessageOpenedApp is subscribed invoke → subscribe Stream fires?
main(), before runApp() 339–366 ms yes
initState(), before the await 830 ms no
initState(), documented order (after the await) > 1400 ms no

When subscribed early enough, the queued event is delivered ~32 ms after subscription (consistent across 5 runs).

As a result, apps that subscribe early during startup (e.g. via DI) hit this, while the example app — which subscribes in initState() as documented — does not. I believe this is why #18490 could not be reproduced.

Reproducing the issue

This reproduces with the firebase_messaging example app, but two changes are required; neither alone is sufficient.

1. Disable the local notification in the background handler

example/lib/main.dart:

@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  // await setupFlutterNotifications();   // <-- comment out
  // showFlutterNotification(message);    // <-- comment out
  print('Handling a background message ${message.messageId}');
}

With these lines active, a flutter_local_notifications notification is shown in addition to the FCM system notification, so two notifications appear. Which one you tap changes the behaviour (verified on device):

Tapped notification Intent Result
Local notification act=SELECT_NOTIFICATION flg=0x10000000 extras carry no google.message_id, so getMessageId() returns null and the method returns early. Neither initialMessage nor the invoke happens — the tap is silently ignored
FCM system notification act=android.intent.action.MAIN flg=0x14000000 handleNotificationIntent performs both the store and the invoke → duplicate delivery

2. Subscribe to onMessageOpenedApp in main(), before runApp()

RemoteMessage? openedAppMessageFromMain;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
    print('[VERIFY] stream fired: messageId=${message.messageId}');
    openedAppMessageFromMain = message;
  });

  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
  runApp(MessagingExampleApp());
}

In the widget, feed both sources into the same handler and count the calls:

int _handleMessageCallCount = 0;

@override
void initState() {
  super.initState();
  // Navigator.pushNamed cannot be called synchronously from initState
  // (setState during build), so defer past the first frame.
  WidgetsBinding.instance.addPostFrameCallback((_) => _setupInteractedMessage());
}

Future<void> _setupInteractedMessage() async {
  final fromMain = openedAppMessageFromMain;
  if (fromMain != null) {
    _handleMessage(fromMain);           // path A: stream
  }

  final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
  if (initialMessage != null) {
    _handleMessage(initialMessage);     // path B: getInitialMessage
  }
}

void _handleMessage(RemoteMessage message) {
  _handleMessageCallCount++;
  print('[VERIFY] _handleMessage count=$_handleMessageCallCount '
        'messageId=${message.messageId}');
}

3. Run

adb shell am force-stop <applicationId>
adb logcat -c
adb logcat | grep -E "\[VERIFY\]"

Send a notification with a notification block (title/body) — a data-only message shows no system notification, so there is nothing to tap. Then tap it from the terminated state.

Result

[VERIFY] stream fired: messageId=<messageId>
[VERIFY] _handleMessage count=1 messageId=<messageId>
[VERIFY] _handleMessage count=2 messageId=<messageId>

One tap, the same messageId handled twice. Navigator.pushNamed also runs twice, pushing the route twice.

Firebase Core version

4.14.0

Flutter Version

3.44.9

Relevant Log Output

[NATIVE] onAttachedToActivity intent=Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x14000000 pkg=<applicationId> (has extras) } hasExtras=true
[NATIVE] onAttachedToActivity -> handleNotificationIntent
[NATIVE] handleNotificationIntent: initialMessage saved + invoking onMessageOpenedApp messageId=<messageId>
[VERIFY] onMessageOpenedApp.listen done (main)
[VERIFY] onMessageOpenedApp stream fired (main): messageId=<messageId>
[VERIFY] _handleMessage count=1 messageId=<messageId>
[NATIVE] getInitialMessage called initialMessage=<messageId>
[VERIFY] getInitialMessage returned: messageId=<messageId>
[VERIFY] _handleMessage count=2 messageId=<messageId>

Flutter dependencies

Reproduced with the firebase_messaging example app in the monorepo
(resolution: workspace, so the plugin resolves to the local source
rather than pub.dev — verified via .dart_tool/package_config.json):

  • firebase_core 4.14.0 (monorepo source)
  • firebase_messaging (monorepo source, main @ 8f0a5d0)
  • firebase_core_platform_interface ^8.1.1
  • firebase_messaging_platform_interface (monorepo source)
  • flutter_local_notifications ^21.0.0

The same behaviour also occurs in a production app using the published
packages: firebase_core 4.14.0 / firebase_messaging 16.6.0.

Additional context and comments

Expected behavior

As documented, a terminated launch should surface the message through getInitialMessage() only, and onMessageOpenedApp should stay silent — the same as iOS.

Proposed fix

Passing the stream-delivery decision into handleNotificationIntent seems the most direct approach:

// onAttachedToActivity (launch from terminated)
handleNotificationIntent(mainActivity.getIntent(), /* shouldNotifyStream= */ false);

// onNewIntent (resume from background)
return handleNotificationIntent(intent, /* shouldNotifyStream= */ true);
private boolean handleNotificationIntent(@NonNull Intent intent, boolean shouldNotifyStream) {
    // ...

    // Always store it for getInitialMessage().
    initialMessage = remoteMessage;
    initialMessageNotification = notificationMap;

    // ...

    // On a terminated launch, getInitialMessage() owns this message.
    if (shouldNotifyStream) {
      channel.invokeMethod("Messaging#onMessageOpenedApp", message);
    }
    mainActivity.setIntent(intent);
    return true;
}

Because initialMessage is still stored, getInitialMessage() remains a pull API and cannot miss the message even if the Dart side is slow to start.

Matching iOS by comparing messageId would also work, but on Android the call site already tells us the state, so the flag makes the intent easier to read and adds no extra state. Happy to open a PR if this approach looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions