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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-09 13:56:13 -04:00
co-authored by Claude Opus 4.8
parent 817ec5872c
commit 0075072196
3 changed files with 172 additions and 126 deletions
+4 -5
View File
@@ -32,13 +32,12 @@ void main() {
); );
} }
// Initialize Firebase at launch so the firebase_messaging plugin's iOS // Resume push at launch (Firebase init + foreground handler + token
// APNs swizzling has a configured app to forward the device token to; // re-registration when already enabled); see initPushOnLaunch.
// without this getAPNSToken() never resolves and enabling push fails.
try { try {
await initFirebaseForPush(); await initPushOnLaunch();
} catch (e, stack) { } 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 // Settle iOS's local-network permission now, at launch, so it isn't
+3 -3
View File
@@ -42,7 +42,7 @@ class _SettingsState extends State<Settings> {
_isFirstRun = _baseUrl.isEmpty; _isFirstRun = _baseUrl.isEmpty;
_selectedTheme = context.read<AppState>().themeKey; _selectedTheme = context.read<AppState>().themeKey;
_pushService = widget.pushService ?? FirebasePushService(); _pushService = widget.pushService ?? FirebasePushService();
_pushEnabled = PrefUtil.getValue('push_enabled', false) as bool; _pushEnabled = pushEnabledOnThisDevice;
_loadConfig(); _loadConfig();
// First run / unconfigured: open the connection dialog immediately and keep // First run / unconfigured: open the connection dialog immediately and keep
// it open (non-dismissible) until the user saves a working configuration. // it open (non-dismissible) until the user saves a working configuration.
@@ -96,7 +96,7 @@ class _SettingsState extends State<Settings> {
final enabled = await _pushService.enable(); final enabled = await _pushService.enable();
if (!mounted) return; if (!mounted) return;
if (enabled) { if (enabled) {
await PrefUtil.setValue('push_enabled', true); await setPushEnabledOnThisDevice(enabled: true);
if (!mounted) return; if (!mounted) return;
setState(() => _pushEnabled = true); setState(() => _pushEnabled = true);
UISnackbars.showSuccess( UISnackbars.showSuccess(
@@ -120,7 +120,7 @@ class _SettingsState extends State<Settings> {
} else { } else {
await _pushService.disable(); await _pushService.disable();
if (!mounted) return; if (!mounted) return;
await PrefUtil.setValue('push_enabled', false); await setPushEnabledOnThisDevice(enabled: false);
if (!mounted) return; if (!mounted) return;
setState(() => _pushEnabled = false); setState(() => _pushEnabled = false);
UISnackbars.showSuccess( UISnackbars.showSuccess(
+165 -118
View File
@@ -6,15 +6,35 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../firebase_options.dart'; import '../firebase_options.dart';
import 'oott_api.dart'; import 'oott_api.dart';
import 'pref_utils.dart';
/// Whether push is available on this platform/build (mobile only — FCM/APNs). // Whether push is available on this platform/build (mobile only — FCM/APNs).
/// Shared by [FirebasePushService.isSupported] and [initFirebaseForPush] so the bool get _pushSupported =>
/// two never drift.
bool get pushSupportedOnThisPlatform =>
!kIsWeb && !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android || (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS); 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<void> setPushEnabledOnThisDevice({required bool enabled}) =>
PrefUtil.setValue('push_enabled', enabled);
// --------------------------------------------------------------------------
// iOS APNs diagnostics
// --------------------------------------------------------------------------
// Channel exposing the native iOS APNs registration outcome (see AppDelegate). // Channel exposing the native iOS APNs registration outcome (see AppDelegate).
const MethodChannel _pushDiagnosticsChannel = MethodChannel( const MethodChannel _pushDiagnosticsChannel = MethodChannel(
'oott/push_diagnostics', 'oott/push_diagnostics',
@@ -25,7 +45,7 @@ const MethodChannel _pushDiagnosticsChannel = MethodChannel(
/// status string on iOS, or null elsewhere or when the channel is unavailable /// status string on iOS, or null elsewhere or when the channel is unavailable
/// (e.g. in tests). /// (e.g. in tests).
Future<String?> apnsRegistrationStatus() async { Future<String?> apnsRegistrationStatus() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return null; if (!_isIOS) return null;
try { try {
return await _pushDiagnosticsChannel.invokeMethod<String>('apnsStatus'); return await _pushDiagnosticsChannel.invokeMethod<String>('apnsStatus');
} catch (_) { } catch (_) {
@@ -33,17 +53,139 @@ Future<String?> 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 // App-launch wiring
/// 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 /// Resumes push on app launch. Always configures Firebase (required at launch so
/// and if Firebase is already initialized. /// the firebase_messaging plugin can forward the iOS APNs token to FCM), then,
Future<void> initFirebaseForPush() async { /// when push is already enabled on this device, re-attaches the foreground
if (!pushSupportedOnThisPlatform || Firebase.apps.isNotEmpty) return; /// 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<void> 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<void> _initFirebase() async {
if (!_pushSupported || Firebase.apps.isNotEmpty) return;
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); 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<void> _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<bool> _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<bool> _awaitApnsToken() async {
for (var attempt = 0; attempt < 10; attempt++) {
if (await FirebaseMessaging.instance.getAPNSToken() != null) return true;
await Future<void>.delayed(const Duration(milliseconds: 500));
}
return false;
}
// --------------------------------------------------------------------------
// Service
// --------------------------------------------------------------------------
/// Per-device push enable/disable, behind an interface so the settings UI can be /// 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 /// 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 /// 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 /// FCM-backed [PushService]. Kept separate from the UI so its Firebase
/// dependencies never reach widget tests, which use a fake [PushService]. /// dependencies never reach widget tests, which use a fake [PushService].
class FirebasePushService implements 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 @override
bool get isSupported => pushSupportedOnThisPlatform; bool get isSupported => _pushSupported;
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<void> _ensureFirebase() => initFirebaseForPush();
@override @override
Future<bool> enable() async { Future<bool> enable() async {
if (!isSupported) return false; if (!_pushSupported) return false;
await _ensureFirebase(); // Firebase must be configured before requesting permission on iOS.
await _initFirebase();
final settings = await FirebaseMessaging.instance.requestPermission(); final settings = await FirebaseMessaging.instance.requestPermission();
if (settings.authorizationStatus == AuthorizationStatus.denied) { if (settings.authorizationStatus == AuthorizationStatus.denied) {
return false; return false;
} }
// On iOS, FCM can only mint a token once Apple has delivered the APNs token // Obtain and register the token (waits for the iOS APNs token internally);
// to the app, which happens asynchronously after permission is granted. // false means no token could be minted, so the toggle stays off.
// Calling getToken() before then throws `apns-token-not-set`, so wait for if (!await _registerCurrentToken()) return false;
// 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;
}
final token = await FirebaseMessaging.instance.getToken(); await _ensureForegroundDisplay();
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();
return true; return true;
} }
@override @override
Future<void> disable() async { Future<void> disable() async {
if (!isSupported) return; if (!_pushSupported) return;
await _ensureFirebase(); await _initFirebase();
final token = await FirebaseMessaging.instance.getToken(); final token = await FirebaseMessaging.instance.getToken();
if (token != null) { if (token != null) {
await BackendAPI.instance.unregisterPushToken(token); await BackendAPI.instance.unregisterPushToken(token);
} }
await FirebaseMessaging.instance.deleteToken(); 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<bool> _awaitApnsToken() async {
for (var attempt = 0; attempt < 10; attempt++) {
if (await FirebaseMessaging.instance.getAPNSToken() != null) return true;
await Future<void>.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<void> _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(),
),
);
});
}
} }