Is there an existing issue for this?
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.
Is there an existing issue for this?
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()andonMessageOpenedApp. 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
2a66cfdhad resolved it — that was my mistake. I had switched to monorepomainwhile 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:If the
messageIdmatches the one stored asinitialMessage, iOS does not invokeonMessageOpenedApp. Android has no equivalent check, so the two platforms behave differently.Root cause (current code on
main)FlutterFirebaseMessagingPlugin.java:onAttachedToActivity(L117-129) callshandleNotificationIntent()(L127) when the launch intent has extras.handleNotificationIntent(L703-746) does both:initialMessage = remoteMessage;(L733) — stores it forgetInitialMessage()channel.invokeMethod("Messaging#onMessageOpenedApp", message);(L743) — feeds the streamThe call site already distinguishes the two states, and the code's own comments say so:
onAttachedToActivity(L117)getInitialMessage()onNewIntent(L628)onMessageOpenedAppMismatch with the documentation
Handling interaction assigns exactly one API per app state:
getInitialMessage()onMessageOpenedAppThe implementation feeds both on a terminated launch. Handling both as the documented
setupInteractedMessagesample does therefore processes one tap twice.Note: the symptom is timing-dependent
Whether
onMessageOpenedAppfires depends on a race — whether the Dart subscription is in place before the nativeinvokeMethod. Measured on a physical device (Pixel, Android 14):onMessageOpenedAppis subscribedmain(), beforerunApp()initState(), before theawaitinitState(), documented order (after theawait)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_messagingexample app, but two changes are required; neither alone is sufficient.1. Disable the local notification in the background handler
example/lib/main.dart:With these lines active, a
flutter_local_notificationsnotification is shown in addition to the FCM system notification, so two notifications appear. Which one you tap changes the behaviour (verified on device):act=SELECT_NOTIFICATIONflg=0x10000000google.message_id, sogetMessageId()returns null and the method returns early. NeitherinitialMessagenor the invoke happens — the tap is silently ignoredact=android.intent.action.MAINflg=0x14000000handleNotificationIntentperforms both the store and the invoke → duplicate delivery2. Subscribe to
onMessageOpenedAppinmain(), beforerunApp()In the widget, feed both sources into the same handler and count the calls:
3. Run
Send a notification with a
notificationblock (title/body) — a data-only message shows no system notification, so there is nothing to tap. Then tap it from the terminated state.Result
One tap, the same
messageIdhandled twice.Navigator.pushNamedalso 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_messagingexample app in the monorepo(
resolution: workspace, so the plugin resolves to the local sourcerather than pub.dev — verified via
.dart_tool/package_config.json):main@ 8f0a5d0)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, andonMessageOpenedAppshould stay silent — the same as iOS.Proposed fix
Passing the stream-delivery decision into
handleNotificationIntentseems the most direct approach:Because
initialMessageis 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
messageIdwould 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.