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/example/ios/Runner/Info.plist b/packages/firebase_messaging/firebase_messaging/example/ios/Runner/Info.plist index cd5c989988e6..f0f47c40ac4b 100644 --- a/packages/firebase_messaging/firebase_messaging/example/ios/Runner/Info.plist +++ b/packages/firebase_messaging/firebase_messaging/example/ios/Runner/Info.plist @@ -20,6 +20,8 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + FirebaseMessagingInstallationIdEnabled + LSRequiresIPhoneOS UIBackgroundModes diff --git a/packages/firebase_messaging/firebase_messaging/example/lib/main.dart b/packages/firebase_messaging/firebase_messaging/example/lib/main.dart index 6175abd772c4..e5f9f4e143d2 100644 --- a/packages/firebase_messaging/firebase_messaging/example/lib/main.dart +++ b/packages/firebase_messaging/firebase_messaging/example/lib/main.dart @@ -28,13 +28,13 @@ import 'token_monitor.dart'; /// 2. Run `melos bootstrap` in FlutterFire project. /// 3. In your terminal, root to ./packages/firebase_messaging/firebase_messaging/example directory. /// 4. Run `flutterfire configure` in the example/ directory to setup your app with your Firebase project. -/// 5. Open `token_monitor.dart` and change `vapidKey` to yours. +/// 5. Open `token_monitor.dart` and change `messagingVapidKey` to yours. /// 6. Run the app on an actual device for iOS, android is fine to run on an emulator. /// 7. Use the following script to send a message to your device: scripts/send-message.js. To run this script, /// you will need nodejs installed on your computer. Then the following: /// a. Download a service account key (JSON file) from your Firebase console, rename it to "google-services.json" and add to the example/scripts directory. /// b. Ensure your device/emulator is running, and run the FirebaseMessaging example app using `flutter run`. -/// c. Copy the token that is printed in the console and paste it here: https://github.com/firebase/flutterfire/blob/01b4d357e1/packages/firebase_messaging/firebase_messaging/example/lib/main.dart#L32 +/// c. Copy the FID that is printed in the console and paste it here: https://github.com/firebase/flutterfire/blob/01b4d357e1/packages/firebase_messaging/firebase_messaging/example/lib/main.dart#L32 /// c. From your terminal, root to example/scripts directory & run `npm install`. /// d. Run `npm run send-message` in the example/scripts directory and your app will receive messages in any state; foreground, background, terminated. /// Note: Flutter API documentation for receiving messages: https://firebase.google.com/docs/cloud-messaging/flutter/receive @@ -150,10 +150,10 @@ class MessagingExampleApp extends StatelessWidget { int _messageCount = 0; /// The API endpoint here accepts a raw FCM payload for demonstration purposes. -String constructFCMPayload(String? token) { +String constructFCMPayload(String? fid) { _messageCount++; return jsonEncode({ - 'token': token, + 'fid': fid, 'data': { 'via': 'FlutterFire Cloud Messaging!!!', 'count': _messageCount.toString(), @@ -172,7 +172,7 @@ class Application extends StatefulWidget { } class _Application extends State { - String? _token; + String? _fid; String? initialMessage; bool _resolved = false; @@ -205,8 +205,8 @@ class _Application extends State { } Future sendPushMessage() async { - if (_token == null) { - print('Unable to send FCM message, no token exists.'); + if (_fid == null) { + print('Unable to send FCM message, no FID exists.'); return; } @@ -216,7 +216,7 @@ class _Application extends State { headers: { 'Content-Type': 'application/json; charset=UTF-8', }, - body: constructFCMPayload(_token), + body: constructFCMPayload(_fid), ); print('FCM request for device sent!'); } catch (e) { @@ -248,6 +248,26 @@ class _Application extends State { ); } break; + case 'register': + { + print('FlutterFire Messaging Example: Registering with FCM.'); + await FirebaseMessaging.instance.register( + vapidKey: messagingVapidKey, + ); + print( + 'FlutterFire Messaging Example: Registering with FCM successful.', + ); + } + break; + case 'unregister': + { + print('FlutterFire Messaging Example: Unregistering from FCM.'); + await FirebaseMessaging.instance.unregister(); + print( + 'FlutterFire Messaging Example: Unregistering from FCM successful.', + ); + } + break; case 'get_apns_token': { if (defaultTargetPlatform == TargetPlatform.iOS || @@ -285,6 +305,14 @@ class _Application extends State { value: 'unsubscribe', child: Text('Unsubscribe to topic'), ), + const PopupMenuItem( + value: 'register', + child: Text('Register with FCM'), + ), + const PopupMenuItem( + value: 'unregister', + child: Text('Unregister from FCM'), + ), const PopupMenuItem( value: 'get_apns_token', child: Text('Get APNs token (Apple only)'), @@ -315,15 +343,36 @@ class _Application extends State { ), ), MetaCard( - 'FCM Token', - TokenMonitor((token) { - _token = token; - return token == null - ? const CircularProgressIndicator() - : SelectableText( - token, + 'FCM Registration FID', + RegistrationMonitor((fid) { + _fid = fid; + return Column( + children: [ + if (fid == null) + const Text('No FID registered yet.') + else + SelectableText( + fid, style: const TextStyle(fontSize: 12), - ); + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + ElevatedButton( + onPressed: () => onActionSelected('register'), + child: const Text('Register with FCM'), + ), + ElevatedButton( + onPressed: () => onActionSelected('unregister'), + child: const Text('Unregister from FCM'), + ), + ], + ), + ], + ); }), ), ElevatedButton( diff --git a/packages/firebase_messaging/firebase_messaging/example/lib/token_monitor.dart b/packages/firebase_messaging/firebase_messaging/example/lib/token_monitor.dart index 6fd6daf2843b..7ff1e600e2be 100644 --- a/packages/firebase_messaging/firebase_messaging/example/lib/token_monitor.dart +++ b/packages/firebase_messaging/firebase_messaging/example/lib/token_monitor.dart @@ -4,47 +4,73 @@ // ignore_for_file: require_trailing_commas +import 'dart:async'; + import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -/// Manages & returns the users FCM token. +/// Web push certificate key used by the example app. +/// +/// This is only required by web, and is ignored on other platforms. +const String messagingVapidKey = 'VAPID_KEY'; + +/// Manages and returns the app instance FCM registration FID. /// -/// Also monitors token refreshes and updates state. -class TokenMonitor extends StatefulWidget { +/// Also monitors registration changes and updates state. +class RegistrationMonitor extends StatefulWidget { // ignore: public_member_api_docs - TokenMonitor(this._builder); + RegistrationMonitor(this._builder); - final Widget Function(String? token) _builder; + final Widget Function(String? fid) _builder; @override - State createState() => _TokenMonitor(); + State createState() => _RegistrationMonitor(); } -class _TokenMonitor extends State { - String? _token; - late Stream _tokenStream; +class _RegistrationMonitor extends State { + String? _fid; + StreamSubscription? _registeredSubscription; + StreamSubscription? _unregisteredSubscription; - void setToken(String? token) { - print('FCM Token: $token'); + void setRegisteredFid(String fid) { + print('FCM registered FID: $fid'); setState(() { - _token = token; + _fid = fid; + }); + } + + void setUnregisteredFid(String fid) { + print('FCM unregistered FID: $fid'); + setState(() { + if (_fid == fid) { + _fid = null; + } }); } @override void initState() { super.initState(); + _registeredSubscription = + FirebaseMessaging.instance.onRegistered.listen(setRegisteredFid); + _unregisteredSubscription = + FirebaseMessaging.instance.onUnregistered.listen(setUnregisteredFid); FirebaseMessaging.instance - .getToken( - vapidKey: - 'BNKkaUWxyP_yC_lki1kYazgca0TNhuzt2drsOrL6WrgGbqnMnr8ZMLzg_rSPDm6HKphABS0KzjPfSqCXHXEd06Y') - .then(setToken); - _tokenStream = FirebaseMessaging.instance.onTokenRefresh; - _tokenStream.listen(setToken); + .register(vapidKey: messagingVapidKey) + .catchError((Object error) { + print('FCM registration failed: $error'); + }); + } + + @override + void dispose() { + _registeredSubscription?.cancel(); + _unregisteredSubscription?.cancel(); + super.dispose(); } @override Widget build(BuildContext context) { - return widget._builder(_token); + return widget._builder(_fid); } } diff --git a/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m b/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m index 46684663681b..ef73b1604d04 100644 --- a/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m +++ b/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m @@ -162,6 +162,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)flutter withMethodCallResult:methodCallResult]; } else if ([@"Messaging#getToken" isEqualToString:call.method]) { [self messagingGetToken:call.arguments withMethodCallResult:methodCallResult]; + } else if ([@"Messaging#register" isEqualToString:call.method]) { + [self messagingRegister:call.arguments withMethodCallResult:methodCallResult]; + } else if ([@"Messaging#unregister" isEqualToString:call.method]) { + [self messagingUnregister:call.arguments withMethodCallResult:methodCallResult]; } else if ([@"Messaging#getNotificationSettings" isEqualToString:call.method]) { if (@available(iOS 10, macOS 10.14, *)) { [self messagingGetNotificationSettings:call.arguments withMethodCallResult:methodCallResult]; @@ -188,6 +192,13 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)flutter methodCallResult.success(FlutterMethodNotImplemented); } } + +- (FIRMessaging *)messagingWithDelegate { + FIRMessaging *messaging = [FIRMessaging messaging]; + messaging.delegate = self; + return messaging; +} + - (void)messagingSetForegroundNotificationPresentationOptions:(id)arguments withMethodCallResult: (FLTFirebaseMethodCallResult *)result { @@ -233,6 +244,44 @@ - (void)messaging:(nonnull FIRMessaging *)messaging } } +- (void)messaging:(nonnull FIRMessaging *)messaging + didReceiveRegistration:(nullable NSString *)installationId { + if (installationId == nil) { + return; + } + + [_channel invokeMethod:@"Messaging#onRegistered" arguments:installationId]; + + // If the users AppDelegate implements messaging:didReceiveRegistration: then call it as well + // so we don't break other libraries. + SEL messaging_didReceiveRegistrationSelector = + NSSelectorFromString(@"messaging:didReceiveRegistration:"); + if ([[GULAppDelegateSwizzler sharedApplication].delegate + respondsToSelector:messaging_didReceiveRegistrationSelector]) { + void (*usersDidReceiveRegistrationIMP)(id, SEL, FIRMessaging *, NSString *) = + (typeof(usersDidReceiveRegistrationIMP))&objc_msgSend; + usersDidReceiveRegistrationIMP([GULAppDelegateSwizzler sharedApplication].delegate, + messaging_didReceiveRegistrationSelector, messaging, + installationId); + } +} + +- (void)messaging:(nonnull FIRMessaging *)messaging + didUnregister:(nonnull NSString *)installationId { + [_channel invokeMethod:@"Messaging#onUnregistered" arguments:installationId]; + + // If the users AppDelegate implements messaging:didUnregister: then call it as well + // so we don't break other libraries. + SEL messaging_didUnregisterSelector = NSSelectorFromString(@"messaging:didUnregister:"); + if ([[GULAppDelegateSwizzler sharedApplication].delegate + respondsToSelector:messaging_didUnregisterSelector]) { + void (*usersDidUnregisterIMP)(id, SEL, FIRMessaging *, NSString *) = + (typeof(usersDidUnregisterIMP))&objc_msgSend; + usersDidUnregisterIMP([GULAppDelegateSwizzler sharedApplication].delegate, + messaging_didUnregisterSelector, messaging, installationId); + } +} + #pragma mark - NSNotificationCenter Observers - (void)setupNotificationHandlingWithRemoteNotification: @@ -864,6 +913,29 @@ - (void)messagingDeleteToken:(id)arguments }]; } +- (void)messagingRegister:(id)arguments withMethodCallResult:(FLTFirebaseMethodCallResult *)result { + FIRMessaging *messaging = [self messagingWithDelegate]; + [messaging registerWithCompletion:^(NSError *_Nullable error) { + if (error != nil) { + result.error(nil, nil, nil, error); + } else { + result.success(nil); + } + }]; +} + +- (void)messagingUnregister:(id)arguments + withMethodCallResult:(FLTFirebaseMethodCallResult *)result { + FIRMessaging *messaging = [self messagingWithDelegate]; + [messaging unregisterWithCompletion:^(NSError *_Nullable error) { + if (error != nil) { + result.error(nil, nil, nil, error); + } else { + result.success(nil); + } + }]; +} + #pragma mark - FLTFirebasePlugin - (void)didReinitializeFirebaseCore:(void (^)(void))completion { @@ -871,8 +943,9 @@ - (void)didReinitializeFirebaseCore:(void (^)(void))completion { } - (NSDictionary *_Nonnull)pluginConstantsForFIRApp:(FIRApp *)firebase_app { + FIRMessaging *messaging = [self messagingWithDelegate]; return @{ - @"AUTO_INIT_ENABLED" : @([FIRMessaging messaging].isAutoInitEnabled), + @"AUTO_INIT_ENABLED" : @(messaging.isAutoInitEnabled), }; } 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..b107e9df11bb 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,32 @@ class MethodChannelFirebaseMessaging extends FirebaseMessagingPlatform { } @override + Future register({ + String? vapidKey, // web only property + String? serviceWorkerScriptPath, // web only property + }) async { + try { + await channel.invokeMapMethod('Messaging#register', { + 'appName': app.name, + }); + } catch (e, stack) { + convertPlatformException(e, stack); + } + } + + @override + Future unregister() async { + 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 +374,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/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); 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..3c3af075c7cd 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 { @@ -46,11 +69,17 @@ extension type Observer._(JSObject _) implements JSObject { } extension type GetTokenOptions._(JSObject _) implements JSObject { - // TODO - I imagine we won't be implementing serviceWorkerRegistration type as it extends EventTarget class - // external String get serviceWorkerRegistration external factory GetTokenOptions({ JSString? vapidKey, - /*dynamic serviceWorkerRegistration */ + web.ServiceWorkerRegistration? serviceWorkerRegistration, + }); + external JSString get vapidKey; + external web.ServiceWorkerRegistration get serviceWorkerRegistration; +} + +extension type RegisterOptions._(JSObject _) implements JSObject { + external factory RegisterOptions({ + JSString? vapidKey, web.ServiceWorkerRegistration? serviceWorkerRegistration, }); external JSString get vapidKey; 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 @@ +