Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: Notification Launch Details Persist After Hot Restart in Debug Mode #2514

Open
SatyamKr07 opened this issue Jan 11, 2025 · 1 comment
Open

Comments

@SatyamKr07
Copy link

SatyamKr07 commented Jan 11, 2025

Description

When using getNotificationAppLaunchDetails() in debug mode, the launch details persist after hot restart, which can cause confusion during development. This appears to be specific to debug mode and hot restart scenarios.

Current Behavior (Debug Mode)

  1. App receives notification in foreground
  2. App is put in background
  3. User taps notification from Notification Center
  4. App opens and processes notification using getNotificationAppLaunchDetails()
  5. Developer performs a hot restart
  6. getNotificationAppLaunchDetails() still returns the previous notification details

Expected Behavior

Even in debug mode with hot restart, getNotificationAppLaunchDetails() should only return notification details when the app is launched directly from a notification tap. After a hot restart, it should return null or indicate that the app wasn't launched from a notification.

Steps to Reproduce (Debug Mode Only)

  1. Run app in debug mode with this notification service:

`
//code
import 'dart:convert';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:injectable/injectable.dart';

import '../my_logger/my_logger.dart';
import 'notification_navigation.dart';
import 'setup/firebase_notification_setup.dart';
import 'setup/local_notification_setup.dart';

@singleton
class NotificationService {
final LocalNotificationSetup _localNotificationSetup =
LocalNotificationSetup();
final FirebaseNotificationSetup _firebaseNotificationSetup =
FirebaseNotificationSetup();

/// Initializes both notification systems and sets up message handlers
///
/// Used when: App starts for the first time
/// - Initializes Flutter Local Notifications for foreground messages
/// - Initializes Firebase Messaging for background/terminated messages
/// - Sets up message handlers for different app states
Future initialize() async {
logger.i('NotificationService initialize: Starting initialization');
try {
// Initialize both notification systems
await _localNotificationSetup.init(
onNotificationTap: NotificationNavigation.handleNotificationTap,
);
await _firebaseNotificationSetup.init(
onNotificationTap: NotificationNavigation.handleNotificationTap,
);

  // Setup message handlers and check pending notifications
  await _setupMessageHandlers();

  logger.i('NotificationService initialize: Completed initialization');
} catch (e) {
  logger
      .e('NotificationService initialize: Error during initialization: $e');
  rethrow;
}

}

/// Sets up handlers for different message scenarios and checks pending notifications
///
/// Handles three scenarios:
/// 1. Foreground messages: Uses Flutter Local Notifications
/// - When: App is open and visible
/// - Action: Shows notification using local notification system
///
/// 2. Background messages: Uses Firebase Messaging
/// - When: App is in background
/// - Action: Firebase shows system notification automatically
///
/// 3. Terminated/Launch messages: Checks both systems
/// - When: App was launched from a notification
/// - Action: Processes pending notifications from both systems
Future _setupMessageHandlers() async {
// Handle foreground messages using Flutter Local Notifications
logger.d(
'NotificationService _setupMessageHandlers: Setting up message handlers');
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

// Check for any pending notifications that might have launched the app
await _checkPendingNotifications();

}

/// Handles messages when app is in foreground
///
/// Used when: App is open and visible to user
/// System: Uses Flutter Local Notifications
/// - Receives message from Firebase
/// - Shows notification using local notification system
/// - Allows custom UI and interaction handling
void _handleForegroundMessage(RemoteMessage message) {
logger.i(
'NotificationService _handleForegroundMessage: Handling foreground message=$message');
// Show local notification when app is in foreground
_localNotificationSetup.showNotification(message);
}

/// Gets the Firebase device token for push notifications
///
/// Used when: App needs to register device for push notifications
/// System: Uses Firebase Messaging
/// - Required for sending targeted notifications to specific devices
/// - Used during user registration or token refresh
Future<String?> getDeviceToken() async {
return await _firebaseNotificationSetup.getToken();
}

/// Subscribes to a specific notification topic
///
/// Used when: App needs to receive notifications for specific topics
/// System: Uses Firebase Messaging
/// - Allows receiving notifications without storing device tokens
/// - Used for broadcast messages to specific groups
Future subscribeToTopic({required String topic}) async {
logger.d(
'NotificationService subscribeToTopic: Subscribing to topic: $topic');

try {
  await FirebaseMessaging.instance.subscribeToTopic(topic);
  logger.i(
      'NotificationService subscribeToTopic: Successfully subscribed to events for institute with topic: $topic');
} catch (e) {
  logger.e(
      'NotificationService subscribeToTopic: Error subscribing to events: $e');
  // rethrow;
}

}

/// Checks for any pending notification requests that might have launched the app
///
/// Used when: App is launched from a terminated state via notification
/// Systems: Checks both Firebase and Flutter Local Notifications
/// - Handles notifications that were shown while app was in foreground
/// - Handles notifications received from Firebase
Future _checkPendingNotifications() async {
logger.d(
'NotificationService _checkPendingNotifications: Checking pending notification requests');

// Check Firebase initial message first
RemoteMessage? firebaseInitial =
    await FirebaseMessaging.instance.getInitialMessage();
if (firebaseInitial != null) {
  logger.i(
      'NotificationService _checkPendingNotifications: Found Firebase initial message: $firebaseInitial');
  NotificationNavigation.handleNotificationTap(firebaseInitial);
  return;
}

// Check Flutter Local Notifications pending requests
final NotificationAppLaunchDetails? launchDetails =
    await _localNotificationSetup.getNotificationAppLaunchDetails();

if (launchDetails != null &&
    launchDetails.didNotificationLaunchApp &&
    launchDetails.notificationResponse?.payload != null) {
  logger.i(
      'NotificationService._checkPendingNotifications: Terminated App launched from local notification');

  try {
    final String payload = launchDetails.notificationResponse!.payload!;
    final Map<String, dynamic> data = jsonDecode(payload);
    final RemoteMessage message = RemoteMessage(data: data);

    logger.i(
        'NotificationService._checkPendingNotifications: Handling local notification. message=$message');
    NotificationNavigation.handleNotificationTap(message);
  } catch (e) {
    logger.e(
        'NotificationService._checkPendingNotifications: Error processing notification: $e');
  }
}

// Always clear notifications at the end of checking
await _localNotificationSetup.clearNotificationDetails();
logger.d(
    'NotificationService._checkPendingNotifications: Cleared all notification data');

}
}

`

`
import 'dart:convert';
import 'dart:io';

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

import '../../my_logger/my_logger.dart';
import 'notification_setup_base.dart';

class LocalNotificationSetup extends NotificationSetupBase {
static final LocalNotificationSetup _instance =
LocalNotificationSetup._internal();
factory LocalNotificationSetup() => _instance;
LocalNotificationSetup._internal();

final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();

@OverRide
Future init({Function(RemoteMessage)? onNotificationTap}) async {
logger.d(
'LocalNotificationSetup initialize: Initializing local notifications');

const AndroidInitializationSettings initializationSettingsAndroid =
    AndroidInitializationSettings('@mipmap/launcher_icon');
const DarwinInitializationSettings initializationSettingsIOS =
    DarwinInitializationSettings(
  requestAlertPermission: true,
  requestBadgePermission: true,
  requestSoundPermission: true,
);
const InitializationSettings initializationSettings =
    InitializationSettings(
  android: initializationSettingsAndroid,
  iOS: initializationSettingsIOS,
);

await _flutterLocalNotificationsPlugin.initialize(
  initializationSettings,
  onDidReceiveNotificationResponse: (response) =>
      _handleNotificationResponse(response, onNotificationTap),
  // onDidReceiveBackgroundNotificationResponse: (response) =>
  //     _handleBackgroundNotificationResponse(response, onNotificationTap),
);

}

void _handleNotificationResponse(
NotificationResponse response, Function(RemoteMessage)? onTap) {
if (response.payload != null) {
try {
final Map<String, dynamic> data = jsonDecode(response.payload!);
final RemoteMessage message = RemoteMessage(data: data);
onTap?.call(message);
} catch (e) {
logger
.e('LocalNotificationSetup _handleNotificationResponse: Error: $e');
}
}
}

@pragma('vm:entry-point')
void _handleBackgroundNotificationResponse(
NotificationResponse response, Function(RemoteMessage)? onTap) {
logger.d(
'LocalNotificationSetup _handleBackgroundNotificationResponse: Handling background notification response');
_handleNotificationResponse(response, onTap);
}

@OverRide
Future showNotification(RemoteMessage message) async {
RemoteNotification? notification = message.notification;
AndroidNotification? android = message.notification?.android;

if (notification != null) {
  logger.i(
      'LocalNotificationSetup showNotification: Showing notification. data: ${message.data}');
  await _flutterLocalNotificationsPlugin.show(
    notification.hashCode,
    notification.title,
    notification.body,
    NotificationDetails(
      android: AndroidNotificationDetails(
        'hiiCampus_channel_id',
        'hiiCampus_channel_name',
        channelDescription: 'Channel for hiiCampus notifications',
        importance: Importance.max,
        priority: Priority.high,
        ticker: 'ticker',
        fullScreenIntent: true,
        visibility: NotificationVisibility.public,
        channelShowBadge: true,
        icon: android?.smallIcon ?? '@mipmap/launcher_icon',
        styleInformation: BigTextStyleInformation(notification.body ?? ''),
      ),
      iOS: const DarwinNotificationDetails(
        presentAlert: true,
        presentBadge: true,
        presentSound: true,
      ),
    ),
    payload: jsonEncode(message.data),
  );
}

}

@OverRide
Future<String?> getToken() async =>
null; // Not applicable for local notifications

Future<NotificationAppLaunchDetails?>
getNotificationAppLaunchDetails() async {
return await _flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
}

/// Clears the notification launch details to prevent reprocessing
Future clearNotificationDetails() async {
logger.d(
'LocalNotificationSetup clearNotificationDetails: Starting to clear notifications');

try {
  // Cancel all notifications
  await _flutterLocalNotificationsPlugin.cancelAll();

  // On Android, we need to remove the notification that launched the app
  if (Platform.isAndroid) {
    final details = await _flutterLocalNotificationsPlugin
        .getNotificationAppLaunchDetails();
    if (details?.notificationResponse?.id != null) {
      await _flutterLocalNotificationsPlugin
          .cancel(details!.notificationResponse!.id!);
    }
  }

  logger.i(
      'LocalNotificationSetup clearNotificationDetails: Successfully cleared notifications');

  // Verify clearing
  final verifyDetails = await _flutterLocalNotificationsPlugin
      .getNotificationAppLaunchDetails();
  logger.d(
      'LocalNotificationSetup clearNotificationDetails: Verification - Launch Details still present: ${verifyDetails?.didNotificationLaunchApp}');
} catch (e) {
  logger.e(
      'LocalNotificationSetup clearNotificationDetails: Error clearing notifications: $e');
}

}
}

`

  1. Send a test notification while app is in foreground
  2. Background the app
  3. Tap the notification to open the app
  4. Verify notification details are processed
  5. Perform a hot restart
  6. Check getNotificationAppLaunchDetails() - it still returns the previous notification details

Important Notes

  • This issue only occurs in debug mode
  • The behavior is specifically related to hot restart
  • In production builds, this behavior should not occur as we can't do hot-restart
  • This can cause confusion during development and testing

Environment

  • flutter_local_notifications version: ^18.0.1
  • Flutter version: 3.27.1
  • Platform: Checked in Android
  • Debug Mode: Yes
  • Reproduction Method: Hot Restart

Additional Context

We've attempted to clear the notification details using:

  • cancelAll()
  • cancel

However, the launch details still persist across hot restarts in debug mode.

Impact

While this shouldn't affect production builds, it makes development and testing of notification-related features more difficult as developers need to fully terminate the app to get accurate notification launch behavior.

Possible Solutions

  1. Clear launch details after hot restart in debug mode
  2. Add a development-time flag to force clear launch details
  3. Add documentation noting this behavior in debug mode
  4. Add a method to explicitly clear launch details that works in debug mode
@Levi-Lesches
Copy link
Contributor

Levi-Lesches commented Jan 11, 2025

  1. Clear launch details after hot restart in debug mode

Seems like there is no hot-restart hook we can listen to. Even Finalizers don't necessarily respond. See flutter/flutter#75528

  1. Add a method to explicitly clear launch details that works in debug mode

This could be good. @MaikuB, would you like a PR for this? Something like plugin.clearLaunchDetails()? I think this is worth fixing: imagine a dev is trying to test a reply or archive button in their notifications. then, hot-restarting would cause their app to try to execute the same logic again.

// Verify clearing

Unrelated, but I'm not sure you need to do this in real applications, though I'd be curious to hear when you would.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants