mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
On iOS, FirebaseMessaging.getToken() throws apns-token-not-set when called before Apple has asynchronously delivered the APNs token, which happens after the permission prompt. This surfaced as "Failed to update push settings" with the toggle stuck off. Poll getAPNSToken() with a short bounded wait first, and return false (toggle stays off) rather than throwing if it never arrives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
166 lines
6.0 KiB
Dart
166 lines
6.0 KiB
Dart
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
|
|
import '../firebase_options.dart';
|
|
import 'oott_api.dart';
|
|
|
|
/// 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
|
|
/// holds the detail.
|
|
abstract class PushService {
|
|
/// Whether push is available on this platform/build (mobile only — FCM/APNs).
|
|
bool get isSupported;
|
|
|
|
/// Requests notification permission, obtains the FCM token, and registers it
|
|
/// with the backend. Returns true when push is enabled, false when the user
|
|
/// declined permission or no token could be obtained.
|
|
Future<bool> enable();
|
|
|
|
/// Unregisters this device's token from the backend and clears it locally so
|
|
/// the device stops receiving push notifications.
|
|
Future<void> disable();
|
|
}
|
|
|
|
/// 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 =>
|
|
!kIsWeb &&
|
|
(defaultTargetPlatform == TargetPlatform.android ||
|
|
defaultTargetPlatform == TargetPlatform.iOS);
|
|
|
|
String get _platformName =>
|
|
defaultTargetPlatform == TargetPlatform.iOS ? 'ios' : 'android';
|
|
|
|
Future<void> _ensureFirebase() async {
|
|
// 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.
|
|
if (Firebase.apps.isEmpty) {
|
|
await Firebase.initializeApp(
|
|
options: DefaultFirebaseOptions.currentPlatform,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> enable() async {
|
|
if (!isSupported) return false;
|
|
await _ensureFirebase();
|
|
|
|
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;
|
|
}
|
|
|
|
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();
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<void> disable() async {
|
|
if (!isSupported) return;
|
|
await _ensureFirebase();
|
|
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<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(),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|