From 8218308b6cfaf35334518cc1678ae2ab34c05b33 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Thu, 23 Jul 2026 08:13:52 +0000 Subject: [PATCH 01/11] fix(firestore, android): clean up Android transaction listeners on completion --- .../FlutterFirebaseFirestorePlugin.java | 24 +++++++++++++++ .../TransactionStreamHandler.java | 9 ++++++ .../integration_test/transaction_e2e.dart | 29 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java index 565b362dbf0a..4624613d368a 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java @@ -277,6 +277,28 @@ private String registerEventChannel(String prefix, String identifier, StreamHand return identifier; } + private void removeEventListener(String identifier) { + synchronized (eventChannels) { + EventChannel eventChannel = eventChannels.remove(identifier); + if (eventChannel != null) { + eventChannel.setStreamHandler(null); + } + } + + synchronized (streamHandlers) { + StreamHandler streamHandler = streamHandlers.remove(identifier); + if (streamHandler != null) { + streamHandler.onCancel(null); + } + } + } + + private void removeTransaction(String transactionId) { + transactions.remove(transactionId); + removeEventListener(transactionId); + transactionHandlers.remove(transactionId); + } + private void removeEventListeners() { synchronized (eventChannels) { for (String identifier : eventChannels.keySet()) { @@ -292,6 +314,7 @@ private void removeEventListeners() { streamHandlers.clear(); } + transactions.clear(); transactionHandlers.clear(); } @@ -560,6 +583,7 @@ public void transactionCreate( final TransactionStreamHandler handler = new TransactionStreamHandler( transaction -> transactions.put(transactionId, transaction), + this::removeTransaction, firestore, transactionId, timeout, diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java index 32f16bc2555b..193c8d9b51ba 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/streamhandler/TransactionStreamHandler.java @@ -38,7 +38,13 @@ public interface OnTransactionStartedListener { void onStarted(Transaction transaction); } + /** Callback when the transaction has reached a terminal state. */ + public interface OnTransactionCompleteListener { + void onComplete(String transactionId); + } + final OnTransactionStartedListener onTransactionStartedListener; + final OnTransactionCompleteListener onTransactionCompleteListener; final FirebaseFirestore firestore; final String transactionId; final Long timeout; @@ -47,11 +53,13 @@ public interface OnTransactionStartedListener { public TransactionStreamHandler( OnTransactionStartedListener onTransactionStartedListener, + OnTransactionCompleteListener onTransactionCompleteListener, FirebaseFirestore firestore, String transactionId, Long timeout, Long maxAttempts) { this.onTransactionStartedListener = onTransactionStartedListener; + this.onTransactionCompleteListener = onTransactionCompleteListener; this.firestore = firestore; this.transactionId = transactionId; this.timeout = timeout; @@ -178,6 +186,7 @@ public void onListen(Object arguments, EventSink events) { () -> { events.success(map); events.endOfStream(); + onTransactionCompleteListener.onComplete(transactionId); }); }); } diff --git a/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart b/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart index 10d52b1e1463..842fc6f40787 100644 --- a/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/example/integration_test/transaction_e2e.dart @@ -213,6 +213,35 @@ void runTransactionTests() { expect(snapshot2.data()!['test'], equals('value4')); }); + test( + 'runs many sequential transactions with large payloads', + () async { + DocumentReference> doc = + await initializeTest('transaction-cleanup-stress'); + final payload = { + for (var i = 0; i < 100; i++) 'field_$i': 'x' * 100, + }; + + await doc.set({'count': 0, ...payload}); + + for (var i = 0; i < 100; i++) { + await firestore.runTransaction((transaction) async { + final snapshot = await transaction.get(doc); + final count = snapshot.data()!['count'] as int; + + transaction.update(doc, { + 'count': count + 1, + ...payload, + }); + }); + } + + final snapshot = await doc.get(); + expect(snapshot.data()!['count'], 100); + }, + skip: kIsWeb, + ); + test( 'should abort if timeout is exceeded', () async { From 98d8c86a0b58990b628d22fb8706364e6ac191de Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 27 Jul 2026 15:45:48 +0000 Subject: [PATCH 02/11] feat(messaging): add FID registration APIs --- .../firebase_messaging/lib/src/messaging.dart | 42 ++++++++++ .../test/firebase_messaging_test.dart | 81 ++++++++++++++++++- .../firebase_messaging/test/mock.dart | 41 +++++++++- .../method_channel_messaging.dart | 58 +++++++++++++ .../platform_interface_messaging.dart | 39 +++++++++ .../platform_interface_messaging_test.dart | 42 +++++++++- .../lib/firebase_messaging_web.dart | 45 +++++++++++ .../lib/src/interop/messaging.dart | 72 +++++++++++++++++ .../lib/src/interop/messaging_interop.dart | 35 ++++++++ 9 files changed, 452 insertions(+), 3 deletions(-) diff --git a/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart b/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart index b9f6a0ccea45..cf26a87b7384 100644 --- a/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart +++ b/packages/firebase_messaging/firebase_messaging/lib/src/messaging.dart @@ -97,6 +97,7 @@ class FirebaseMessaging extends FirebasePlugin { /// Removes access to an FCM token previously authorized. /// /// Messages sent by the server to this token will fail. + @Deprecated('Use unregister() instead.') Future deleteToken() { return _delegate.deleteToken(); } @@ -111,6 +112,45 @@ class FirebaseMessaging extends FirebasePlugin { return _delegate.getAPNSToken(); } + /// Registers the current app instance with Firebase Cloud Messaging. + /// + /// Once registration completes, [onRegistered] provides the Firebase + /// Installation ID (FID) that can be used as the direct-send target for this + /// app instance. + Future register({ + String? vapidKey, + String? serviceWorkerScriptPath, + }) { + return _delegate.register( + vapidKey: vapidKey, + serviceWorkerScriptPath: serviceWorkerScriptPath, + ); + } + + /// Unregisters the current app instance with Firebase Cloud Messaging. + /// + /// Once unregistration completes, [onUnregistered] provides the Firebase + /// Installation ID (FID) that is no longer active for direct-send messaging. + Future unregister() { + return _delegate.unregister(); + } + + /// Fires when this app instance is registered with Firebase Cloud Messaging. + /// + /// The event value is the Firebase Installation ID (FID) that should be sent + /// to your backend for direct-send messaging. + Stream get onRegistered { + return _delegate.onRegistered; + } + + /// Fires when this app instance is unregistered from Firebase Cloud Messaging. + /// + /// The event value is the Firebase Installation ID (FID) that should be + /// removed from your backend. + Stream get onUnregistered { + return _delegate.onUnregistered; + } + /// Returns the default FCM token for this device. /// /// On web, a [vapidKey] is required. @@ -118,6 +158,7 @@ class FirebaseMessaging extends FirebasePlugin { /// On web, a custom messaging service worker can be registered with /// [serviceWorkerScriptPath]. This must point to a JavaScript file in the /// root of the app's `web` directory. + @Deprecated('Use register() and onRegistered instead.') Future getToken({ String? vapidKey, String? serviceWorkerScriptPath, @@ -129,6 +170,7 @@ class FirebaseMessaging extends FirebasePlugin { } /// Fires when a new FCM token is generated. + @Deprecated('Use onRegistered instead.') Stream get onTokenRefresh { return _delegate.onTokenRefresh; } diff --git a/packages/firebase_messaging/firebase_messaging/test/firebase_messaging_test.dart b/packages/firebase_messaging/firebase_messaging/test/firebase_messaging_test.dart index f3e1dd4204bb..02aafbe81ab1 100644 --- a/packages/firebase_messaging/firebase_messaging/test/firebase_messaging_test.dart +++ b/packages/firebase_messaging/firebase_messaging/test/firebase_messaging_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: require_trailing_commas +// ignore_for_file: deprecated_member_use, require_trailing_commas // Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -96,6 +96,85 @@ void main() { verify(kMockMessagingPlatform.getAPNSToken()); }); }); + + group('register', () { + test('verify delegate method is called with correct args', () async { + const vapidKey = 'test-vapid-key'; + + when(kMockMessagingPlatform.register( + vapidKey: anyNamed('vapidKey'), + serviceWorkerScriptPath: anyNamed('serviceWorkerScriptPath'), + )).thenAnswer((_) => Future.value()); + + await messaging!.register(vapidKey: vapidKey); + + verify(kMockMessagingPlatform.register( + vapidKey: vapidKey, + serviceWorkerScriptPath: null, + )); + }); + + test('verify delegate method is called with service worker path', + () async { + const serviceWorkerScriptPath = 'custom-messaging-sw.js'; + + when(kMockMessagingPlatform.register( + vapidKey: anyNamed('vapidKey'), + serviceWorkerScriptPath: anyNamed('serviceWorkerScriptPath'), + )).thenAnswer((_) => Future.value()); + + await messaging!.register( + serviceWorkerScriptPath: serviceWorkerScriptPath, + ); + + verify(kMockMessagingPlatform.register( + vapidKey: null, + serviceWorkerScriptPath: serviceWorkerScriptPath, + )); + }); + }); + + group('unregister', () { + test('verify delegate method is called', () async { + when(kMockMessagingPlatform.unregister()) + .thenAnswer((_) => Future.value()); + + await messaging!.unregister(); + + verify(kMockMessagingPlatform.unregister()); + }); + }); + + group('onRegistered', () { + test('verify delegate method is called', () async { + const fid = 'test-fid'; + + when(kMockMessagingPlatform.onRegistered) + .thenAnswer((_) => Stream.fromIterable([fid])); + + final StreamQueue changes = + StreamQueue(messaging!.onRegistered); + expect(await changes.next, equals(fid)); + + verify(kMockMessagingPlatform.onRegistered); + }); + }); + + group('onUnregistered', () { + test('verify delegate method is called', () async { + const fid = 'test-fid'; + + when(kMockMessagingPlatform.onUnregistered) + .thenAnswer((_) => Stream.fromIterable([fid])); + + final StreamQueue changes = + StreamQueue(messaging!.onUnregistered); + expect(await changes.next, equals(fid)); + + verify(kMockMessagingPlatform.onUnregistered); + }); + }); + group('getToken', () { test('verify delegate method is called with correct args', () async { const vapidKey = 'test-vapid-key'; diff --git a/packages/firebase_messaging/firebase_messaging/test/mock.dart b/packages/firebase_messaging/firebase_messaging/test/mock.dart index 4555c8d77d30..f7aa8c795759 100644 --- a/packages/firebase_messaging/firebase_messaging/test/mock.dart +++ b/packages/firebase_messaging/firebase_messaging/test/mock.dart @@ -1,4 +1,4 @@ -// ignore_for_file: require_trailing_commas +// ignore_for_file: deprecated_member_use, require_trailing_commas // Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -95,6 +95,45 @@ class MockFirebaseMessaging extends Mock returnValueForMissingStub: Future.value('')); } + @override + Future register({ + String? vapidKey, + String? serviceWorkerScriptPath, + }) { + return super.noSuchMethod( + Invocation.method(#register, [], { + #vapidKey: vapidKey, + #serviceWorkerScriptPath: serviceWorkerScriptPath + }), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()); + } + + @override + Future unregister() { + return super.noSuchMethod(Invocation.method(#unregister, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()); + } + + @override + Stream get onRegistered { + return super.noSuchMethod( + Invocation.getter(#onRegistered), + returnValue: const Stream.empty(), + returnValueForMissingStub: const Stream.empty(), + ); + } + + @override + Stream get onUnregistered { + return super.noSuchMethod( + Invocation.getter(#onUnregistered), + returnValue: const Stream.empty(), + returnValueForMissingStub: const Stream.empty(), + ); + } + @override Future getToken({String? vapidKey, String? serviceWorkerScriptPath}) { return super.noSuchMethod( diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/method_channel/method_channel_messaging.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/method_channel/method_channel_messaging.dart index 73788d05c3de..1eaec4311892 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/method_channel/method_channel_messaging.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/method_channel/method_channel_messaging.dart @@ -94,6 +94,14 @@ class MethodChannelFirebaseMessaging extends FirebaseMessagingPlatform { MethodChannelFirebaseMessaging.tokenStreamController .add(call.arguments as String); break; + case 'Messaging#onRegistered': + MethodChannelFirebaseMessaging.registeredStreamController + .add(call.arguments as String); + break; + case 'Messaging#onUnregistered': + MethodChannelFirebaseMessaging.unregisteredStreamController + .add(call.arguments as String); + break; case 'Messaging#onMessage': Map messageMap = Map.from(call.arguments); @@ -126,6 +134,14 @@ class MethodChannelFirebaseMessaging extends FirebaseMessagingPlatform { static StreamController tokenStreamController = StreamController.broadcast(); + // ignore: close_sinks, never closed + static StreamController registeredStreamController = + StreamController.broadcast(); + + // ignore: close_sinks, never closed + static StreamController unregisteredStreamController = + StreamController.broadcast(); + // Created this to check APNS token is available before certain Apple Firebase // Messaging requests. See this issue: // https://github.com/firebase/flutterfire/issues/10625 @@ -207,6 +223,7 @@ class MethodChannelFirebaseMessaging extends FirebaseMessagingPlatform { } @override + @Deprecated('Use unregister() instead.') Future deleteToken() async { await _APNSTokenCheck(); @@ -238,6 +255,36 @@ class MethodChannelFirebaseMessaging extends FirebaseMessagingPlatform { } @override + Future register({ + String? vapidKey, // web only property + String? serviceWorkerScriptPath, // web only property + }) async { + await _APNSTokenCheck(); + + try { + await channel.invokeMapMethod('Messaging#register', { + 'appName': app.name, + }); + } catch (e, stack) { + convertPlatformException(e, stack); + } + } + + @override + Future unregister() async { + await _APNSTokenCheck(); + + try { + await channel.invokeMapMethod('Messaging#unregister', { + 'appName': app.name, + }); + } catch (e, stack) { + convertPlatformException(e, stack); + } + } + + @override + @Deprecated('Use register() and onRegistered instead.') Future getToken({ String? vapidKey, // not used yet; web only property String? serviceWorkerScriptPath, // web only property @@ -331,10 +378,21 @@ class MethodChannelFirebaseMessaging extends FirebaseMessagingPlatform { } @override + @Deprecated('Use onRegistered instead.') Stream get onTokenRefresh { return tokenStreamController.stream; } + @override + Stream get onRegistered { + return registeredStreamController.stream; + } + + @override + Stream get onUnregistered { + return unregisteredStreamController.stream; + } + @override Future setForegroundNotificationPresentationOptions({ required bool alert, diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/platform_interface/platform_interface_messaging.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/platform_interface/platform_interface_messaging.dart index 44d20d32ed5b..c01e4f37533b 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/platform_interface/platform_interface_messaging.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/lib/src/platform_interface/platform_interface_messaging.dart @@ -160,6 +160,7 @@ abstract class FirebaseMessagingPlatform extends PlatformInterface { /// Removes access to an FCM token previously authorized with optional [senderId]. /// /// Messages sent by the server to this token will fail. + @Deprecated('Use unregister() instead.') Future deleteToken() { throw UnimplementedError('deleteToken() is not implemented'); } @@ -170,7 +171,44 @@ abstract class FirebaseMessagingPlatform extends PlatformInterface { throw UnimplementedError('getAPNSToken() is not implemented'); } + /// Registers the current app instance with Firebase Cloud Messaging. + /// + /// Once registration completes, [onRegistered] provides the Firebase + /// Installation ID (FID) that can be used as the direct-send target for this + /// app instance. + Future register({ + String? vapidKey, + String? serviceWorkerScriptPath, + }) { + throw UnimplementedError('register() is not implemented'); + } + + /// Unregisters the current app instance with Firebase Cloud Messaging. + /// + /// Once unregistration completes, [onUnregistered] provides the Firebase + /// Installation ID (FID) that is no longer active for direct-send messaging. + Future unregister() { + throw UnimplementedError('unregister() is not implemented'); + } + + /// Fires when this app instance is registered with Firebase Cloud Messaging. + /// + /// The event value is the Firebase Installation ID (FID) that should be sent + /// to your backend for direct-send messaging. + Stream get onRegistered { + throw UnimplementedError('onRegistered is not implemented'); + } + + /// Fires when this app instance is unregistered from Firebase Cloud Messaging. + /// + /// The event value is the Firebase Installation ID (FID) that should be + /// removed from your backend. + Stream get onUnregistered { + throw UnimplementedError('onUnregistered is not implemented'); + } + /// Returns the default FCM token for this device and optionally [senderId]. + @Deprecated('Use register() and onRegistered instead.') Future getToken({ String? vapidKey, String? serviceWorkerScriptPath, @@ -179,6 +217,7 @@ abstract class FirebaseMessagingPlatform extends PlatformInterface { } /// Fires when a new FCM token is generated. + @Deprecated('Use onRegistered instead.') Stream get onTokenRefresh { throw UnimplementedError('onTokenRefresh is not implemented'); } diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/test/platform_interface_tests/platform_interface_messaging_test.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/test/platform_interface_tests/platform_interface_messaging_test.dart index 54c959be7eeb..7946d7c6c756 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/test/platform_interface_tests/platform_interface_messaging_test.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/test/platform_interface_tests/platform_interface_messaging_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: require_trailing_commas +// ignore_for_file: deprecated_member_use, require_trailing_commas // Copyright 2020, the Chromium project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. @@ -146,6 +146,46 @@ void main() { fail('Should have thrown an [UnimplementedError]'); }); + test('throws if register()', () async { + try { + await firebaseMessagingPlatform.register(); + } on UnimplementedError catch (e) { + expect(e.message, equals('register() is not implemented')); + return; + } + fail('Should have thrown an [UnimplementedError]'); + }); + + test('throws if unregister()', () async { + try { + await firebaseMessagingPlatform.unregister(); + } on UnimplementedError catch (e) { + expect(e.message, equals('unregister() is not implemented')); + return; + } + fail('Should have thrown an [UnimplementedError]'); + }); + + test('throws if onRegistered', () { + try { + firebaseMessagingPlatform.onRegistered; + } on UnimplementedError catch (e) { + expect(e.message, equals('onRegistered is not implemented')); + return; + } + fail('Should have thrown an [UnimplementedError]'); + }); + + test('throws if onUnregistered', () { + try { + firebaseMessagingPlatform.onUnregistered; + } on UnimplementedError catch (e) { + expect(e.message, equals('onUnregistered is not implemented')); + return; + } + fail('Should have thrown an [UnimplementedError]'); + }); + test('throws if onTokenRefresh', () { try { firebaseMessagingPlatform.onTokenRefresh; diff --git a/packages/firebase_messaging/firebase_messaging_web/lib/firebase_messaging_web.dart b/packages/firebase_messaging/firebase_messaging_web/lib/firebase_messaging_web.dart index 7825b227fa1c..e2406fc7dcc4 100644 --- a/packages/firebase_messaging/firebase_messaging_web/lib/firebase_messaging_web.dart +++ b/packages/firebase_messaging/firebase_messaging_web/lib/firebase_messaging_web.dart @@ -95,6 +95,7 @@ class FirebaseMessagingWeb extends FirebaseMessagingPlatform { } @override + @Deprecated('Use unregister() instead.') Future deleteToken() async { _delegate; @@ -112,6 +113,49 @@ class FirebaseMessagingWeb extends FirebaseMessagingPlatform { } @override + Future register({ + String? vapidKey, + String? serviceWorkerScriptPath, + }) async { + _delegate; + + if (!_initialized) { + // no-op for unsupported browsers + return; + } + + return convertWebExceptions( + () => _delegate.register( + vapidKey: vapidKey, + serviceWorkerScriptPath: serviceWorkerScriptPath, + ), + ); + } + + @override + Future unregister() async { + _delegate; + + if (!_initialized) { + // no-op for unsupported browsers + return; + } + + return convertWebExceptions(_delegate.unregister); + } + + @override + Stream get onRegistered { + return _delegate.onRegistered; + } + + @override + Stream get onUnregistered { + return _delegate.onUnregistered; + } + + @override + @Deprecated('Use register() and onRegistered instead.') Future getToken( {String? vapidKey, String? serviceWorkerScriptPath}) async { _delegate; @@ -128,6 +172,7 @@ class FirebaseMessagingWeb extends FirebaseMessagingPlatform { } @override + @Deprecated('Use onRegistered instead.') Stream get onTokenRefresh { // onTokenRefresh is deprecated on web, however since this is a non-critical // api we just return a noop stream to keep functionality the same across diff --git a/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging.dart b/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging.dart index 265724c2c288..c5b735580e1f 100644 --- a/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging.dart +++ b/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging.dart @@ -41,6 +41,38 @@ class Messaging extends JsObjectWrapper { /// Calling this method will stop the periodic data transmission to the FCM backend. Future deleteToken() => messaging_interop.deleteToken(jsObject).toDart; + Future _buildRegisterOptions({ + String? vapidKey, + String? serviceWorkerScriptPath, + }) async { + web.ServiceWorkerRegistration? serviceWorkerRegistration; + if (serviceWorkerScriptPath != null) { + serviceWorkerRegistration = await web.window.navigator.serviceWorker + .register(serviceWorkerScriptPath.toJS) + .toDart; + } + + return vapidKey == null && serviceWorkerRegistration == null + ? null + : messaging_interop.RegisterOptions( + vapidKey: vapidKey?.toJS, + serviceWorkerRegistration: serviceWorkerRegistration, + ); + } + + Future register({ + String? vapidKey, + String? serviceWorkerScriptPath, + }) async { + final registerOptions = await _buildRegisterOptions( + vapidKey: vapidKey, + serviceWorkerScriptPath: serviceWorkerScriptPath, + ); + await messaging_interop.register(jsObject, registerOptions).toDart; + } + + Future unregister() => messaging_interop.unregister(jsObject).toDart; + /// After calling [requestPermission] you can call this method to get an FCM registration token /// that can be used to send push messages to this user. Future getToken( @@ -82,6 +114,10 @@ class Messaging extends JsObjectWrapper { // ignore: close_sinks StreamController? _onMessageController; + // ignore: close_sinks + StreamController? _onRegisteredController; + // ignore: close_sinks + StreamController? _onUnregisteredController; /// When a push message is received and the user is currently on a page for your origin, /// the message is passed to the page and an [onMessage] event is dispatched with the payload of the push message. @@ -108,6 +144,42 @@ class Messaging extends JsObjectWrapper { } return _controller.stream; } + + Stream get onRegistered => + _createRegistrationStream(_onRegisteredController, true); + + Stream get onUnregistered => + _createRegistrationStream(_onUnregisteredController, false); + + Stream _createRegistrationStream( + StreamController? controller, + bool registered, + ) { + StreamController? _controller = controller; + if (_controller == null) { + _controller = StreamController.broadcast(sync: true); + final nextWrapper = (JSString installationId) { + _controller!.add(installationId.toDart); + }; + final errorWrapper = (JSError e) { + _controller!.addError(e); + }; + + final observer = messaging_interop.Observer( + next: nextWrapper.toJS, + error: errorWrapper.toJS, + ); + + if (registered) { + messaging_interop.onRegistered(jsObject, observer); + _onRegisteredController = _controller; + } else { + messaging_interop.onUnregistered(jsObject, observer); + _onUnregisteredController = _controller; + } + } + return _controller.stream; + } } class NotificationPayload diff --git a/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging_interop.dart b/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging_interop.dart index 9603e421c961..730edd6babc6 100644 --- a/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging_interop.dart +++ b/packages/firebase_messaging/firebase_messaging_web/lib/src/interop/messaging_interop.dart @@ -26,6 +26,15 @@ external JSPromise deleteToken(MessagingJsImpl messaging); external JSPromise getToken( MessagingJsImpl messaging, GetTokenOptions? getTokenOptions); +@JS() +@staticInterop +external JSPromise register( + MessagingJsImpl messaging, RegisterOptions? registerOptions); + +@JS() +@staticInterop +external JSPromise unregister(MessagingJsImpl messaging); + @JS('isSupported') @staticInterop external JSPromise isSupported(); @@ -37,6 +46,20 @@ external JSFunction onMessage( Observer observer, ); +@JS() +@staticInterop +external JSFunction onRegistered( + MessagingJsImpl messaging, + Observer observer, +); + +@JS() +@staticInterop +external JSFunction onUnregistered( + MessagingJsImpl messaging, + Observer observer, +); + extension type MessagingJsImpl._(JSObject _) implements JSObject {} extension type Observer._(JSObject _) implements JSObject { @@ -57,6 +80,18 @@ extension type GetTokenOptions._(JSObject _) implements JSObject { external web.ServiceWorkerRegistration get serviceWorkerRegistration; } +extension type RegisterOptions._(JSObject _) implements JSObject { + // TODO - I imagine we won't be implementing serviceWorkerRegistration type as it extends EventTarget class + // external String get serviceWorkerRegistration + external factory RegisterOptions({ + JSString? vapidKey, + /*dynamic serviceWorkerRegistration */ + web.ServiceWorkerRegistration? serviceWorkerRegistration, + }); + external JSString get vapidKey; + external web.ServiceWorkerRegistration get serviceWorkerRegistration; +} + extension type NotificationPayloadJsImpl._(JSObject _) implements JSObject { external JSString? get title; external JSString? get body; From 55d589c1672e91b271452c5c8fc58aaca628527d Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 28 Jul 2026 12:52:35 +0000 Subject: [PATCH 03/11] feat: add FID registration support for android --- .../FlutterFirebaseMessagingPlugin.java | 50 ++++++++++++++++ .../FlutterFirebaseMessagingService.java | 10 ++++ .../FlutterFirebaseRegisteredLiveData.java | 22 +++++++ .../FlutterFirebaseUnregisteredLiveData.java | 22 +++++++ .../method_channel_messaging_test.dart | 57 ++++++++++++++++++- 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRegisteredLiveData.java create mode 100644 packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseUnregisteredLiveData.java diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java index 868c950899b3..dc933233260c 100644 --- a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingPlugin.java @@ -55,6 +55,12 @@ public class FlutterFirebaseMessagingPlugin private Observer remoteMessageObserver; private final LiveData liveDataToken = FlutterFirebaseTokenLiveData.getInstance(); private Observer tokenObserver; + private final LiveData liveDataRegistered = + FlutterFirebaseRegisteredLiveData.getInstance(); + private Observer registeredObserver; + private final LiveData liveDataUnregistered = + FlutterFirebaseUnregisteredLiveData.getInstance(); + private Observer unregisteredObserver; private RemoteMessage initialMessage; // We store the initial notification in a separate variable @@ -77,10 +83,14 @@ private void initInstance(BinaryMessenger messenger) { channel.invokeMethod("Messaging#onMessage", content); }; tokenObserver = token -> channel.invokeMethod("Messaging#onTokenRefresh", token); + registeredObserver = fid -> channel.invokeMethod("Messaging#onRegistered", fid); + unregisteredObserver = fid -> channel.invokeMethod("Messaging#onUnregistered", fid); // We remove these observers in the onDetachedFromEngine method. Using "observeForever()" // allows us to use without a LifecycleOwner. liveDataRemoteMessage.observeForever(remoteMessageObserver); liveDataToken.observeForever(tokenObserver); + liveDataRegistered.observeForever(registeredObserver); + liveDataUnregistered.observeForever(unregisteredObserver); registerPlugin(channelName, this); } @@ -95,6 +105,8 @@ public void onAttachedToEngine(FlutterPluginBinding binding) { public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { liveDataToken.removeObserver(tokenObserver); liveDataRemoteMessage.removeObserver(remoteMessageObserver); + liveDataRegistered.removeObserver(registeredObserver); + liveDataUnregistered.removeObserver(unregisteredObserver); } @Override @@ -163,6 +175,38 @@ private Task> getToken() { return taskCompletionSource.getTask(); } + private Task register() { + TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); + + cachedThreadPool.execute( + () -> { + try { + Tasks.await(FirebaseMessaging.getInstance().register()); + taskCompletionSource.setResult(null); + } catch (Exception e) { + taskCompletionSource.setException(e); + } + }); + + return taskCompletionSource.getTask(); + } + + private Task unregister() { + TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); + + cachedThreadPool.execute( + () -> { + try { + Tasks.await(FirebaseMessaging.getInstance().unregister()); + taskCompletionSource.setResult(null); + } catch (Exception e) { + taskCompletionSource.setException(e); + } + }); + + return taskCompletionSource.getTask(); + } + private Task subscribeToTopic(Map arguments) { TaskCompletionSource taskCompletionSource = new TaskCompletionSource<>(); @@ -482,6 +526,12 @@ public void onMethodCall(final MethodCall call, @NonNull final Result result) { case "Messaging#getToken": methodCallTask = getToken(); break; + case "Messaging#register": + methodCallTask = register(); + break; + case "Messaging#unregister": + methodCallTask = unregister(); + break; case "Messaging#subscribeToTopic": methodCallTask = subscribeToTopic(call.arguments()); break; diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingService.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingService.java index 85d557453646..c9a5d434813d 100644 --- a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingService.java +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseMessagingService.java @@ -14,6 +14,16 @@ public void onNewToken(@NonNull String token) { FlutterFirebaseTokenLiveData.getInstance().postToken(token); } + @Override + public void onRegistered(@NonNull String installationId) { + FlutterFirebaseRegisteredLiveData.getInstance().postFid(installationId); + } + + @Override + public void onUnregistered(@NonNull String installationId) { + FlutterFirebaseUnregisteredLiveData.getInstance().postFid(installationId); + } + @Override public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { // Added for commenting purposes; diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRegisteredLiveData.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRegisteredLiveData.java new file mode 100644 index 000000000000..16d8228ba1c7 --- /dev/null +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseRegisteredLiveData.java @@ -0,0 +1,22 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.firebase.messaging; + +import androidx.lifecycle.LiveData; + +public class FlutterFirebaseRegisteredLiveData extends LiveData { + private static FlutterFirebaseRegisteredLiveData instance; + + public static FlutterFirebaseRegisteredLiveData getInstance() { + if (instance == null) { + instance = new FlutterFirebaseRegisteredLiveData(); + } + return instance; + } + + public void postFid(String fid) { + postValue(fid); + } +} diff --git a/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseUnregisteredLiveData.java b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseUnregisteredLiveData.java new file mode 100644 index 000000000000..a9168f29e1bf --- /dev/null +++ b/packages/firebase_messaging/firebase_messaging/android/src/main/java/io/flutter/plugins/firebase/messaging/FlutterFirebaseUnregisteredLiveData.java @@ -0,0 +1,22 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.firebase.messaging; + +import androidx.lifecycle.LiveData; + +public class FlutterFirebaseUnregisteredLiveData extends LiveData { + private static FlutterFirebaseUnregisteredLiveData instance; + + public static FlutterFirebaseUnregisteredLiveData getInstance() { + if (instance == null) { + instance = new FlutterFirebaseUnregisteredLiveData(); + } + return instance; + } + + public void postFid(String fid) { + postValue(fid); + } +} diff --git a/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart b/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart index 4e868706acfa..b7143a0ac1b9 100644 --- a/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart +++ b/packages/firebase_messaging/firebase_messaging_platform_interface/test/method_channel_tests/method_channel_messaging_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: require_trailing_commas +// ignore_for_file: deprecated_member_use, require_trailing_commas // Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. @@ -29,6 +29,8 @@ void main() { log.add(call); switch (call.method) { case 'Messaging#deleteToken': + case 'Messaging#register': + case 'Messaging#unregister': case 'Messaging#subscribeToTopic': case 'Messaging#unsubscribeFromTopic': return null; @@ -168,6 +170,51 @@ void main() { ]); }); + test('register', () async { + await messaging.register(); + + // check native method was called + expect(log, [ + isMethodCall( + 'Messaging#register', + arguments: { + 'appName': defaultFirebaseAppName, + }, + ), + ]); + }); + + test('register with web options', () async { + await messaging.register( + vapidKey: 'test-vapid-key', + serviceWorkerScriptPath: 'custom-messaging-sw.js', + ); + + // check native method was called + expect(log, [ + isMethodCall( + 'Messaging#register', + arguments: { + 'appName': defaultFirebaseAppName, + }, + ), + ]); + }); + + test('unregister', () async { + await messaging.unregister(); + + // check native method was called + expect(log, [ + isMethodCall( + 'Messaging#unregister', + arguments: { + 'appName': defaultFirebaseAppName, + }, + ), + ]); + }); + test('requestPermission', () async { // test android response final androidPermissions = await messaging.requestPermission(); @@ -240,6 +287,14 @@ void main() { expect(messaging.onTokenRefresh, isA>()); }); + test('onRegistered', () { + expect(messaging.onRegistered, isA>()); + }); + + test('onUnregistered', () { + expect(messaging.onUnregistered, isA>()); + }); + test('subscribeToTopic', () async { const topic = 'test-topic'; await messaging.subscribeToTopic(topic); From 6b26b280daad9f145025be8e853472a85965a5a1 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 28 Jul 2026 14:09:26 +0000 Subject: [PATCH 04/11] chore: add FID e2e tests --- .../android/app/src/main/AndroidManifest.xml | 3 + .../firebase_messaging_e2e_test.dart | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/tests/android/app/src/main/AndroidManifest.xml b/tests/android/app/src/main/AndroidManifest.xml index 79041cd1bd1f..a928f2ff89d1 100644 --- a/tests/android/app/src/main/AndroidManifest.xml +++ b/tests/android/app/src/main/AndroidManifest.xml @@ -37,6 +37,9 @@ +