[App Note] Flutter and FCM

Using firebase_message and local_notification

Posted by Jamie on Monday, March 28, 2022

Using FCM in Flutter

Preface

FCM (Firebase Cloud Message) uses Firebase to control sending and receiving notifications. When I was working on a project, I found there weren’t many articles online sharing how to set it up, and the ones that did exist were quite inconsistent (probably because the firebase_message library updates so often), so I’m recording how the current project does it.

To be honest I still don’t fully understand the why behind everything, so I’m just recording it for now and will fill in more explanation later.

Project Versions

  • Flutter: 2.5.3
  • firebase_core: ^1.9.0
  • firebase_messaging: ^10.0.9
  • flutter_local_notifications: ^9.1.5

Process

Initializing the Firebase Project

  • First, in the Firebase project, download the SDKs needed for ios/android (GoogleService-Info.plist / google-services.json).
    • Make sure the project name matches (e.g. com.expamle.demo).
    • For iOS projects, it’s best to use Xcode to import GoogleService-Info.plist. (But iOS also requires some setup on the App Developer side, which I’ll skip here.)
  • Download the required libraries in pubspec.yaml.

Android Setup - in {Flutter project}/android/app/build.gradle - (when running you may hit some Android version errors; I made a few modifications too)

//firebase setting
apply plugin: 'com.google.gms.google-services'

android {
    // modify 29 to 31 as setting flutter persission
    compileSdkVersion 31

    defaultConfig {
        //modify from 20 to 23 due to firebase services
        minSdkVersion 23
    }
    // ... other setting
}

dependencies {
// ... other dependencies
// add this line
implementation platform('com.google.firebase:firebase-bom:29.0.0')
}
  • In {Flutter project}/android/build.gradle:

    buildscript {
    dependencies {
    // ... other dependencies
    // add this line
     classpath 'com.google.gms:google-services:4.3.10'
    	}
    }
    

iOS Setup

  • In {Flutter Project}/ios/Runner/AppDelegate.swift:

    import UIKit
    import Flutter
    
    @UIApplicationMain
    @objc class AppDelegate: FlutterAppDelegate {
    override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
    //firebase setting
    if #available(iOS 10.0, *) {
      UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
    }
    //firebase setting end
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }
    }
    

Flutter Code Setup

In main.dart

Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
}

void main() async {

	///Firebase.initializeApp() needs to call native code to initialize Firebase,
	///and since this library uses a flutter channel to call native code, that is done asynchronously,
	///so WidgetsFlutterBinding.ensureInitialized() must be called to ensure there is a WidgetsBinding instance
	WidgetsFlutterBinding.ensureInitialized()
	await Firebase.initializeApp();

	///allow the device to receive notifications even when the app is not launched
	FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

	/// library: flutter_local_notifications
	LocalNotificationService.requestIOSPermissions();

	runApp(MyApp())
}

class MyApp extends StatelessWidget {
	@override
	Widget build(BuildContext context) {
		 FirebaseNotificationService.initFirebaseNotificaiton(context);
	}
}

Add a new file under lib/, firebase_notification_service.dart


class FirebaseNotificationService {
  static initFirebaseNotificaiton(BuildContext context, String route) {
    /// initialize LocalNotificationService
    /// pass context in so we can use Navigator
    LocalNotificationService.initialize(context, route);

    ///give the message which user taps when app is from terminated state
    FirebaseMessaging.instance.getInitialMessage().then((message) {
      if (message != null) {
        // go to main screen
        // Navigator.of(context).pushNamed(route);
      }
    });

    var lock = Lock();

    ///stream listener
    ///only work in the foreground
    FirebaseMessaging.onMessage.listen((message) async {
      if (!lock.locked) {
        await lock.synchronized(() {
          String preMsgId = context.read<TicketsQueueProvider>().preMsgId;
          String newMsgId = message.messageId;
          if (newMsgId != preMsgId) {
            LocalNotificationService.display(message);
          }
          Provider.of<TicketsQueueProvider>(context, listen: false)
              .setPreMsgId(newMsgId);
        });
      }
    });

    ///stream listener
    ///only when app in the background but not close, and user tap notifiction
    ///user tap notification
    FirebaseMessaging.onMessageOpenedApp.listen((message) {
      // do nothing, could go to previous screen(route)
    });
  }
}
  • The reason I added the synchronized lock is that during real-device testing on iOS I noticed that FirebaseMessaging.onMessage would receive the same message.id more than once. So in the project I use Lock to ensure only one message.id is compared at a time, and use Provider to globally store the previous message.id.

Add a new file under lib/, local_notification_service.dart


class LocalNotificationService {
  ///Singleton pattern
  ///Goal: ensure that a class produces only one instance and provides a unified way to access it
  static final LocalNotificationService _notificationService =
      LocalNotificationService._internal();
  factory LocalNotificationService() {
    return _notificationService;
  }
  LocalNotificationService._internal();


  static void requestIOSPermissions() {
    notificationsPlugin
        .resolvePlatformSpecificImplementation<
            IOSFlutterLocalNotificationsPlugin>()
        ?.requestPermissions(
          alert: true,
          badge: true,
          sound: true,
        );
  }

  static final FlutterLocalNotificationsPlugin notificationsPlugin =
      FlutterLocalNotificationsPlugin();

  static void initialize(
      BuildContext context, String defaultRouteForMessage) async {
    final InitializationSettings initializationSettings = InitializationSettings(

        ///android default icon
        ///route should be: {project}\android\app\src\main\res\drawable\YOUR_APP_ICON.png
        android: AndroidInitializationSettings("@drawable/ic_notification"),

        ///ios default setting
        iOS: IOSInitializationSettings(
          requestSoundPermission: false,
          requestBadgePermission: false,
          requestAlertPermission: false,
        ));

    /// Create an Android Notification Channel.
    /// We use this channel in the `AndroidManifest.xml` file to override the
    /// default FCM channel to enable heads up notifications.
    AndroidNotificationChannel channel = const AndroidNotificationChannel(
      'high_importance_channel', // id
      'High Importance Notifications', // title
      importance: Importance.high,
    );
    await notificationsPlugin
        .resolvePlatformSpecificImplementation<
            AndroidFlutterLocalNotificationsPlugin>()
        ?.createNotificationChannel(channel);

    await notificationsPlugin.initialize(initializationSettings,
        onSelectNotification: (String route) async {
      print('---selected notification');
      // Navigator.of(context)
      //     .pushNamedAndRemoveUntil(defaultRouteForMessage, (route) => false);
      // Navigator.of(context).pushNamed(defaultRouteForMessage);
    });
  }

  static void testDisplay() async {
    try {
      final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;

      /// notification settings such as channel, priority, etc.
      final notificationDetails = NotificationDetails(
        android: AndroidNotificationDetails(
          'high_importance_channel', //channel id
          'High Importance Notifications', //channel name
          importance: Importance.max,
          priority: Priority.high,
        ),
      );

      await notificationsPlugin.show(
        id,
        'notificaion title',
        'notificaion body',
        notificationDetails,
        payload: "",
      );
    } catch (e) {
      print(e);
    }
  }

  static void display(RemoteMessage message) async {
    try {
      /// ensure each id is unique by using datetime
      final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;

      AndroidNotificationDetails _androidNotificationDetails =
          AndroidNotificationDetails(
              'high_importance_channel', 'High Importance Notifications',
              playSound: true,
              priority: Priority.high,
              importance: Importance.max,);

      IOSNotificationDetails _iosNotificationDetails = IOSNotificationDetails(
//         presentAlert: bool?,  // Present an alert when the notification is displayed and the application is in the foreground (only from iOS 10 onwards)
//         presentBadge: bool?,  // Present the badge number when the notification is displayed and the application is in the foreground (only from iOS 10 onwards)
//         presentSound: bool?,  // Play a sound when the notification is displayed and the application is in the foreground (only from iOS 10 onwards)
//         sound: String?,  // Specifics the file path to play (only from iOS 10 onwards)
//         badgeNumber: int?, // The application's icon badge number
//         attachments: List<IOSNotificationAttachment>?, (only from iOS 10 onwards)
//         subtitle: String?, //Secondary description  (only from iOS 10 onwards)
//         threadIdentifier: String? (only from iOS 10 onwards)
          );
      NotificationDetails platformChannelSpecifics = NotificationDetails(
          android: _androidNotificationDetails, iOS: _iosNotificationDetails);

      await notificationsPlugin.show(
          id,
          message.notification.title,
          message.notification.body,
          // notificationDetails,
          platformChannelSpecifics,
          payload: ""
          /// pass the route in
          // payload: message.data["route"],
          );
    } catch (e) {
      print(e);
    }
  }
}

Change Log

  • 20220328 - init
  • 20260501–translate by claude code