Handling Firebase Notifications in Flutter: Practical Tips

hosamhasan

Hosam Hasan

Posted on January 14, 2024

Handling Firebase Notifications in Flutter: Practical Tips

I was assigned the task of handling system notifications for medication reminders. The notifications will include two actions: Take and Skip. The notification should function in all app states, including foreground, background, and terminated. Let's begin with these basic requirements and add more as we progress.

This blog will serve as a QA form for the challenges I faced while implementing this functionality.

Tools helped me along the way:

  • Mockoon - Used as a logging server instead of using print in the console.
  • Google Dev Playground - Used for obtaining an auth token to use Google APIs for sending notifications.
  • Hoppscotch - HTTP client used for sending notifications through the Google API.

What libraries should use to achieve this functionality ?

firebase_messaging, flutter_local_notifications and permission_handler .

How to listen to remote notification in all app states ?

1- Ensure that notification permission is configured and granted

// in AndroidManifist.xml add this permession
// <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Permission.notification.request();
Enter fullscreen mode Exit fullscreen mode

2- Initialize Firebase in the main function.

await Firebase.initializeApp();
Enter fullscreen mode Exit fullscreen mode

3- Configure notification callbacks.

FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
Enter fullscreen mode Exit fullscreen mode

4- Implement notification callbacks.

@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {}

void _onForegroundMessage(RemoteMessage message) {}  
Enter fullscreen mode Exit fullscreen mode
  • _onBackgroundMessage: Triggered when the app receives a remote notification in the background and terminated.
  • _onForegroundMessage: Triggered when the app receives a remote notification in the foreground.

Don't forget @pragma('vm:entry-point') for the background callback to work properly. Now we can show notifications with actions in all states.

Hint: Sometimes, FirebaseMessaging callbacks don't work properly without calling FirebaseMessaging.instance.getToken() first.

How to add actions to remote notifications ?

We can't add actions to notification directly using firebase_messaging package only, we need to use flutter_local_notifications to implement this functionally

1- Initialize and configure flutter_local_notifications.

// call this function in main
Future<void> configLocalNotification() async {
  await FlutterLocalNotificationsPlugin().initialize(
    const InitializationSettings(
      android: AndroidInitializationSettings('@mipmap/ic_launcher'),
    ),
    onDidReceiveNotificationResponse: _onForegroundNotificationResponse,
    onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationResponse,
  );
}
Enter fullscreen mode Exit fullscreen mode

2- Implement action callbacks.

void _onForegroundNotificationResponse(NotificationResponse details) {}

@pragma('vm:entry-point')
void _onBackgroundNotificationResponse(NotificationResponse details) {}
Enter fullscreen mode Exit fullscreen mode
  • _onForegroundNotificationResponse: Triggered when the app receives a notification action in the foreground.
  • _onBackgroundNotificationResponse: Triggered when the app receives a notification action in the background and terminated. Don't forget @pragma('vm:entry-point') for background callback to work properly. Now we can show notification with actions in all states.

3- Receive silent remote notifications or data-only notifications from the backend and then show local notifications with actions once the device receives remote notifications.

{
    "message": {
        "token": "",
        "android": {
            "priority": "HIGH"
        },
        "data": {
            "title": "string",
            "body": "string"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4- Show local notifications with actions on _onBackgroundMessage and _onForegroundMessage callbacks using the FlutterLocalNotificationsPlugin.show(...) method.

How to redirect the user to a specific page if the app was opened from a notification tap?

Call FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails() in the main function to know if the app was opened from a notification or not.

How to bring app to foreground when app receives remote notification on background or terminated state ?

Using android_intent_plus and package_info_plus will help us achieve this feature:

1- Make sure that SYSTEM_ALERT_WINDOW permission is granted

/// <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
// Used to show full app notifcation on lock screen
 Permission.systemAlertWindow.request();
Enter fullscreen mode Exit fullscreen mode

2- use android Intent to open or bring app to foreground

Future<void> _bringAppToForeground() async {
  final package = await PackageInfo.fromPlatform();
  final packageName = package.packageName;
  final intent = AndroidIntent(
    action: 'android.intent.action.MAIN',
    flags: [Flag.FLAG_ACTIVITY_NEW_TASK],
    category: 'android.intent.category.LAUNCHER',
    arguments: {'args': 'run flutter app automatically from notification'},
    package: packageName,
    componentName: '$packageName.MainActivity',
  );
  return intent.launch();
}
Enter fullscreen mode Exit fullscreen mode
  • we need to check arguments on app start to check if app was opened automatically from background
  • Also if we need to bring app to foreground when screen in locked we need to add USE_FULL_SCREEN_INTENT permission to manifest, and config main activity
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />

<!-- add this keys to activity -->
<activity
    ...
    android:showWhenLocked="true"
    android:turnScreenOn="true">
</activity>

Enter fullscreen mode Exit fullscreen mode
  • To check if app was launched using the android Intent we need to check launch arguments using launch_args or receive_intent package or simply implement native android function and call it using method channel and call it in main function
private fun getIntentArgs(): Map<String,Any?>?{
     val args = intent.extras
     val map = mutableMapOf<String,Any?>();
     args?.keySet()?.forEach{
         map[it] = args[it]
     }
     return  if(args == null) null else map
}
Enter fullscreen mode Exit fullscreen mode
const _methodChannel = MethodChannel('channel_name');
Future<Map<String, dynamic>?> getIntentArgs() async {
  final argsResult = await _methodChannel.invokeMapMethod('getIntentArgs');
  final args = argsResult?.cast<String, dynamic>();
  return args;
}
Enter fullscreen mode Exit fullscreen mode

How to close app once user responds to notification if app was opened from notification ?

Using the flutter_exit_app package to close the app completely whenever we want.

How to Display App-Specific UI when Receiving Notifications in Foreground or Background?

While reacting to notification events in the foreground has no challenges since all listeners are registered in the main isolate, the complexity arises when dealing with background events. To address this, a communication mechanism between the main isolate and other isolates becomes essential.

The IsolateNameServer API comes to our rescue in establishing straightforward isolate communication. We achieve this by registering the UIIsolateCommunicationChannel in the main thread using the forceRegister method. Additionally, the listen function facilitates the reception of data through this channel, making use of the send method to communicate with the UIIsolateCommunicationChannel.

abstract class UIIsolateCommunicationChannel {
  static final _receivePort = ReceivePort();
  static const name = 'ui_isolate';

  static bool register() => IsolateNameServer.registerPortWithName(
        _receivePort.sendPort,
        name,
      );

  static void forceRegister() {
    final isRegistered = IsolateNameServer.registerPortWithName(
      _receivePort.sendPort,
      name,
    );

    if (isRegistered == false) {
      IsolateNameServer.removePortNameMapping(name);
      IsolateNameServer.registerPortWithName(
        _receivePort.sendPort,
        name,
      );
    }
  }

  static StreamSubscription listen(void Function(dynamic) listener) => _receivePort.listen(listener);

  static void send(dynamic value) => IsolateNameServer.lookupPortByName(name)?.send(value);

  static bool unregister() => IsolateNameServer.removePortNameMapping(name);

  static bool isRegistered() => IsolateNameServer.lookupPortByName(name) != null;
}
Enter fullscreen mode Exit fullscreen mode

Hints:

  • Supported types for communication channel Link.
  • Also, make sure to use primary constructors from List and Map to pass them through communication channel to work on release mode . issue reference
UIIsolateCommunicationChannel.send((data.toList())) // fails in release mode ❌
UIIsolateCommunicationChannel.send((List.of(data))) //  ✅
Enter fullscreen mode Exit fullscreen mode

Not the End

I hope this small journey has been helpful for you.
Stay tuned for the IOS implementation

Resources:

💖 💪 🙅 🚩
hosamhasan
Hosam Hasan

Posted on January 14, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related