From 0075072196681016aa8e54cf39d5db2b7e6db7dd Mon Sep 17 00:00:00 2001 From: rzuasti Date: Tue, 9 Jun 2026 13:56:13 -0400 Subject: [PATCH] Re-register push token at launch; refactor push_service After a cold restart the app considered push enabled (from prefs) but never re-registered its FCM token, so a rotated token (or a backend that lost its token store) left the backend delivering to nothing while the test button still reported success. Re-register the current token at launch so the backend converges to the live token on every start, and re-attach the foreground display handler then too (it was previously only wired during enable()). Also tidy push_service.dart: a single initPushOnLaunch() startup entry point so main.dart needs no push internals, one owner for the persisted push-enabled flag (pushEnabledOnThisDevice / setPushEnabledOnThisDevice) instead of a hard-coded key in four places, private internal helpers, deduped platform checks, and section grouping. enable() now reuses the shared token-registration path. Co-Authored-By: Claude Opus 4.8 --- frontend/lib/main.dart | 9 +- frontend/lib/settings/settings.dart | 6 +- frontend/lib/utils/push_service.dart | 283 ++++++++++++++++----------- 3 files changed, 172 insertions(+), 126 deletions(-) diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 387e5aa..ed839c9 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -32,13 +32,12 @@ void main() { ); } - // Initialize Firebase at launch so the firebase_messaging plugin's iOS - // APNs swizzling has a configured app to forward the device token to; - // without this getAPNSToken() never resolves and enabling push fails. + // Resume push at launch (Firebase init + foreground handler + token + // re-registration when already enabled); see initPushOnLaunch. try { - await initFirebaseForPush(); + await initPushOnLaunch(); } catch (e, stack) { - debugPrint('Firebase init for push failed, continuing: $e\n$stack'); + debugPrint('Push launch init failed, continuing: $e\n$stack'); } // Settle iOS's local-network permission now, at launch, so it isn't diff --git a/frontend/lib/settings/settings.dart b/frontend/lib/settings/settings.dart index 70dedb5..991bc5e 100644 --- a/frontend/lib/settings/settings.dart +++ b/frontend/lib/settings/settings.dart @@ -42,7 +42,7 @@ class _SettingsState extends State { _isFirstRun = _baseUrl.isEmpty; _selectedTheme = context.read().themeKey; _pushService = widget.pushService ?? FirebasePushService(); - _pushEnabled = PrefUtil.getValue('push_enabled', false) as bool; + _pushEnabled = pushEnabledOnThisDevice; _loadConfig(); // First run / unconfigured: open the connection dialog immediately and keep // it open (non-dismissible) until the user saves a working configuration. @@ -96,7 +96,7 @@ class _SettingsState extends State { final enabled = await _pushService.enable(); if (!mounted) return; if (enabled) { - await PrefUtil.setValue('push_enabled', true); + await setPushEnabledOnThisDevice(enabled: true); if (!mounted) return; setState(() => _pushEnabled = true); UISnackbars.showSuccess( @@ -120,7 +120,7 @@ class _SettingsState extends State { } else { await _pushService.disable(); if (!mounted) return; - await PrefUtil.setValue('push_enabled', false); + await setPushEnabledOnThisDevice(enabled: false); if (!mounted) return; setState(() => _pushEnabled = false); UISnackbars.showSuccess( diff --git a/frontend/lib/utils/push_service.dart b/frontend/lib/utils/push_service.dart index 0992814..4624aab 100644 --- a/frontend/lib/utils/push_service.dart +++ b/frontend/lib/utils/push_service.dart @@ -6,15 +6,35 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../firebase_options.dart'; import 'oott_api.dart'; +import 'pref_utils.dart'; -/// Whether push is available on this platform/build (mobile only — FCM/APNs). -/// Shared by [FirebasePushService.isSupported] and [initFirebaseForPush] so the -/// two never drift. -bool get pushSupportedOnThisPlatform => +// Whether push is available on this platform/build (mobile only — FCM/APNs). +bool get _pushSupported => !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); +bool get _isIOS => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + +String get _platformName => _isIOS ? 'ios' : 'android'; + +// -------------------------------------------------------------------------- +// Persisted per-device intent +// -------------------------------------------------------------------------- + +/// Whether the user has turned push on for this device (persisted intent). Owned +/// here so the storage key lives in one place; the delivery wiring keys off this. +bool get pushEnabledOnThisDevice => + PrefUtil.getValue('push_enabled', false) as bool; + +/// Persists the per-device push intent. See [pushEnabledOnThisDevice]. +Future setPushEnabledOnThisDevice({required bool enabled}) => + PrefUtil.setValue('push_enabled', enabled); + +// -------------------------------------------------------------------------- +// iOS APNs diagnostics +// -------------------------------------------------------------------------- + // Channel exposing the native iOS APNs registration outcome (see AppDelegate). const MethodChannel _pushDiagnosticsChannel = MethodChannel( 'oott/push_diagnostics', @@ -25,7 +45,7 @@ const MethodChannel _pushDiagnosticsChannel = MethodChannel( /// status string on iOS, or null elsewhere or when the channel is unavailable /// (e.g. in tests). Future apnsRegistrationStatus() async { - if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return null; + if (!_isIOS) return null; try { return await _pushDiagnosticsChannel.invokeMethod('apnsStatus'); } catch (_) { @@ -33,17 +53,139 @@ Future apnsRegistrationStatus() async { } } -/// Initializes Firebase at app startup on push-capable platforms. This must run -/// at launch — the firebase_messaging plugin wires up iOS APNs swizzling in the -/// AppDelegate at launch, and it can only forward the APNs device token to FCM -/// if a FirebaseApp is already configured when iOS delivers it. Without this, -/// `getAPNSToken()` never resolves and enabling push fails. No-op on web/desktop -/// and if Firebase is already initialized. -Future initFirebaseForPush() async { - if (!pushSupportedOnThisPlatform || Firebase.apps.isNotEmpty) return; +// -------------------------------------------------------------------------- +// App-launch wiring +// -------------------------------------------------------------------------- + +/// Resumes push on app launch. Always configures Firebase (required at launch so +/// the firebase_messaging plugin can forward the iOS APNs token to FCM), then, +/// when push is already enabled on this device, re-attaches the foreground +/// display handler and re-registers the current token so the backend converges to +/// the live token even if it rotated or the backend lost its token store. Single +/// startup entry point, so callers need no knowledge of push internals. +Future initPushOnLaunch() async { + await _initFirebase(); + if (!pushEnabledOnThisDevice) return; + await _ensureForegroundDisplay(); + await _registerCurrentToken(); +} + +// Initializes Firebase on push-capable platforms. No-op on web/desktop and if +// Firebase is already initialized. Options come from the committed +// firebase_options.dart, so no google-services.json / GoogleService-Info.plist +// is needed in the build. +Future _initFirebase() async { + if (!_pushSupported || Firebase.apps.isNotEmpty) return; await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); } +// -------------------------------------------------------------------------- +// Foreground display +// -------------------------------------------------------------------------- + +// Android channel used to surface a heads-up notification while the app is in the +// foreground (the OS shows backgrounded/terminated notifications itself). +const AndroidNotificationChannel _androidChannel = AndroidNotificationChannel( + 'oott_alerts', + 'OOTT alerts', + description: 'New device, device back online and device changed alerts.', + importance: Importance.high, +); + +final FlutterLocalNotificationsPlugin _localNotifications = + FlutterLocalNotificationsPlugin(); + +// Process-global so the foreground handler is wired at most once, whether the +// request comes from startup (push already enabled) or from enable() on toggle. +bool _foregroundDisplayWired = false; + +// Renders push notifications that arrive while the app is in the foreground (the +// OS displays backgrounded/terminated ones itself); taps just open the app, so no +// tap handler is wired. Idempotent; a no-op on platforms without push. +Future _ensureForegroundDisplay() async { + if (!_pushSupported || _foregroundDisplayWired) return; + _foregroundDisplayWired = true; + + await _localNotifications.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings(), + ), + ); + await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(_androidChannel); + + FirebaseMessaging.onMessage.listen((message) { + final notification = message.notification; + if (notification == null) return; + _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + NotificationDetails( + android: AndroidNotificationDetails( + _androidChannel.id, + _androidChannel.name, + channelDescription: _androidChannel.description, + importance: Importance.high, + priority: Priority.high, + ), + iOS: const DarwinNotificationDetails(), + ), + ); + }); +} + +// -------------------------------------------------------------------------- +// Token registration +// -------------------------------------------------------------------------- + +// Process-global so the FCM token-rotation listener is attached at most once, +// whether registration is driven from startup or from enable(). +bool _tokenRefreshWired = false; + +// Fetches this device's current FCM token, (re)registers it with the backend, and +// keeps it in sync when FCM rotates it. Assumes notification permission is already +// granted; returns false when no token is available (e.g. the iOS APNs token +// never arrived) or on platforms without push. +Future _registerCurrentToken() async { + if (!_pushSupported) return false; + await _initFirebase(); + + // iOS: getToken() throws until Apple delivers the APNs token, so wait for it. + if (_isIOS && !await _awaitApnsToken()) return false; + + final token = await FirebaseMessaging.instance.getToken(); + if (token == null) return false; + await BackendAPI.instance.registerPushToken(token, _platformName); + + if (!_tokenRefreshWired) { + _tokenRefreshWired = true; + FirebaseMessaging.instance.onTokenRefresh.listen((refreshed) { + BackendAPI.instance.registerPushToken(refreshed, _platformName); + }); + } + return true; +} + +// Polls for the iOS APNs token, which Apple delivers asynchronously after the +// user grants permission. Returns true once available, or false after a short +// bounded wait (e.g. no network on first run). +Future _awaitApnsToken() async { + for (var attempt = 0; attempt < 10; attempt++) { + if (await FirebaseMessaging.instance.getAPNSToken() != null) return true; + await Future.delayed(const Duration(milliseconds: 500)); + } + return false; +} + +// -------------------------------------------------------------------------- +// Service +// -------------------------------------------------------------------------- + /// Per-device push enable/disable, behind an interface so the settings UI can be /// driven by a fake in tests without pulling in Firebase. Tapping a push only /// opens the app (no deep-link, no identifier); the in-app notification list @@ -65,131 +207,36 @@ abstract class PushService { /// FCM-backed [PushService]. Kept separate from the UI so its Firebase /// dependencies never reach widget tests, which use a fake [PushService]. class FirebasePushService implements PushService { - FirebasePushService({FlutterLocalNotificationsPlugin? localNotifications}) - : _localNotifications = - localNotifications ?? FlutterLocalNotificationsPlugin(); - - final FlutterLocalNotificationsPlugin _localNotifications; - - // Android channel used to surface a heads-up notification while the app is in - // the foreground (the OS shows backgrounded/terminated notifications itself). - static const AndroidNotificationChannel _androidChannel = - AndroidNotificationChannel( - 'oott_alerts', - 'OOTT alerts', - description: - 'New device, device back online and device changed alerts.', - importance: Importance.high, - ); - - bool _foregroundDisplayWired = false; - @override - bool get isSupported => pushSupportedOnThisPlatform; - - String get _platformName => - defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android'; - - // Safety net in case startup init was skipped; normally Firebase is already - // initialized at launch by initFirebaseForPush(). Options come from the - // committed firebase_options.dart rather than native config files, so no - // google-services.json / GoogleService-Info.plist is needed in the build. - Future _ensureFirebase() => initFirebaseForPush(); + bool get isSupported => _pushSupported; @override Future enable() async { - if (!isSupported) return false; - await _ensureFirebase(); + if (!_pushSupported) return false; + // Firebase must be configured before requesting permission on iOS. + await _initFirebase(); final settings = await FirebaseMessaging.instance.requestPermission(); if (settings.authorizationStatus == AuthorizationStatus.denied) { return false; } - // On iOS, FCM can only mint a token once Apple has delivered the APNs token - // to the app, which happens asynchronously after permission is granted. - // Calling getToken() before then throws `apns-token-not-set`, so wait for - // the APNs token first. Returns false (rather than throwing) if it never - // arrives, so the toggle simply stays off instead of erroring. - if (defaultTargetPlatform == TargetPlatform.iOS && - !await _awaitApnsToken()) { - return false; - } + // Obtain and register the token (waits for the iOS APNs token internally); + // false means no token could be minted, so the toggle stays off. + if (!await _registerCurrentToken()) return false; - final token = await FirebaseMessaging.instance.getToken(); - if (token == null) return false; - - await BackendAPI.instance.registerPushToken(token, _platformName); - - // Re-register whenever FCM rotates the token so the backend never holds a - // stale one. - FirebaseMessaging.instance.onTokenRefresh.listen((refreshed) { - BackendAPI.instance.registerPushToken(refreshed, _platformName); - }); - - await _wireForegroundDisplay(); + await _ensureForegroundDisplay(); return true; } @override Future disable() async { - if (!isSupported) return; - await _ensureFirebase(); + if (!_pushSupported) return; + await _initFirebase(); final token = await FirebaseMessaging.instance.getToken(); if (token != null) { await BackendAPI.instance.unregisterPushToken(token); } await FirebaseMessaging.instance.deleteToken(); } - - // Polls for the iOS APNs token, which Apple delivers asynchronously after the - // user grants permission. Returns true once it is available, or false if it - // has not arrived after a short bounded wait (e.g. no network on first run). - Future _awaitApnsToken() async { - for (var attempt = 0; attempt < 10; attempt++) { - if (await FirebaseMessaging.instance.getAPNSToken() != null) return true; - await Future.delayed(const Duration(milliseconds: 500)); - } - return false; - } - - // Configure the local-notifications plugin and render foreground messages - // ourselves (the OS displays them directly when the app is backgrounded or - // terminated). Taps just open the app, so no tap handler is wired. - Future _wireForegroundDisplay() async { - if (_foregroundDisplayWired) return; - _foregroundDisplayWired = true; - - await _localNotifications.initialize( - const InitializationSettings( - android: AndroidInitializationSettings('@mipmap/ic_launcher'), - iOS: DarwinInitializationSettings(), - ), - ); - await _localNotifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.createNotificationChannel(_androidChannel); - - FirebaseMessaging.onMessage.listen((message) { - final notification = message.notification; - if (notification == null) return; - _localNotifications.show( - notification.hashCode, - notification.title, - notification.body, - NotificationDetails( - android: AndroidNotificationDetails( - _androidChannel.id, - _androidChannel.name, - channelDescription: _androidChannel.description, - importance: Importance.high, - priority: Priority.high, - ), - iOS: const DarwinNotificationDetails(), - ), - ); - }); - } }