mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: NoopCrashReportingService and handle Firebase initialization gracefully

+236 -12
+29
ios/Runner.xcodeproj/project.pbxproj
··· 13 13 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 14 14 62EEA636832BD30DA6CBEFB6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */; }; 15 15 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 16 + 7F3E251C2C4A91B900B33C11 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7F3E251B2C4A91B900B33C11 /* GoogleService-Info.plist */; }; 16 17 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 17 18 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 18 19 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; ··· 53 54 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 = "<group>"; }; 54 55 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 55 56 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 57 + 7F3E251B2C4A91B900B33C11 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 56 58 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 57 59 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 = "<group>"; }; 58 60 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; ··· 135 137 97C146FD1CF9000F007C117D /* Assets.xcassets */, 136 138 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 137 139 97C147021CF9000F007C117D /* Info.plist */, 140 + 7F3E251B2C4A91B900B33C11 /* GoogleService-Info.plist */, 138 141 9D72E1A09E8B4F5D8D8B46A1 /* Runner.entitlements */, 139 142 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 140 143 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, ··· 201 204 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 202 205 D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */, 203 206 46A625365BA943162BC0528F /* [CP] Copy Pods Resources */, 207 + F71C51392C4AA10000B33C11 /* [firebase_crashlytics] Crashlytics Upload Symbols */, 204 208 ); 205 209 buildRules = ( 206 210 ); ··· 262 266 isa = PBXResourcesBuildPhase; 263 267 buildActionMask = 2147483647; 264 268 files = ( 269 + 7F3E251C2C4A91B900B33C11 /* GoogleService-Info.plist in Resources */, 265 270 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 266 271 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 267 272 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, ··· 379 384 runOnlyForDeploymentPostprocessing = 0; 380 385 shellPath = /bin/sh; 381 386 shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 387 + showEnvVarsInLog = 0; 388 + }; 389 + F71C51392C4AA10000B33C11 /* [firebase_crashlytics] Crashlytics Upload Symbols */ = { 390 + isa = PBXShellScriptBuildPhase; 391 + buildActionMask = 2147483647; 392 + files = ( 393 + ); 394 + inputFileListPaths = ( 395 + ); 396 + inputPaths = ( 397 + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", 398 + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", 399 + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", 400 + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", 401 + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", 402 + ); 403 + name = "[firebase_crashlytics] Crashlytics Upload Symbols"; 404 + outputFileListPaths = ( 405 + ); 406 + outputPaths = ( 407 + ); 408 + runOnlyForDeploymentPostprocessing = 0; 409 + shellPath = /bin/sh; 410 + shellScript = "\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; 382 411 showEnvVarsInLog = 0; 383 412 }; 384 413 /* End PBXShellScriptBuildPhase section */
+27 -1
lib/core/crash_reporting/crash_reporting_service.dart
··· 18 18 void crash(); 19 19 } 20 20 21 + class NoopCrashReportingService implements CrashReportingService { 22 + @override 23 + void recordFlutterFatalError(FlutterErrorDetails details) {} 24 + 25 + @override 26 + Future<void> recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async {} 27 + 28 + @override 29 + Future<void> setCollectionEnabled(bool enabled) async {} 30 + 31 + @override 32 + Future<void> sendUnsentReports() async {} 33 + 34 + @override 35 + Future<void> deleteUnsentReports() async {} 36 + 37 + @override 38 + void crash() { 39 + log.w('Crashlytics test crash unavailable because Firebase is not initialized.'); 40 + } 41 + } 42 + 21 43 class FirebaseCrashReportingService implements CrashReportingService { 22 44 FirebaseCrashReportingService({FirebaseCrashlytics? crashlytics}) 23 45 : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance; ··· 75 97 76 98 @override 77 99 void crash() { 78 - _crashlytics.crash(); 100 + try { 101 + _crashlytics.crash(); 102 + } catch (error, stackTrace) { 103 + log.w('Unable to trigger Crashlytics test crash', error: error, stackTrace: stackTrace); 104 + } 79 105 } 80 106 }
+46 -4
lib/features/notifications/data/firebase_push_token_provider.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:io'; 2 3 3 4 import 'package:firebase_core/firebase_core.dart'; 4 5 import 'package:firebase_messaging/firebase_messaging.dart'; ··· 6 7 import 'package:lazurite/features/notifications/domain/push_token_provider.dart'; 7 8 8 9 class FirebasePushTokenProvider implements PushTokenProvider { 9 - FirebasePushTokenProvider({FirebaseMessaging? messaging}) : _messaging = messaging; 10 + FirebasePushTokenProvider({ 11 + FirebaseMessaging? messaging, 12 + bool Function()? isApplePlatform, 13 + Future<void> Function(Duration)? delayFn, 14 + int apnsTokenRetryAttempts = 15, 15 + Duration apnsTokenRetryDelay = const Duration(milliseconds: 400), 16 + }) : _messaging = messaging, 17 + _isApplePlatform = isApplePlatform ?? (() => Platform.isIOS || Platform.isMacOS), 18 + _delayFn = delayFn ?? Future.delayed, 19 + _apnsTokenRetryAttempts = apnsTokenRetryAttempts, 20 + _apnsTokenRetryDelay = apnsTokenRetryDelay; 10 21 11 22 FirebaseMessaging? _messaging; 23 + final bool Function() _isApplePlatform; 24 + final Future<void> Function(Duration) _delayFn; 25 + final int _apnsTokenRetryAttempts; 26 + final Duration _apnsTokenRetryDelay; 12 27 final _refreshController = StreamController<String>.broadcast(); 13 28 StreamSubscription<String>? _refreshSubscription; 14 29 var _initialized = false; ··· 23 38 } 24 39 25 40 try { 26 - if (Firebase.apps.isEmpty) { 27 - await Firebase.initializeApp(); 41 + if (_messaging == null) { 42 + if (Firebase.apps.isEmpty) { 43 + await Firebase.initializeApp(); 44 + } 45 + _messaging = FirebaseMessaging.instance; 28 46 } 29 47 30 - _messaging ??= FirebaseMessaging.instance; 31 48 final messaging = _messaging!; 32 49 33 50 final notificationSettings = await messaging.requestPermission( ··· 39 56 log.i('Notification permission status: ${notificationSettings.authorizationStatus.name}'); 40 57 41 58 await messaging.setAutoInitEnabled(true); 59 + await _waitForApnsToken(messaging); 42 60 43 61 _refreshSubscription = messaging.onTokenRefresh.listen( 44 62 (token) { ··· 70 88 } 71 89 72 90 try { 91 + await _waitForApnsToken(_messaging!); 73 92 final token = await _messaging!.getToken(); 74 93 final trimmed = token?.trim(); 75 94 if (trimmed == null || trimmed.isEmpty) { ··· 80 99 log.w('Failed to acquire push token', error: error, stackTrace: stackTrace); 81 100 return null; 82 101 } 102 + } 103 + 104 + Future<void> _waitForApnsToken(FirebaseMessaging messaging) async { 105 + if (!_isApplePlatform()) { 106 + return; 107 + } 108 + 109 + for (var attempt = 1; attempt <= _apnsTokenRetryAttempts; attempt++) { 110 + final apnsToken = await messaging.getAPNSToken(); 111 + final normalized = apnsToken?.trim(); 112 + if (normalized != null && normalized.isNotEmpty) { 113 + if (attempt > 1) { 114 + log.i('APNs token became available after retry (attempt $attempt/$_apnsTokenRetryAttempts)'); 115 + } 116 + return; 117 + } 118 + await _delayFn(_apnsTokenRetryDelay); 119 + } 120 + 121 + log.w( 122 + 'APNs token unavailable after retries; FCM token registration may be delayed ' 123 + 'until APNs registration completes', 124 + ); 83 125 } 84 126 85 127 @override
+26 -7
lib/main.dart
··· 76 76 imageCache.maximumSizeBytes = OfflineCachePolicy.imageMemoryByteLimit; 77 77 78 78 await log.initialize(); 79 - if (Firebase.apps.isEmpty) { 80 - await Firebase.initializeApp(); 79 + var firebaseAvailable = false; 80 + try { 81 + if (Firebase.apps.isEmpty) { 82 + await Firebase.initializeApp(); 83 + } 84 + firebaseAvailable = Firebase.apps.isNotEmpty; 85 + } catch (error, stackTrace) { 86 + log.w( 87 + 'Firebase initialization failed; continuing with Firebase-dependent features disabled', 88 + error: error, 89 + stackTrace: stackTrace, 90 + ); 81 91 } 82 92 83 - final crashReportingService = FirebaseCrashReportingService(); 93 + final crashReportingService = firebaseAvailable ? FirebaseCrashReportingService() : NoopCrashReportingService(); 84 94 final previousFlutterErrorHandler = FlutterError.onError; 85 95 FlutterError.onError = (details) { 86 96 previousFlutterErrorHandler?.call(details); ··· 92 102 }; 93 103 94 104 await PostScheduler.initialize(); 95 - FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); 105 + if (firebaseAvailable) { 106 + FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); 107 + } 96 108 await NotificationBackgroundScheduler.ensureScheduled(); 97 109 Bloc.observer = LoggingBlocObserver(); 98 110 ··· 161 173 localNotificationAdapter, 162 174 pushRegistrationService, 163 175 crashReportingService, 176 + firebaseAvailable, 164 177 ), 165 178 ); 166 179 }, ··· 184 197 required this.localNotificationAdapter, 185 198 required this.pushRegistrationService, 186 199 required this.crashReportingService, 200 + required this.firebaseAvailable, 187 201 }); 188 202 189 203 final AuthBloc authBloc; ··· 197 211 final LocalNotificationAdapter localNotificationAdapter; 198 212 final PushRegistrationService pushRegistrationService; 199 213 final CrashReportingService crashReportingService; 214 + final bool firebaseAvailable; 200 215 201 216 /// factory constructor with positional params 202 217 static LazuriteApp from( ··· 211 226 LocalNotificationAdapter localNotificationAdapter, 212 227 PushRegistrationService pushRegistrationService, 213 228 CrashReportingService crashReportingService, 229 + bool firebaseAvailable, 214 230 ) => LazuriteApp( 215 231 authBloc: authBloc, 216 232 database: database, ··· 223 239 localNotificationAdapter: localNotificationAdapter, 224 240 pushRegistrationService: pushRegistrationService, 225 241 crashReportingService: crashReportingService, 242 + firebaseAvailable: firebaseAvailable, 226 243 ); 227 244 228 245 @override ··· 258 275 _pushRegistrationSubscription = widget.authBloc.stream.map((state) => state.tokens).listen((tokens) { 259 276 unawaited(widget.pushRegistrationService.updateSession(tokens)); 260 277 }); 261 - _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { 262 - unawaited(notificationPushPayloadEntrypoint(message.data)); 263 - }); 278 + if (widget.firebaseAvailable) { 279 + _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { 280 + unawaited(notificationPushPayloadEntrypoint(message.data)); 281 + }); 282 + } 264 283 _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); 265 284 _simulateOfflineSubscription = widget.settingsCubit.stream 266 285 .map((state) => state.simulateOffline)
+108
test/features/notifications/data/firebase_push_token_provider_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:firebase_messaging/firebase_messaging.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/features/notifications/data/firebase_push_token_provider.dart'; 6 + import 'package:mocktail/mocktail.dart'; 7 + 8 + class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} 9 + 10 + void main() { 11 + const grantedSettings = NotificationSettings( 12 + alert: AppleNotificationSetting.enabled, 13 + announcement: AppleNotificationSetting.enabled, 14 + authorizationStatus: AuthorizationStatus.authorized, 15 + badge: AppleNotificationSetting.enabled, 16 + carPlay: AppleNotificationSetting.notSupported, 17 + lockScreen: AppleNotificationSetting.enabled, 18 + notificationCenter: AppleNotificationSetting.enabled, 19 + showPreviews: AppleShowPreviewSetting.always, 20 + timeSensitive: AppleNotificationSetting.notSupported, 21 + criticalAlert: AppleNotificationSetting.notSupported, 22 + sound: AppleNotificationSetting.enabled, 23 + providesAppNotificationSettings: AppleNotificationSetting.notSupported, 24 + ); 25 + 26 + group('FirebasePushTokenProvider', () { 27 + late MockFirebaseMessaging messaging; 28 + late StreamController<String> tokenRefreshController; 29 + 30 + setUp(() { 31 + messaging = MockFirebaseMessaging(); 32 + tokenRefreshController = StreamController<String>.broadcast(); 33 + when(() => messaging.onTokenRefresh).thenAnswer((_) => tokenRefreshController.stream); 34 + when( 35 + () => messaging.requestPermission( 36 + alert: any(named: 'alert'), 37 + badge: any(named: 'badge'), 38 + sound: any(named: 'sound'), 39 + provisional: any(named: 'provisional'), 40 + ), 41 + ).thenAnswer((_) async => grantedSettings); 42 + when(() => messaging.setAutoInitEnabled(any())).thenAnswer((_) async {}); 43 + }); 44 + 45 + tearDown(() async { 46 + await tokenRefreshController.close(); 47 + }); 48 + 49 + test('waits for APNs token availability on Apple platforms before requesting FCM token', () async { 50 + var apnsCalls = 0; 51 + when(() => messaging.getAPNSToken()).thenAnswer((_) async { 52 + apnsCalls += 1; 53 + return apnsCalls >= 2 ? 'apns-token' : null; 54 + }); 55 + when(() => messaging.getToken(vapidKey: any(named: 'vapidKey'))).thenAnswer((_) async => 'fcm-token'); 56 + 57 + final provider = FirebasePushTokenProvider( 58 + messaging: messaging, 59 + isApplePlatform: () => true, 60 + apnsTokenRetryAttempts: 3, 61 + apnsTokenRetryDelay: Duration.zero, 62 + delayFn: (_) async {}, 63 + ); 64 + addTearDown(provider.dispose); 65 + 66 + final token = await provider.getToken(); 67 + 68 + expect(token, 'fcm-token'); 69 + expect(apnsCalls, greaterThanOrEqualTo(2)); 70 + verify(() => messaging.setAutoInitEnabled(true)).called(1); 71 + }); 72 + 73 + test('does not query APNs token on non-Apple platforms', () async { 74 + when(() => messaging.getToken(vapidKey: any(named: 'vapidKey'))).thenAnswer((_) async => 'fcm-token'); 75 + 76 + final provider = FirebasePushTokenProvider( 77 + messaging: messaging, 78 + isApplePlatform: () => false, 79 + apnsTokenRetryAttempts: 1, 80 + ); 81 + addTearDown(provider.dispose); 82 + 83 + final token = await provider.getToken(); 84 + 85 + expect(token, 'fcm-token'); 86 + verifyNever(() => messaging.getAPNSToken()); 87 + }); 88 + 89 + test('forwards non-empty refreshed tokens', () async { 90 + when(() => messaging.getAPNSToken()).thenAnswer((_) async => 'apns-token'); 91 + when(() => messaging.getToken(vapidKey: any(named: 'vapidKey'))).thenAnswer((_) async => 'fcm-token'); 92 + 93 + final provider = FirebasePushTokenProvider( 94 + messaging: messaging, 95 + isApplePlatform: () => true, 96 + apnsTokenRetryAttempts: 1, 97 + ); 98 + addTearDown(provider.dispose); 99 + 100 + await provider.initialize(); 101 + final firstTokenFuture = provider.onTokenRefresh.first; 102 + tokenRefreshController.add(' '); 103 + tokenRefreshController.add('new-token'); 104 + final emitted = await firstTokenFuture; 105 + expect(emitted, 'new-token'); 106 + }); 107 + }); 108 + }