diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9a3a1a7..edc5152 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 62EEA636832BD30DA6CBEFB6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7F3E251C2C4A91B900B33C11 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7F3E251B2C4A91B900B33C11 /* GoogleService-Info.plist */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -53,6 +54,7 @@ 420DA8205F7F2E7EF8664FD5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7F3E251B2C4A91B900B33C11 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -135,6 +137,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + 7F3E251B2C4A91B900B33C11 /* GoogleService-Info.plist */, 9D72E1A09E8B4F5D8D8B46A1 /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, @@ -201,6 +204,7 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */, 46A625365BA943162BC0528F /* [CP] Copy Pods Resources */, + F71C51392C4AA10000B33C11 /* [firebase_crashlytics] Crashlytics Upload Symbols */, ); buildRules = ( ); @@ -262,6 +266,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7F3E251C2C4A91B900B33C11 /* GoogleService-Info.plist in Resources */, 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, @@ -381,6 +386,30 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + F71C51392C4AA10000B33C11 /* [firebase_crashlytics] Crashlytics Upload Symbols */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", + ); + name = "[firebase_crashlytics] Crashlytics Upload Symbols"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/lib/core/crash_reporting/crash_reporting_service.dart b/lib/core/crash_reporting/crash_reporting_service.dart index 0cc47a3..565f195 100644 --- a/lib/core/crash_reporting/crash_reporting_service.dart +++ b/lib/core/crash_reporting/crash_reporting_service.dart @@ -18,6 +18,28 @@ abstract class CrashReportingService { void crash(); } +class NoopCrashReportingService implements CrashReportingService { + @override + void recordFlutterFatalError(FlutterErrorDetails details) {} + + @override + Future recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async {} + + @override + Future setCollectionEnabled(bool enabled) async {} + + @override + Future sendUnsentReports() async {} + + @override + Future deleteUnsentReports() async {} + + @override + void crash() { + log.w('Crashlytics test crash unavailable because Firebase is not initialized.'); + } +} + class FirebaseCrashReportingService implements CrashReportingService { FirebaseCrashReportingService({FirebaseCrashlytics? crashlytics}) : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance; @@ -75,6 +97,10 @@ class FirebaseCrashReportingService implements CrashReportingService { @override void crash() { - _crashlytics.crash(); + try { + _crashlytics.crash(); + } catch (error, stackTrace) { + log.w('Unable to trigger Crashlytics test crash', error: error, stackTrace: stackTrace); + } } } diff --git a/lib/features/notifications/data/firebase_push_token_provider.dart b/lib/features/notifications/data/firebase_push_token_provider.dart index 8d71b59..95f6da5 100644 --- a/lib/features/notifications/data/firebase_push_token_provider.dart +++ b/lib/features/notifications/data/firebase_push_token_provider.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -6,9 +7,23 @@ import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/notifications/domain/push_token_provider.dart'; class FirebasePushTokenProvider implements PushTokenProvider { - FirebasePushTokenProvider({FirebaseMessaging? messaging}) : _messaging = messaging; + FirebasePushTokenProvider({ + FirebaseMessaging? messaging, + bool Function()? isApplePlatform, + Future Function(Duration)? delayFn, + int apnsTokenRetryAttempts = 15, + Duration apnsTokenRetryDelay = const Duration(milliseconds: 400), + }) : _messaging = messaging, + _isApplePlatform = isApplePlatform ?? (() => Platform.isIOS || Platform.isMacOS), + _delayFn = delayFn ?? Future.delayed, + _apnsTokenRetryAttempts = apnsTokenRetryAttempts, + _apnsTokenRetryDelay = apnsTokenRetryDelay; FirebaseMessaging? _messaging; + final bool Function() _isApplePlatform; + final Future Function(Duration) _delayFn; + final int _apnsTokenRetryAttempts; + final Duration _apnsTokenRetryDelay; final _refreshController = StreamController.broadcast(); StreamSubscription? _refreshSubscription; var _initialized = false; @@ -23,11 +38,13 @@ class FirebasePushTokenProvider implements PushTokenProvider { } try { - if (Firebase.apps.isEmpty) { - await Firebase.initializeApp(); + if (_messaging == null) { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + _messaging = FirebaseMessaging.instance; } - _messaging ??= FirebaseMessaging.instance; final messaging = _messaging!; final notificationSettings = await messaging.requestPermission( @@ -39,6 +56,7 @@ class FirebasePushTokenProvider implements PushTokenProvider { log.i('Notification permission status: ${notificationSettings.authorizationStatus.name}'); await messaging.setAutoInitEnabled(true); + await _waitForApnsToken(messaging); _refreshSubscription = messaging.onTokenRefresh.listen( (token) { @@ -70,6 +88,7 @@ class FirebasePushTokenProvider implements PushTokenProvider { } try { + await _waitForApnsToken(_messaging!); final token = await _messaging!.getToken(); final trimmed = token?.trim(); if (trimmed == null || trimmed.isEmpty) { @@ -82,6 +101,29 @@ class FirebasePushTokenProvider implements PushTokenProvider { } } + Future _waitForApnsToken(FirebaseMessaging messaging) async { + if (!_isApplePlatform()) { + return; + } + + for (var attempt = 1; attempt <= _apnsTokenRetryAttempts; attempt++) { + final apnsToken = await messaging.getAPNSToken(); + final normalized = apnsToken?.trim(); + if (normalized != null && normalized.isNotEmpty) { + if (attempt > 1) { + log.i('APNs token became available after retry (attempt $attempt/$_apnsTokenRetryAttempts)'); + } + return; + } + await _delayFn(_apnsTokenRetryDelay); + } + + log.w( + 'APNs token unavailable after retries; FCM token registration may be delayed ' + 'until APNs registration completes', + ); + } + @override Future dispose() async { await _refreshSubscription?.cancel(); diff --git a/lib/main.dart b/lib/main.dart index a5cddb0..86aa4ac 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -76,11 +76,21 @@ Future main() async { imageCache.maximumSizeBytes = OfflineCachePolicy.imageMemoryByteLimit; await log.initialize(); - if (Firebase.apps.isEmpty) { - await Firebase.initializeApp(); + var firebaseAvailable = false; + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + firebaseAvailable = Firebase.apps.isNotEmpty; + } catch (error, stackTrace) { + log.w( + 'Firebase initialization failed; continuing with Firebase-dependent features disabled', + error: error, + stackTrace: stackTrace, + ); } - final crashReportingService = FirebaseCrashReportingService(); + final crashReportingService = firebaseAvailable ? FirebaseCrashReportingService() : NoopCrashReportingService(); final previousFlutterErrorHandler = FlutterError.onError; FlutterError.onError = (details) { previousFlutterErrorHandler?.call(details); @@ -92,7 +102,9 @@ Future main() async { }; await PostScheduler.initialize(); - FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); + if (firebaseAvailable) { + FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); + } await NotificationBackgroundScheduler.ensureScheduled(); Bloc.observer = LoggingBlocObserver(); @@ -161,6 +173,7 @@ Future main() async { localNotificationAdapter, pushRegistrationService, crashReportingService, + firebaseAvailable, ), ); }, @@ -184,6 +197,7 @@ class LazuriteApp extends StatefulWidget { required this.localNotificationAdapter, required this.pushRegistrationService, required this.crashReportingService, + required this.firebaseAvailable, }); final AuthBloc authBloc; @@ -197,6 +211,7 @@ class LazuriteApp extends StatefulWidget { final LocalNotificationAdapter localNotificationAdapter; final PushRegistrationService pushRegistrationService; final CrashReportingService crashReportingService; + final bool firebaseAvailable; /// factory constructor with positional params static LazuriteApp from( @@ -211,6 +226,7 @@ class LazuriteApp extends StatefulWidget { LocalNotificationAdapter localNotificationAdapter, PushRegistrationService pushRegistrationService, CrashReportingService crashReportingService, + bool firebaseAvailable, ) => LazuriteApp( authBloc: authBloc, database: database, @@ -223,6 +239,7 @@ class LazuriteApp extends StatefulWidget { localNotificationAdapter: localNotificationAdapter, pushRegistrationService: pushRegistrationService, crashReportingService: crashReportingService, + firebaseAvailable: firebaseAvailable, ); @override @@ -258,9 +275,11 @@ class _LazuriteAppState extends State { _pushRegistrationSubscription = widget.authBloc.stream.map((state) => state.tokens).listen((tokens) { unawaited(widget.pushRegistrationService.updateSession(tokens)); }); - _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { - unawaited(notificationPushPayloadEntrypoint(message.data)); - }); + if (widget.firebaseAvailable) { + _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { + unawaited(notificationPushPayloadEntrypoint(message.data)); + }); + } _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); _simulateOfflineSubscription = widget.settingsCubit.stream .map((state) => state.simulateOffline) diff --git a/test/features/notifications/data/firebase_push_token_provider_test.dart b/test/features/notifications/data/firebase_push_token_provider_test.dart new file mode 100644 index 0000000..a0654c2 --- /dev/null +++ b/test/features/notifications/data/firebase_push_token_provider_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/notifications/data/firebase_push_token_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} + +void main() { + const grantedSettings = NotificationSettings( + alert: AppleNotificationSetting.enabled, + announcement: AppleNotificationSetting.enabled, + authorizationStatus: AuthorizationStatus.authorized, + badge: AppleNotificationSetting.enabled, + carPlay: AppleNotificationSetting.notSupported, + lockScreen: AppleNotificationSetting.enabled, + notificationCenter: AppleNotificationSetting.enabled, + showPreviews: AppleShowPreviewSetting.always, + timeSensitive: AppleNotificationSetting.notSupported, + criticalAlert: AppleNotificationSetting.notSupported, + sound: AppleNotificationSetting.enabled, + providesAppNotificationSettings: AppleNotificationSetting.notSupported, + ); + + group('FirebasePushTokenProvider', () { + late MockFirebaseMessaging messaging; + late StreamController tokenRefreshController; + + setUp(() { + messaging = MockFirebaseMessaging(); + tokenRefreshController = StreamController.broadcast(); + when(() => messaging.onTokenRefresh).thenAnswer((_) => tokenRefreshController.stream); + when( + () => messaging.requestPermission( + alert: any(named: 'alert'), + badge: any(named: 'badge'), + sound: any(named: 'sound'), + provisional: any(named: 'provisional'), + ), + ).thenAnswer((_) async => grantedSettings); + when(() => messaging.setAutoInitEnabled(any())).thenAnswer((_) async {}); + }); + + tearDown(() async { + await tokenRefreshController.close(); + }); + + test('waits for APNs token availability on Apple platforms before requesting FCM token', () async { + var apnsCalls = 0; + when(() => messaging.getAPNSToken()).thenAnswer((_) async { + apnsCalls += 1; + return apnsCalls >= 2 ? 'apns-token' : null; + }); + when(() => messaging.getToken(vapidKey: any(named: 'vapidKey'))).thenAnswer((_) async => 'fcm-token'); + + final provider = FirebasePushTokenProvider( + messaging: messaging, + isApplePlatform: () => true, + apnsTokenRetryAttempts: 3, + apnsTokenRetryDelay: Duration.zero, + delayFn: (_) async {}, + ); + addTearDown(provider.dispose); + + final token = await provider.getToken(); + + expect(token, 'fcm-token'); + expect(apnsCalls, greaterThanOrEqualTo(2)); + verify(() => messaging.setAutoInitEnabled(true)).called(1); + }); + + test('does not query APNs token on non-Apple platforms', () async { + when(() => messaging.getToken(vapidKey: any(named: 'vapidKey'))).thenAnswer((_) async => 'fcm-token'); + + final provider = FirebasePushTokenProvider( + messaging: messaging, + isApplePlatform: () => false, + apnsTokenRetryAttempts: 1, + ); + addTearDown(provider.dispose); + + final token = await provider.getToken(); + + expect(token, 'fcm-token'); + verifyNever(() => messaging.getAPNSToken()); + }); + + test('forwards non-empty refreshed tokens', () async { + when(() => messaging.getAPNSToken()).thenAnswer((_) async => 'apns-token'); + when(() => messaging.getToken(vapidKey: any(named: 'vapidKey'))).thenAnswer((_) async => 'fcm-token'); + + final provider = FirebasePushTokenProvider( + messaging: messaging, + isApplePlatform: () => true, + apnsTokenRetryAttempts: 1, + ); + addTearDown(provider.dispose); + + await provider.initialize(); + final firstTokenFuture = provider.onTokenRefresh.first; + tokenRefreshController.add(' '); + tokenRefreshController.add('new-token'); + final emitted = await firstTokenFuture; + expect(emitted, 'new-token'); + }); + }); +}