How can I update the UI widget when Hive data get update inside onBackgroundMessage handler of FireBaseMessage in flutter?

I'm working on an app that receives notifications from firebase cloud messaging. I save the message in Hive upon receipt. I have a notification screen that displays the notification read from hive which updates immediately when notification is received. This has been working well.

The problem now is, notification received when the app is running in background (not kill/terminate) is saved in hive but screen is not updated when navigated to notification screen (the update in the hive is not seen) until the app is terminated and reran.

I read this is because onBackgroundMessage handler runs on different isolate and isolate cannot share storage. It seems like I need a way to pass the Hive notification update to the main isolate from the onBackgroundMessage handler.

This is my implementation so far

push_message.dart the notification class whose instance is save in hive

import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:app_name/helpers/helpers.dart';
part 'push_message.g.dart';

@HiveType(typeId: 1)
class PushMessage extends HiveObject {
  @HiveField(0)
  int id = int.parse(generateRandomNumber(7));
  @HiveField(1)
  String? messageId;
  @HiveField(2)
  String title;
  @HiveField(3)
  String body;
  @HiveField(4)
  String? bigPicture;
  @HiveField(5)
  DateTime? sentAt;
  @HiveField(6)
  DateTime? receivedAt;
  @HiveField(7)
  String? payload;
  @HiveField(8, defaultValue: '')
  String channelId = 'channel_id';
  @HiveField(9, defaultValue: 'channel name')
  String channelName = 'channel name';

  @HiveField(10, defaultValue: 'App notification')
  String channelDescription = 'App notification';

  PushMessage({
    this.id = 0,
    this.messageId,
    required this.title,
    required this.body,
    this.payload,
    this.channelDescription = 'App notification',
    this.channelName = 'channel name',
    this.channelId = 'channel_id',
    this.bigPicture,
    this.sentAt,
    this.receivedAt,
  });

  Future<void> display() async {
    AndroidNotificationDetails androidPlatformChannelSpecifics =
        AndroidNotificationDetails(
      channelId,
      channelName,
      channelDescription: channelDescription,
      importance: Importance.max,
      priority: Priority.high,
      ticker: 'ticker',
      icon: "app_logo",
      largeIcon: DrawableResourceAndroidBitmap('app_logo'),
    );
    IOSNotificationDetails iOS = IOSNotificationDetails(
      presentAlert: true,
    );
    NotificationDetails platformChannelSpecifics =
        NotificationDetails(android: androidPlatformChannelSpecifics, iOS: iOS);
    final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
        FlutterLocalNotificationsPlugin();
    await flutterLocalNotificationsPlugin
        .show(id, title, body, platformChannelSpecifics, payload: payload);
  }

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'messageId': messageId,
      'title': title,
      'body': body,
      'bigPicture': bigPicture,
      'sentAt': sentAt?.millisecondsSinceEpoch,
      'receivedAt': receivedAt?.millisecondsSinceEpoch,
      'payload': payload,
    };
  }

  factory PushMessage.fromMap(map) {
    return PushMessage(
      id: map.hashCode,
      messageId: map['messageId'],
      title: map['title'],
      body: map['body'],
      payload: map['payload'],
      bigPicture: map['bigPicture'],
      sentAt: map['sentAt'] is DateTime
          ? map['sentAt']
          : (map['sentAt'] is int
              ? DateTime.fromMillisecondsSinceEpoch(map['sentAt'])
              : null),
      receivedAt: map['receivedAt'] is DateTime
          ? map['receivedAt']
          : (map['receivedAt'] is int
              ? DateTime.fromMillisecondsSinceEpoch(map['receivedAt'])
              : null),
    );
  }

  factory PushMessage.fromFCM(RemoteMessage event) {
    RemoteNotification? notification = event.notification;
    Map<String, dynamic> data = event.data;
    var noti = PushMessage(
      id: event.hashCode,
      messageId: event.messageId!,
      title: notification?.title ?? (data['title'] ?? 'No title found'),
      body: notification?.body! ?? (data['body'] ?? 'Can not find content'),
      sentAt: event.sentTime,
      receivedAt: DateTime.now(),
      bigPicture: event.notification?.android?.imageUrl,
    );
    return noti;
  }

  Future<void> saveToHive() async {
    if (!Hive.isBoxOpen('notifications')) {
      await Hive.openBox<PushMessage>('notifications');
    }
    await Hive.box<PushMessage>('notifications').add(this);
  }

  String toJson() => json.encode(toMap());

  factory PushMessage.fromJson(String source) =>
      PushMessage.fromMap(json.decode(source));

  Future<void> sendToOne(String receiverToken) async {
    try {
      await Dio().post(
        "https://fcm.googleapis.com/fcm/send",
        data: {
          "to": receiverToken,         
          "data": {
            "url": bigPicture,
            "title": title,
            "body": body,
            "mutable_content": true,
            "sound": "Tri-tone"            
          }
        },
        options: Options(
          contentType: 'application/json; charset=UTF-8',
          headers: {
            "Authorization":
                "Bearer xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
          },
        ),
      );
    } catch (e) {
      debugPrint("Error sending notification");
      debugPrint(e.toString());
    }
  }
}

notifications.dart notification screen

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:app_name/custom_widgets/drawer_sheet.dart';
import 'package:app_name/custom_widgets/notification_expandable.dart';
import 'package:app_name/models/config.dart';
import 'package:app_name/models/push_message.dart';

class Notifications extends StatelessWidget {
  const Notifications({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (!Hive.isBoxOpen('notifications')) {
      Hive.openBox<PushMessage>('notifications');
    }
    return Scaffold(
      appBar: AppBar(
        title: const Text("Notifications"),
        centerTitle: true,
      ),
      body: ValueListenableBuilder<Box<PushMessage>>(
        valueListenable: Hive.box<PushMessage>('notifications').listenable(),
        builder: (context, Box<PushMessage> box, widget) {
          return box.isEmpty
              ? const Center(child: Text('Empty'))
              : ListView.builder(
                  itemCount: box.length,
                  itemBuilder: (BuildContext context, int i) {
                    int reversedIndex = box.length - i - 1;
                    return NotificationExpandable((box.getAt(reversedIndex))!);
                  },
                );
        },
      ),
      drawer: !useDrawer
          ? null
          : const DrawerSheet(
              currentPage: "notifications",
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Hive.box<PushMessage>('notifications').clear();
        },
        child: const Text('Clear'),
      ),
    );
  }
}

backgroundMessageHandler

Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  if (!Hive.isAdapterRegistered(1)) {
    Hive.registerAdapter(PushMessageAdapter());
  }
  await Hive.initFlutter();
  if (!Hive.isBoxOpen('notifications')) {
    await Hive.openBox<PushMessage>('notifications');
  }
  await Firebase.initializeApp();
  print('Handling a background message ${message.messageId}');
  PushMessage msg = PushMessage.fromFCM(message);
  await msg.saveToHive();
  msg.display();
  Hive.close();
}

Solution 1:

I've found the way around it.

It not possible to communicate from the onBackgroundMessage handler of firebase cloud messaging to the main isolate since the function runs on a different isolate and I don't know where it is triggered. FCM documentation also says that any UI impacting logic cannot be done in this handler but io process like storing the message in device storage is possible.

I save the background message in a hive box different from where I save foreground message. So, in the initstate of the notification screen, I first get messages from background box, append it to the foreground box the clear, then start a function that runs in an isolate which check form background message every 1 second. When the function gets a background message it sends it the main isolate as map where it is converted to instance of foreground message and append to foreground message box. The widget get update whenever the value of the foreground notification box changes since I use a ValueListenableBuilder that listens to hive box of foreground notification. The isolate is then terminated in the dispose method of the screen. Here is the code.

import 'dart:async';
import 'dart:isolate';

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:app_name/custom_widgets/drawer_sheet.dart';
import 'package:app_name/custom_widgets/notification_expandable.dart';
import 'package:app_name/models/background_push_message.dart';
import 'package:app_name/models/config.dart';
import 'package:app_name/models/push_message.dart';

class Notifications extends StatefulWidget {
  const Notifications({Key? key}) : super(key: key);

  @override
  State<Notifications> createState() => _NotificationsState();
}

class _NotificationsState extends State<Notifications> {
  bool loading = true;
  late Box<PushMessage> notifications;

  @override
  void dispose() {
    stopRunningIsolate();
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    startIsolate().then((value) {
      setState(() {
        loading = false;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Notifications"),
        centerTitle: true,
      ),
      body: ValueListenableBuilder<Box<PushMessage>>(
        valueListenable: Hive.box<PushMessage>('notifications').listenable(),
        builder: (context, Box<PushMessage> box, widget) {
          return box.isEmpty
              ? const Center(child: Text('Empty'))
              : ListView.builder(
                  itemCount: box.length,
                  itemBuilder: (BuildContext context, int i) {
                    int reversedIndex = box.length - i - 1;
                    return NotificationExpandable((box.getAt(reversedIndex))!);
                  },
                );
        },
      ),
      drawer: fromSchool
          ? null
          : const DrawerSheet(
              currentPage: "notifications",
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Hive.box<PushMessage>('notifications').clear();
        },
        child: const Text('Clear'),
      ),
    );
  }
}

Isolate? isolate;

Future<bool> startIsolate() async {
  if (!Hive.isAdapterRegistered(2)) {
    Hive.registerAdapter(BackgroundPushMessageAdapter());
  }
  Hive.initFlutter();
  if (!Hive.isBoxOpen('notifications')) {
    await Hive.openBox<PushMessage>('notifications');
  }
  if (Hive.isBoxOpen('background_notifications')) {
    await Hive.box<BackgroundPushMessage>('background_notifications').close();
  }
  await Hive.openBox<BackgroundPushMessage>('background_notifications');
  Iterable<BackgroundPushMessage> bgNotifications =
      Hive.box<BackgroundPushMessage>('background_notifications').values;
  List<PushMessage> bgMsgs =
      bgNotifications.map((e) => PushMessage.fromMap(e.toMap())).toList();

  Hive.box<PushMessage>('notifications').addAll(bgMsgs);
  await Hive.box<BackgroundPushMessage>('background_notifications').clear();
  await Hive.box<BackgroundPushMessage>('background_notifications').close();
  ReceivePort receivePort = ReceivePort();
  String path = (await getApplicationDocumentsDirectory()).path;
  isolate = await Isolate.spawn(
      backgroundNotificationCheck, [receivePort.sendPort, path]);
  receivePort.listen((msg) {
    List<Map> data = msg as List<Map>;
    List<PushMessage> notis =
        data.map((noti) => PushMessage.fromMap(noti)).toList();

    Hive.box<PushMessage>('notifications').addAll(notis);
  });
  return true;
}

void stopRunningIsolate() {
  isolate?.kill(priority: Isolate.immediate);
  isolate = null;
}

Future<void> backgroundNotificationCheck(List args) async {
  SendPort sendPort = args[0];
  String path = args[1];
  if (!Hive.isAdapterRegistered(1)) {
    Hive.registerAdapter(PushMessageAdapter());
  }
  Hive.init(path);
  Timer.periodic(const Duration(seconds: 1), (Timer t) async {
    if (!Hive.isAdapterRegistered(2)) {
      Hive.registerAdapter(BackgroundPushMessageAdapter());
    }
    if (Hive.isBoxOpen('background_notifications')) {
      await Hive.box<BackgroundPushMessage>('background_notifications').close();
    }
    await Hive.openBox<BackgroundPushMessage>('background_notifications');
    if (Hive.box<BackgroundPushMessage>('background_notifications')
        .isNotEmpty) {
      List<Map> notifications =
          Hive.box<BackgroundPushMessage>('background_notifications')
              .values
              .map((noti) => noti.toMap())
              .toList();
      sendPort.send(notifications);
      await Hive.box<BackgroundPushMessage>('background_notifications').clear();
    }
    await Hive.box<BackgroundPushMessage>('background_notifications').close();
  });
}

onBackgroundNotification handler

if (!Hive.isAdapterRegistered(2)) {
    Hive.registerAdapter(BackgroundPushMessageAdapter());
  }
  final documentDirectory = await getApplicationDocumentsDirectory();
  Hive.init(documentDirectory.path);
  if (Hive.isBoxOpen('background_notifications')) {
    await Hive.box<BackgroundPushMessage>('background_notifications').close();
  }
  await Hive.openBox<BackgroundPushMessage>('background_notifications');
  await Firebase.initializeApp();
  print('Handling a background message ${message.messageId}');
  BackgroundPushMessage msg = BackgroundPushMessage.fromFCM(message);
  msg.display();
  Hive.box<BackgroundPushMessage>('background_notifications').add(msg);
  print("Notification shown for $msg");
  print(
      "Notification length ${Hive.box<BackgroundPushMessage>('background_notifications').length}");
  if (Hive.isBoxOpen('background_notifications')) {
    Hive.box<BackgroundPushMessage>('background_notifications').close();
  }

Note, I have different class for both background notification object and foreground notification object as hive cannot register two instances of the same class in different box. Thus, I have to register adapter for each class. Though, I make one class extends the other to avoid duplicating code.