diff --git a/packages/firebase_messaging/README.md b/packages/firebase_messaging/README.md index fced940d7b7f..47676faac17d 100644 --- a/packages/firebase_messaging/README.md +++ b/packages/firebase_messaging/README.md @@ -54,7 +54,77 @@ Note: When you are debugging on Android, use a device or AVD with Google Play se ``` - +#### Optionally handle background messages + +By default background messaging is not enabled. To handle messages in the background: + +1. Add an Application.java class to your app + + ``` + package io.flutter.plugins.firebasemessagingexample; + + import io.flutter.app.FlutterApplication; + import io.flutter.plugin.common.PluginRegistry; + import io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback; + import io.flutter.plugins.GeneratedPluginRegistrant; + import io.flutter.plugins.firebasemessaging.FlutterFirebaseMessagingService; + + public class Application extends FlutterApplication implements PluginRegistrantCallback { + @Override + public void onCreate() { + super.onCreate(); + FlutterFirebaseMessagingService.setPluginRegistrant(this); + } + + @Override + public void registerWith(PluginRegistry registry) { + GeneratedPluginRegistrant.registerWith(registry); + } + } + ``` +1. Set name property of application in `AndroidManifest.xml` + ``` + + ``` +1. Define a top level Dart method to handle background messages + ``` + Future myBackgroundMessageHandler(Map message) { + if (message.containsKey('data')) { + // Handle data message + dynamic data = message['data']; + } + + if (message.containsKey('notification')) { + // Handle notification message + dynamic notification = message['notification']; + } + + // Or do other work. + } + ``` + Note: the protocol of `data` and `notification` are in line with the + fields defined by a [RemoteMessage](https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/RemoteMessage). +1. Set `onBackgroundMessage` handler when calling `configure` + ``` + _firebaseMessaging.configure( + onMessage: (Map message) async { + print("onMessage: $message"); + _showItemDialog(message); + }, + onBackgroundMessage: myBackgroundMessageHandler, + onLaunch: (Map message) async { + print("onLaunch: $message"); + _navigateToItemDetail(message); + }, + onResume: (Map message) async { + print("onResume: $message"); + _navigateToItemDetail(message); + }, + ); + ``` + Note: `configure` should be called early in the lifecycle of your application + so that it can be ready to receive messages as early as possible. See the + example app for a demonstration. ### iOS Integration diff --git a/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FirebaseMessagingPlugin.java b/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FirebaseMessagingPlugin.java index 4ab56e01d299..28e7eb2f1152 100644 --- a/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FirebaseMessagingPlugin.java +++ b/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FirebaseMessagingPlugin.java @@ -41,9 +41,15 @@ public class FirebaseMessagingPlugin extends BroadcastReceiver public static void registerWith(Registrar registrar) { final MethodChannel channel = new MethodChannel(registrar.messenger(), "plugins.flutter.io/firebase_messaging"); + final MethodChannel backgroundCallbackChannel = + new MethodChannel( + registrar.messenger(), "plugins.flutter.io/firebase_messaging_background"); final FirebaseMessagingPlugin plugin = new FirebaseMessagingPlugin(registrar, channel); registrar.addNewIntentListener(plugin); channel.setMethodCallHandler(plugin); + backgroundCallbackChannel.setMethodCallHandler(plugin); + + FlutterFirebaseMessagingService.setBackgroundChannel(backgroundCallbackChannel); } private FirebaseMessagingPlugin(Registrar registrar, MethodChannel channel) { @@ -99,7 +105,40 @@ private Map parseRemoteMessage(RemoteMessage message) { @Override public void onMethodCall(final MethodCall call, final Result result) { - if ("configure".equals(call.method)) { + /* Even when the app is not active the `FirebaseMessagingService` extended by + * `FlutterFirebaseMessagingService` allows incoming FCM messages to be handled. + * + * `FcmDartService#start` and `FcmDartService#initialized` are the two methods used + * to optionally setup handling messages received while the app is not active. + * + * `FcmDartService#start` sets up the plumbing that allows messages received while + * the app is not active to be handled by a background isolate. + * + * `FcmDartService#initialized` is called by the Dart side when the plumbing for + * background message handling is complete. + */ + if ("FcmDartService#start".equals(call.method)) { + long setupCallbackHandle = 0; + long backgroundMessageHandle = 0; + try { + Map callbacks = ((Map) call.arguments); + setupCallbackHandle = callbacks.get("setupHandle"); + backgroundMessageHandle = callbacks.get("backgroundHandle"); + } catch (Exception e) { + Log.e(TAG, "There was an exception when getting callback handle from Dart side"); + e.printStackTrace(); + } + FlutterFirebaseMessagingService.setBackgroundSetupHandle( + this.registrar.context(), setupCallbackHandle); + FlutterFirebaseMessagingService.startBackgroundIsolate( + this.registrar.context(), setupCallbackHandle); + FlutterFirebaseMessagingService.setBackgroundMessageHandle( + this.registrar.context(), backgroundMessageHandle); + result.success(true); + } else if ("FcmDartService#initialized".equals(call.method)) { + FlutterFirebaseMessagingService.onInitialized(); + result.success(true); + } else if ("configure".equals(call.method)) { FirebaseInstanceId.getInstance() .getInstanceId() .addOnCompleteListener( diff --git a/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FlutterFirebaseMessagingService.java b/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FlutterFirebaseMessagingService.java index 345c0161e192..97d898dcc8f2 100644 --- a/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FlutterFirebaseMessagingService.java +++ b/packages/firebase_messaging/android/src/main/java/io/flutter/plugins/firebasemessaging/FlutterFirebaseMessagingService.java @@ -4,10 +4,31 @@ package io.flutter.plugins.firebasemessaging; +import android.app.ActivityManager; +import android.app.KeyguardManager; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Process; +import android.util.Log; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.PluginRegistry; +import io.flutter.view.FlutterCallbackInformation; +import io.flutter.view.FlutterMain; +import io.flutter.view.FlutterNativeView; +import io.flutter.view.FlutterRunArguments; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; public class FlutterFirebaseMessagingService extends FirebaseMessagingService { @@ -18,16 +39,81 @@ public class FlutterFirebaseMessagingService extends FirebaseMessagingService { public static final String ACTION_TOKEN = "io.flutter.plugins.firebasemessaging.TOKEN"; public static final String EXTRA_TOKEN = "token"; + private static final String SHARED_PREFERENCES_KEY = "io.flutter.android_fcm_plugin"; + private static final String BACKGROUND_SETUP_CALLBACK_HANDLE_KEY = "background_setup_callback"; + private static final String BACKGROUND_MESSAGE_CALLBACK_HANDLE_KEY = + "background_message_callback"; + + // TODO(kroikie): make sIsIsolateRunning per-instance, not static. + private static AtomicBoolean sIsIsolateRunning = new AtomicBoolean(false); + + /** Background Dart execution context. */ + private static FlutterNativeView sBackgroundFlutterView; + + private static MethodChannel sBackgroundChannel; + + private static Long sBackgroundMessageHandle; + + private static List sBackgroundMessageQueue = + Collections.synchronizedList(new LinkedList()); + + private static PluginRegistry.PluginRegistrantCallback sPluginRegistrantCallback; + + private static final String TAG = "FlutterFcmService"; + + private static Context sBackgroundContext; + + @Override + public void onCreate() { + super.onCreate(); + + sBackgroundContext = getApplicationContext(); + FlutterMain.ensureInitializationComplete(sBackgroundContext, null); + + // If background isolate is not running start it. + if (!sIsIsolateRunning.get()) { + SharedPreferences p = sBackgroundContext.getSharedPreferences(SHARED_PREFERENCES_KEY, 0); + long callbackHandle = p.getLong(BACKGROUND_SETUP_CALLBACK_HANDLE_KEY, 0); + startBackgroundIsolate(sBackgroundContext, callbackHandle); + } + } + /** * Called when message is received. * * @param remoteMessage Object representing the message received from Firebase Cloud Messaging. */ @Override - public void onMessageReceived(RemoteMessage remoteMessage) { - Intent intent = new Intent(ACTION_REMOTE_MESSAGE); - intent.putExtra(EXTRA_REMOTE_MESSAGE, remoteMessage); - LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + public void onMessageReceived(final RemoteMessage remoteMessage) { + // If application is running in the foreground use local broadcast to handle message. + // Otherwise use the background isolate to handle message. + if (isApplicationForeground(this)) { + Intent intent = new Intent(ACTION_REMOTE_MESSAGE); + intent.putExtra(EXTRA_REMOTE_MESSAGE, remoteMessage); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + } else { + // If background isolate is not running yet, put message in queue and it will be handled + // when the isolate starts. + if (!sIsIsolateRunning.get()) { + sBackgroundMessageQueue.add(remoteMessage); + } else { + final CountDownLatch latch = new CountDownLatch(1); + new Handler(getMainLooper()) + .post( + new Runnable() { + @Override + public void run() { + executeDartCallbackInBackgroundIsolate( + FlutterFirebaseMessagingService.this, remoteMessage, latch); + } + }); + try { + latch.await(); + } catch (InterruptedException ex) { + Log.i(TAG, "Exception waiting to execute Dart callback", ex); + } + } + } } /** @@ -42,4 +128,221 @@ public void onNewToken(String token) { intent.putExtra(EXTRA_TOKEN, token); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); } + + /** + * Setup the background isolate that would allow background messages to be handled on the Dart + * side. Called either by the plugin when the app is starting up or when the app receives a + * message while it is inactive. + * + * @param context Registrar or FirebaseMessagingService context. + * @param callbackHandle Handle used to retrieve the Dart function that sets up background + * handling on the dart side. + */ + public static void startBackgroundIsolate(Context context, long callbackHandle) { + FlutterMain.ensureInitializationComplete(context, null); + String appBundlePath = FlutterMain.findAppBundlePath(context); + FlutterCallbackInformation flutterCallback = + FlutterCallbackInformation.lookupCallbackInformation(callbackHandle); + if (flutterCallback == null) { + Log.e(TAG, "Fatal: failed to find callback"); + return; + } + + // Note that we're passing `true` as the second argument to our + // FlutterNativeView constructor. This specifies the FlutterNativeView + // as a background view and does not create a drawing surface. + sBackgroundFlutterView = new FlutterNativeView(context, true); + if (appBundlePath != null && !sIsIsolateRunning.get()) { + if (sPluginRegistrantCallback == null) { + throw new RuntimeException("PluginRegistrantCallback is not set."); + } + FlutterRunArguments args = new FlutterRunArguments(); + args.bundlePath = appBundlePath; + args.entrypoint = flutterCallback.callbackName; + args.libraryPath = flutterCallback.callbackLibraryPath; + sBackgroundFlutterView.runFromBundle(args); + sPluginRegistrantCallback.registerWith(sBackgroundFlutterView.getPluginRegistry()); + } + } + + /** + * Acknowledge that background message handling on the Dart side is ready. This is called by the + * Dart side once all background initialization is complete via `FcmDartService#initialized`. + */ + public static void onInitialized() { + sIsIsolateRunning.set(true); + synchronized (sBackgroundMessageQueue) { + // Handle all the messages received before the Dart isolate was + // initialized, then clear the queue. + Iterator i = sBackgroundMessageQueue.iterator(); + while (i.hasNext()) { + executeDartCallbackInBackgroundIsolate(sBackgroundContext, i.next(), null); + } + sBackgroundMessageQueue.clear(); + } + } + + /** + * Set the method channel that is used for handling background messages. This method is only + * called when the plugin registers. + * + * @param channel Background method channel. + */ + public static void setBackgroundChannel(MethodChannel channel) { + sBackgroundChannel = channel; + } + + /** + * Set the background message handle for future use. When background messages need to be handled + * on the Dart side the handler must be retrieved in the background isolate to allow processing of + * the incoming message. This method is called by the Dart side via `FcmDartService#start`. + * + * @param context Registrar context. + * @param handle Handle representing the Dart side method that will handle background messages. + */ + public static void setBackgroundMessageHandle(Context context, Long handle) { + sBackgroundMessageHandle = handle; + + // Store background message handle in shared preferences so it can be retrieved + // by other application instances. + SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFERENCES_KEY, 0); + prefs.edit().putLong(BACKGROUND_MESSAGE_CALLBACK_HANDLE_KEY, handle).apply(); + } + + /** + * Set the background message setup handle for future use. The Dart side of this plugin has a + * method that sets up the background method channel. When ready to setup the background channel + * the Dart side needs to be able to retrieve the setup method. This method is called by the Dart + * side via `FcmDartService#start`. + * + * @param context Registrar context. + * @param setupBackgroundHandle Handle representing the dart side method that will setup the + * background method channel. + */ + public static void setBackgroundSetupHandle(Context context, long setupBackgroundHandle) { + // Store background setup handle in shared preferences so it can be retrieved + // by other application instances. + SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFERENCES_KEY, 0); + prefs.edit().putLong(BACKGROUND_SETUP_CALLBACK_HANDLE_KEY, setupBackgroundHandle).apply(); + } + + /** + * Retrieve the background message handle. When a background message is received and must be + * processed on the dart side the handle representing the Dart side handle is retrieved so the + * appropriate method can be called to process the message on the Dart side. This method is called + * by FlutterFirebaseMessagingServcie either when a new background message is received or if + * background messages were queued up while background message handling was being setup. + * + * @param context Application context. + * @return Dart side background message handle. + */ + public static Long getBackgroundMessageHandle(Context context) { + return context + .getSharedPreferences(SHARED_PREFERENCES_KEY, 0) + .getLong(BACKGROUND_MESSAGE_CALLBACK_HANDLE_KEY, 0); + } + + /** + * Process the incoming message in the background isolate. This method is called only after + * background method channel is setup, it is called by FlutterFirebaseMessagingServcie either when + * a new background message is received or after background method channel setup for queued + * messages received during setup. + * + * @param context Application or FirebaseMessagingService context. + * @param remoteMessage Message received from Firebase Cloud Messaging. + * @param latch If set will count down when the Dart side message processing is complete. Allowing + * any waiting threads to continue. + */ + private static void executeDartCallbackInBackgroundIsolate( + Context context, RemoteMessage remoteMessage, final CountDownLatch latch) { + if (sBackgroundChannel == null) { + throw new RuntimeException( + "setBackgroundChannel was not called before messages came in, exiting."); + } + + // If another thread is waiting, then wake that thread when the callback returns a result. + MethodChannel.Result result = null; + if (latch != null) { + result = + new MethodChannel.Result() { + @Override + public void success(Object result) { + latch.countDown(); + } + + @Override + public void error(String errorCode, String errorMessage, Object errorDetails) { + latch.countDown(); + } + + @Override + public void notImplemented() { + latch.countDown(); + } + }; + } + + Map args = new HashMap<>(); + Map messageData = new HashMap<>(); + if (sBackgroundMessageHandle == null) { + sBackgroundMessageHandle = getBackgroundMessageHandle(context); + } + args.put("handle", sBackgroundMessageHandle); + + if (remoteMessage.getData() != null) { + messageData.put("data", remoteMessage.getData()); + } + if (remoteMessage.getNotification() != null) { + messageData.put("notification", remoteMessage.getNotification()); + } + + args.put("message", messageData); + + sBackgroundChannel.invokeMethod("handleBackgroundMessage", args, result); + } + + /** + * Set the registrant callback. This is called by the app's Application class if background + * message handling is enabled. + * + * @param callback Application class which implements PluginRegistrantCallback. + */ + public static void setPluginRegistrant(PluginRegistry.PluginRegistrantCallback callback) { + sPluginRegistrantCallback = callback; + } + + /** + * Identify if the application is currently in a state where user interaction is possible. This + * method is only called by FlutterFirebaseMessagingService when a message is received to + * determine how the incoming message should be handled. + * + * @param context FlutterFirebaseMessagingService context. + * @return True if the application is currently in a state where user interaction is possible, + * false otherwise. + */ + // TODO(kroikie): Find a better way to determine application state. + private static boolean isApplicationForeground(Context context) { + KeyguardManager keyguardManager = + (KeyguardManager) context.getSystemService(Context.KEYGUARD_SERVICE); + + if (keyguardManager.inKeyguardRestrictedInputMode()) { + return false; + } + int myPid = Process.myPid(); + + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + + List list; + + if ((list = activityManager.getRunningAppProcesses()) != null) { + for (ActivityManager.RunningAppProcessInfo aList : list) { + ActivityManager.RunningAppProcessInfo info; + if ((info = aList).pid == myPid) { + return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; + } + } + } + return false; + } } diff --git a/packages/firebase_messaging/lib/firebase_messaging.dart b/packages/firebase_messaging/lib/firebase_messaging.dart index f40420c6dbe5..794aa9980a70 100644 --- a/packages/firebase_messaging/lib/firebase_messaging.dart +++ b/packages/firebase_messaging/lib/firebase_messaging.dart @@ -3,13 +3,54 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:platform/platform.dart'; typedef Future MessageHandler(Map message); +/// Setup method channel to handle Firebase Cloud Messages received while +/// the Flutter app is not active. The handle for this method is generated +/// and passed to the Android side so that the background isolate knows where +/// to send background messages for processing. +/// +/// Your app should never call this method directly, this is only for use +/// by the firebase_messaging plugin to setup background message handling. +@visibleForTesting +Future fcmSetupBackgroundChannel( + {MethodChannel backgroundChannel = const MethodChannel( + 'plugins.flutter.io/firebase_messaging_background')}) async { + // Setup Flutter state needed for MethodChannels. + WidgetsFlutterBinding.ensureInitialized(); + + // This is where the magic happens and we handle background events from the + // native portion of the plugin. + backgroundChannel.setMethodCallHandler((MethodCall call) async { + if (call.method == 'handleBackgroundMessage') { + final CallbackHandle handle = + CallbackHandle.fromRawHandle(call.arguments['handle']); + final Function handlerFunction = + PluginUtilities.getCallbackFromHandle(handle); + try { + await handlerFunction( + Map.from(call.arguments['message'])); + } catch (e) { + print('Unable to handle incoming background message.'); + print(e); + } + return Future.value(); + } + }); + +// Once we've finished initializing, let the native portion of the plugin +// know that it can start scheduling handling messages. + await backgroundChannel.invokeMethod('FcmDartService#initialized'); +} + /// Implementation of the Firebase Cloud Messaging API for Flutter. /// /// Your app should call [requestNotificationPermissions] first and then @@ -30,6 +71,7 @@ class FirebaseMessaging { final Platform _platform; MessageHandler _onMessage; + MessageHandler _onBackgroundMessage; MessageHandler _onLaunch; MessageHandler _onResume; @@ -59,6 +101,7 @@ class FirebaseMessaging { /// Sets up [MessageHandler] for incoming messages. void configure({ MessageHandler onMessage, + MessageHandler onBackgroundMessage, MessageHandler onLaunch, MessageHandler onResume, }) { @@ -67,6 +110,20 @@ class FirebaseMessaging { _onResume = onResume; _channel.setMethodCallHandler(_handleMethod); _channel.invokeMethod('configure'); + if (onBackgroundMessage != null) { + _onBackgroundMessage = onBackgroundMessage; + final CallbackHandle backgroundSetupHandle = + PluginUtilities.getCallbackHandle(fcmSetupBackgroundChannel); + final CallbackHandle backgroundMessageHandle = + PluginUtilities.getCallbackHandle(_onBackgroundMessage); + _channel.invokeMethod( + 'FcmDartService#start', + { + 'setupHandle': backgroundSetupHandle.toRawHandle(), + 'backgroundHandle': backgroundMessageHandle.toRawHandle() + }, + ); + } } final StreamController _tokenStreamController = diff --git a/packages/firebase_messaging/test/firebase_messaging_test.dart b/packages/firebase_messaging/test/firebase_messaging_test.dart index a4e6d7c04756..057d178e4237 100644 --- a/packages/firebase_messaging/test/firebase_messaging_test.dart +++ b/packages/firebase_messaging/test/firebase_messaging_test.dart @@ -12,10 +12,12 @@ import 'package:test/test.dart'; void main() { MockMethodChannel mockChannel; + MockMethodChannel mockBackgroundChannel; FirebaseMessaging firebaseMessaging; setUp(() { mockChannel = MockMethodChannel(); + mockBackgroundChannel = MockMethodChannel(); firebaseMessaging = FirebaseMessaging.private( mockChannel, FakePlatform(operatingSystem: 'ios')); }); @@ -163,6 +165,12 @@ void main() { verify(mockChannel.invokeMethod('setAutoInitEnabled', false)); }); + + test('setupBackgroundCallback', () { + fcmSetupBackgroundChannel(backgroundChannel: mockBackgroundChannel); + verify( + mockBackgroundChannel.invokeMethod('FcmDartService#initialized')); + }); } class MockMethodChannel extends Mock implements MethodChannel {}