flutterdartcronfirebase-cloud-messagingappwrite

Duplicate Notifications with Appwrite and FCM on Flutter


Description

I'm using an Appwrite function triggered by a cron job to schedule daily notifications via FCM. While notifications are sent at the correct time, I'm occasionally receiving duplicate notifications on the same device.

What I've Checked

Example Screenshot

screenshot

Code

Scheduler Appwrite Function

Future<ResponseModel> setRemindersForUser(
    context, User user, Messaging messaging) async {
  context.log('processing user ${user.name}');
  // Retrieve user's timezone offset
  final timezoneOffset = user.prefs.data['timezone'];

  if (timezoneOffset != null) {
    context.log('retrieved timezone offset: $timezoneOffset');
    // Parse timezone offset
    context.log('Original timezone offset: $timezoneOffset');
    final isOffsetNegative = timezoneOffset.startsWith('-');
    final pureOffsetDuration =
        timezoneOffset.replaceAll('-', '').split('.').first;
    context.log('Pure offset duration: $pureOffsetDuration');
    final offsetHour = int.parse(pureOffsetDuration.split(':')[0]);
    final offsetMin = int.parse(pureOffsetDuration.split(':')[1]);
    final offsetSec = int.parse(pureOffsetDuration.split(':')[2]);
    final offsetDuration = Duration(
      hours: offsetHour,
      minutes: offsetMin,
      seconds: offsetSec,
    );
    context.log('Offset duration: $offsetDuration');

    // Calculate next 9 PM in user's local time
    final now = DateTime.now().toUtc();
    context.log('Current UTC time: $now');
    final userTime = isOffsetNegative
        ? now.subtract(offsetDuration)
        : now.add(offsetDuration);
    context.log('Current user time: $userTime');
    DateTime next9PM =
        DateTime(userTime.year, userTime.month, userTime.day, 21);
    if (userTime.isAfter(next9PM)) {
      next9PM = next9PM.add(Duration(days: 1));
    }
    context.log('Next 9 PM user time: $next9PM');

    // Convert next9PM to UTC
    final next9PMUtc = isOffsetNegative
        ? next9PM.add(offsetDuration)
        : next9PM.subtract(offsetDuration);

    final messageId = _generateMessageId(user, next9PMUtc);
    late Function(
        {required String messageId,
        required String title,
        required String body,
        List<String>? topics,
        List<String>? users,
        List<String>? targets,
        Map? data,
        String? action,
        String? image,
        String? icon,
        String? sound,
        String? color,
        String? tag,
        bool? draft,
        String? scheduledAt}) scheduler;
    try {
      context.log('check existing message');
      // update if message already scheduled
      await messaging.getMessage(messageId: messageId);
      // no error thrown means message exists
      scheduler = messaging.updatePush;
      context.log('found existing message');
    } catch (e) {
      // create if message not scheduled
      context.log('no existing message found');
      scheduler = messaging.createPush;
      context.log('should create new message');
      context.log(e.toString());
    }
    // Schedule push notification
    context.log('scheduling push notification');
    final userPushTargets = user.targets
        .where((element) => element.providerType == "push")
        .toList();
    context.log('user push targets: ${jsonEncode(userPushTargets.map(
          (e) => e.toMap(),
        ).toList())}');
    try {
      final content = dynamicNotifications.random;
      final result = await scheduler(
        messageId: messageId,
        title: content.$1,
        body: content.$2,
        scheduledAt: next9PMUtc.toIso8601String(),
        targets: userPushTargets
            .map(
              (e) => e.$id,
            )
            .toList(),
      );
      context
          .log('scheduled push notification!: $messageId - ${result.toMap()}');
      return ResponseModel.success(null);
    } catch (e) {
      context.log(e.toString());
      return ResponseModel.failed(message: e.toString());
    }
  }
  return ResponseModel.failed(message: 'Timezone offset not found');
}

Flutter App Notification Handler Code

static Future<bool> initialize() async {
  try {
    final result = await FirebaseMessaging.instance.requestPermission();
    if (Platform.isAndroid) {
      await localNotifications
          .resolvePlatformSpecificImplementation<
              AndroidFlutterLocalNotificationsPlugin>()
          ?.createNotificationChannel(reminderChannel);
    }
    await localNotifications.initialize(
      const InitializationSettings(
        android: AndroidInitializationSettings('@mipmap/ic_launcher'),
        iOS: DarwinInitializationSettings(),
      ),
    );
    _addOnReceiveMessageListener();
    _addTokenRefreshListener();
    return result.authorizationStatus == AuthorizationStatus.authorized;
  } catch (e) {
    log(e.toString());
  }
  return false;
}

static void _addOnReceiveMessageListener() {
  FirebaseMessaging.onMessage.listen((message) {
    if (message.notification != null) {
      _showLocalNotification(
          title: message.notification!.title ?? '',
          message: message.notification!.body ?? '',
          data: jsonEncode(message.data));
    }
  });
}

static void _showLocalNotification(
    {required String title, required String message, String? data}) {
  FlutterLocalNotificationsPlugin()
      .show(generateTimeBasedId(), title, message,
          _details,
          payload: data)
      .then((_) {});
}

static void _addTokenRefreshListener() {
   FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
      // updating fcm token on appwrite
   });
}

I'm looking for insights or suggestions on what might be causing these duplicate notifications.


Solution

  • When App is in the background and Notification is sent the the firebase is automatically handling it. and when you again call the show notification method two notifications are displayed. to handle this situation you can either conditionally call the show notification method you can do some changes in the push(while creating notification) in this case don't send title and body directly instead send it in the data by doing so you're sending a "data" message from Firebase Messaging instead of sending a "notification" message. checkout the difference https://firebase.google.com/docs/cloud-messaging/concept-options#notifications_and_data_messages

    schedule notification like this:

     final result = await scheduler(
            messageId: messageId,
            //remove these
           // title: content.$1, body: content.$2, 
            data: {
               "title": content.$1, 
               "body": content.$2, 
            },
            scheduledAt: next9PMUtc.toIso8601String(),
            targets: userPushTargets
                .map(
                  (e) => e.$id,
                )
                .toList(),
          );
    

    and when displaying the notification get the title and the body from the data field.

    Difference between notification message and data message. Different types of messages