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: push notifications

+1515 -29
+7 -1
docs/TODO.md
··· 1 1 --- 2 2 title: To-Do/Parking Lot 3 - updated: 2026-04-26 3 + updated: 2026-05-02 4 4 --- 5 5 6 6 ## Tests ··· 18 18 - Saved posts should be a tabbed view for local & ATProto/BSky saved posts. 19 19 20 20 ## UX 21 + 22 + ### Notifications 23 + 24 + - Foreground push messages are processed through the standalone background notification 25 + context (same path as background payload handling), to keep behavior consistent across 26 + app states. 21 27 22 28 ### Posts 23 29
+23 -15
docs/tasks/notification.md
··· 1 1 --- 2 2 title: Notification Milestones 3 - updated: 2026-04-29 3 + updated: 2026-05-01 4 4 --- 5 5 6 6 ## M1 - Foundation Hardening (Polling Baseline) ··· 20 20 21 21 ## M3 - Push Registration Lifecycle 22 22 23 - - [ ] Add token acquisition and refresh listeners 24 - - [ ] Implement `registerPush` and `unregisterPush` 25 - - [ ] Wire account login/switch/logout paths 26 - - [ ] Add retries/backoff for registration failures 27 - - [ ] Add unit tests for lifecycle transitions 23 + - [x] Add token acquisition and refresh listeners 24 + - [x] Implement `registerPush` and `unregisterPush` 25 + - [x] Wire account login/switch/logout paths 26 + - [x] Add retries/backoff for registration failures 27 + - [x] Add unit tests for lifecycle transitions 28 28 29 29 ## M4 - Push Payload Processing 30 30 31 - - [ ] Add background payload entrypoint (`@pragma('vm:entry-point')`) 32 - - [ ] Parse defensively: `senderDid`, `targetDid`, `recordUri`, `reason` 33 - - [ ] Fetch canonical notification payload before display 34 - - [ ] Apply moderation + preference filtering before display 35 - - [ ] Add timeout-bound processing and drop accounting 31 + - [x] Add background payload entrypoint (`@pragma('vm:entry-point')`) 32 + - [x] Parse defensively: `senderDid`, `targetDid`, `recordUri`, `reason` 33 + - [x] Fetch canonical notification payload before display 34 + - [x] Apply moderation + preference filtering before display 35 + - [x] Add timeout-bound processing and drop accounting 36 36 37 37 ## M5 - Background Reconciliation 38 38 39 - - [ ] Add periodic background reconcile task (Android 15m+) 40 - - [ ] Add iOS background fetch/BGTaskScheduler integration 41 - - [ ] Ensure tasks are idempotent and dedupe-safe 42 - - [ ] Add test harness for worker entrypoints 39 + - [x] Add periodic background reconcile task (Android 15m+) 40 + - [x] Add iOS background fetch/BGTaskScheduler integration 41 + - [x] Ensure tasks are idempotent and dedupe-safe 42 + - [x] Add test harness for worker entrypoints 43 43 44 44 ## M6 - Preferences and UX 45 45 ··· 54 54 - [ ] Add smoke checklist for Android/iOS permission and delivery scenarios 55 55 - [ ] Validate multi-account behavior and token cleanup 56 56 - [ ] Run full `flutter analyze` and full test suite 57 + 58 + ## M8 - Firebase/APNs Production Push Setup 59 + 60 + - [ ] Create/configure Firebase project apps for iOS + Android 61 + - [ ] Add `GoogleService-Info.plist` to iOS target and `google-services.json` to `android/app` 62 + - [ ] Configure Apple Push Notifications capability/provisioning in Apple Developer 63 + - [ ] Upload APNs auth key/certificate to Firebase Cloud Messaging settings 64 + - [ ] Validate end-to-end remote push delivery (foreground, background, terminated) on iOS + Android
+97
ios/Podfile.lock
··· 1 1 PODS: 2 2 - connectivity_plus (0.0.1): 3 3 - Flutter 4 + - Firebase/CoreOnly (12.12.0): 5 + - FirebaseCore (~> 12.12.0) 6 + - Firebase/Messaging (12.12.0): 7 + - Firebase/CoreOnly 8 + - FirebaseMessaging (~> 12.12.0) 9 + - firebase_core (4.7.0): 10 + - Firebase/CoreOnly (= 12.12.0) 11 + - Flutter 12 + - firebase_messaging (16.2.0): 13 + - Firebase/Messaging (= 12.12.0) 14 + - firebase_core 15 + - Flutter 16 + - FirebaseCore (12.12.1): 17 + - FirebaseCoreInternal (~> 12.12.0) 18 + - GoogleUtilities/Environment (~> 8.1) 19 + - GoogleUtilities/Logger (~> 8.1) 20 + - FirebaseCoreInternal (12.12.0): 21 + - "GoogleUtilities/NSData+zlib (~> 8.1)" 22 + - FirebaseInstallations (12.12.0): 23 + - FirebaseCore (~> 12.12.0) 24 + - GoogleUtilities/Environment (~> 8.1) 25 + - GoogleUtilities/UserDefaults (~> 8.1) 26 + - PromisesObjC (~> 2.4) 27 + - FirebaseMessaging (12.12.0): 28 + - FirebaseCore (~> 12.12.0) 29 + - FirebaseInstallations (~> 12.12.0) 30 + - GoogleDataTransport (~> 10.1) 31 + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) 32 + - GoogleUtilities/Environment (~> 8.1) 33 + - GoogleUtilities/Reachability (~> 8.1) 34 + - GoogleUtilities/UserDefaults (~> 8.1) 35 + - nanopb (~> 3.30910.0) 4 36 - Flutter (1.0.0) 37 + - flutter_local_notifications (0.0.1): 38 + - Flutter 5 39 - flutter_native_splash (2.4.3): 6 40 - Flutter 7 41 - gal (1.0.0): 8 42 - Flutter 9 43 - FlutterMacOS 44 + - GoogleDataTransport (10.1.0): 45 + - nanopb (~> 3.30910.0) 46 + - PromisesObjC (~> 2.4) 47 + - GoogleUtilities/AppDelegateSwizzler (8.1.0): 48 + - GoogleUtilities/Environment 49 + - GoogleUtilities/Logger 50 + - GoogleUtilities/Network 51 + - GoogleUtilities/Privacy 52 + - GoogleUtilities/Environment (8.1.0): 53 + - GoogleUtilities/Privacy 54 + - GoogleUtilities/Logger (8.1.0): 55 + - GoogleUtilities/Environment 56 + - GoogleUtilities/Privacy 57 + - GoogleUtilities/Network (8.1.0): 58 + - GoogleUtilities/Logger 59 + - "GoogleUtilities/NSData+zlib" 60 + - GoogleUtilities/Privacy 61 + - GoogleUtilities/Reachability 62 + - "GoogleUtilities/NSData+zlib (8.1.0)": 63 + - GoogleUtilities/Privacy 64 + - GoogleUtilities/Privacy (8.1.0) 65 + - GoogleUtilities/Reachability (8.1.0): 66 + - GoogleUtilities/Logger 67 + - GoogleUtilities/Privacy 68 + - GoogleUtilities/UserDefaults (8.1.0): 69 + - GoogleUtilities/Logger 70 + - GoogleUtilities/Privacy 10 71 - image_picker_ios (0.0.1): 11 72 - Flutter 73 + - nanopb (3.30910.0): 74 + - nanopb/decode (= 3.30910.0) 75 + - nanopb/encode (= 3.30910.0) 76 + - nanopb/decode (3.30910.0) 77 + - nanopb/encode (3.30910.0) 12 78 - ObjectBox (5.3.0-beta.4) 13 79 - objectbox_flutter_libs (0.0.1): 14 80 - Flutter ··· 17 83 - Flutter 18 84 - permission_handler_apple (9.3.0): 19 85 - Flutter 86 + - PromisesObjC (2.4.0) 20 87 - share_plus (0.0.1): 21 88 - Flutter 22 89 - sqflite_darwin (0.0.4): ··· 81 148 82 149 DEPENDENCIES: 83 150 - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) 151 + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) 152 + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) 84 153 - Flutter (from `Flutter`) 154 + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) 85 155 - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) 86 156 - gal (from `.symlinks/plugins/gal/darwin`) 87 157 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) ··· 99 169 100 170 SPEC REPOS: 101 171 trunk: 172 + - Firebase 173 + - FirebaseCore 174 + - FirebaseCoreInternal 175 + - FirebaseInstallations 176 + - FirebaseMessaging 177 + - GoogleDataTransport 178 + - GoogleUtilities 179 + - nanopb 102 180 - ObjectBox 181 + - PromisesObjC 103 182 - sqlite3 104 183 - TensorFlowLiteC 105 184 - TensorFlowLiteSwift ··· 107 186 EXTERNAL SOURCES: 108 187 connectivity_plus: 109 188 :path: ".symlinks/plugins/connectivity_plus/ios" 189 + firebase_core: 190 + :path: ".symlinks/plugins/firebase_core/ios" 191 + firebase_messaging: 192 + :path: ".symlinks/plugins/firebase_messaging/ios" 110 193 Flutter: 111 194 :path: Flutter 195 + flutter_local_notifications: 196 + :path: ".symlinks/plugins/flutter_local_notifications/ios" 112 197 flutter_native_splash: 113 198 :path: ".symlinks/plugins/flutter_native_splash/ios" 114 199 gal: ··· 140 225 141 226 SPEC CHECKSUMS: 142 227 connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd 228 + Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33 229 + firebase_core: 9156a152117c843440b0b990c785aa0259bc5447 230 + firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269 231 + FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284 232 + FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9 233 + FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e 234 + FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31 143 235 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 236 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb 144 237 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf 145 238 gal: baecd024ebfd13c441269ca7404792a7152fde89 239 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 240 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 146 241 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 242 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 147 243 ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a 148 244 objectbox_flutter_libs: ed1510f71602e4a0d3f2a721324e468d066fdbb9 149 245 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 150 246 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d 247 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 151 248 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a 152 249 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 153 250 sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
+5 -4
ios/Runner/Info.plist
··· 2 2 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 3 <plist version="1.0"> 4 4 <dict> 5 - <key>BGTaskSchedulerPermittedIdentifiers</key> 6 - <array> 7 - <string>lazurite.scheduled_post</string> 8 - </array> 5 + <key>BGTaskSchedulerPermittedIdentifiers</key> 6 + <array> 7 + <string>lazurite.scheduled_post</string> 8 + <string>lazurite.notification_reconcile</string> 9 + </array> 9 10 <key>CADisableMinimumFrameDurationOnPhone</key> 10 11 <true/> 11 12 <key>CFBundleDevelopmentRegion</key>
+4
lib/core/network/app_view_request_context.dart
··· 12 12 13 13 String publicServiceHost() => _routerForCurrentProvider().provider.publicBaseUrl.host; 14 14 15 + String notificationServiceDid() => _routerForCurrentProvider().provider.serviceDid.split('#').first; 16 + 17 + String notificationProxyServiceDid() => '${notificationServiceDid()}#bsky_notif'; 18 + 15 19 Map<String, String> appBskyHeaders([Map<String, String>? baseHeaders]) { 16 20 final merged = <String, String>{...?baseHeaders}; 17 21 merged.addAll(_routerForCurrentProvider().appBskyProxyHeaders());
+17 -9
lib/core/scheduler/post_scheduler.dart
··· 11 11 import 'package:lazurite/core/network/xrpc_client_factory.dart'; 12 12 import 'package:lazurite/features/auth/data/auth_repository.dart'; 13 13 import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; 14 + import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; 14 15 import 'package:workmanager/workmanager.dart'; 15 16 16 17 const _kTaskName = 'lazurite.scheduled_post'; ··· 23 24 @pragma('vm:entry-point') 24 25 void callbackDispatcher() { 25 26 Workmanager().executeTask((taskName, inputData) async { 26 - if (taskName != _kTaskName) return Future.value(true); 27 + if (taskName == _kTaskName) { 28 + final draftId = inputData?['draftId'] as int?; 29 + if (draftId == null) return Future.value(false); 27 30 28 - final draftId = inputData?['draftId'] as int?; 29 - if (draftId == null) return Future.value(false); 31 + try { 32 + await _submitScheduledDraft(draftId); 33 + return Future.value(true); 34 + } catch (e, stackTrace) { 35 + log.e('Scheduled post failed for draft $draftId', error: e, stackTrace: stackTrace); 36 + return Future.value(false); 37 + } 38 + } 30 39 31 - try { 32 - await _submitScheduledDraft(draftId); 33 - return Future.value(true); 34 - } catch (e, stackTrace) { 35 - log.e('Scheduled post failed for draft $draftId', error: e, stackTrace: stackTrace); 36 - return Future.value(false); 40 + final notificationTaskResult = await handleNotificationWorkmanagerTask(taskName, inputData); 41 + if (notificationTaskResult != null) { 42 + return Future.value(notificationTaskResult); 37 43 } 44 + 45 + return Future.value(true); 38 46 }); 39 47 } 40 48
+194
lib/features/notifications/background/notification_background_worker.dart
··· 1 + import 'dart:async'; 2 + import 'dart:io'; 3 + 4 + import 'package:firebase_messaging/firebase_messaging.dart'; 5 + import 'package:flutter/services.dart'; 6 + import 'package:flutter/widgets.dart'; 7 + import 'package:lazurite/core/database/app_database.dart'; 8 + import 'package:lazurite/core/logging/app_logger.dart'; 9 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 10 + import 'package:lazurite/features/auth/data/auth_repository.dart'; 11 + import 'package:lazurite/features/moderation/data/moderation_service.dart'; 12 + import 'package:lazurite/features/notifications/data/flutter_local_notification_adapter.dart'; 13 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 14 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 15 + import 'package:workmanager/workmanager.dart'; 16 + 17 + const notificationReconcileTaskName = 'lazurite.notification_reconcile'; 18 + const notificationReconcileUniqueName = 'notification_reconcile_periodic'; 19 + 20 + @pragma('vm:entry-point') 21 + Future<void> notificationPushPayloadEntrypoint(Map<String, dynamic> payload) async { 22 + WidgetsFlutterBinding.ensureInitialized(); 23 + 24 + final context = await _BackgroundNotificationContext.create(); 25 + if (context == null) { 26 + return; 27 + } 28 + 29 + try { 30 + await runNotificationPushPayloadTask( 31 + payload: _coercePayload(payload), 32 + processPayload: context.domainService.onPushPayload, 33 + ); 34 + } catch (error, stackTrace) { 35 + log.w('Background push payload processing failed', error: error, stackTrace: stackTrace); 36 + } finally { 37 + await context.dispose(); 38 + } 39 + } 40 + 41 + @pragma('vm:entry-point') 42 + Future<void> notificationFirebaseMessagingBackgroundHandler(RemoteMessage message) async { 43 + await notificationPushPayloadEntrypoint(message.data); 44 + } 45 + 46 + Future<bool?> handleNotificationWorkmanagerTask(String taskName, Map<String, dynamic>? inputData) async { 47 + if (taskName != notificationReconcileTaskName && taskName != Workmanager.iOSBackgroundTask) { 48 + return null; 49 + } 50 + 51 + final context = await _BackgroundNotificationContext.create(); 52 + if (context == null) { 53 + return true; 54 + } 55 + 56 + try { 57 + return await runNotificationReconcileTask(reconcile: context.domainService.onBackgroundTick); 58 + } catch (error, stackTrace) { 59 + log.w('Background notification reconcile failed', error: error, stackTrace: stackTrace); 60 + return false; 61 + } finally { 62 + await context.dispose(); 63 + } 64 + } 65 + 66 + class NotificationBackgroundScheduler { 67 + NotificationBackgroundScheduler._(); 68 + 69 + static Future<void> ensureScheduled() async { 70 + if (Platform.isAndroid) { 71 + await Workmanager().registerPeriodicTask( 72 + notificationReconcileUniqueName, 73 + notificationReconcileTaskName, 74 + frequency: const Duration(minutes: 15), 75 + existingWorkPolicy: ExistingWorkPolicy.keep, 76 + constraints: Constraints(networkType: NetworkType.connected), 77 + ); 78 + return; 79 + } 80 + 81 + if (!Platform.isIOS) { 82 + return; 83 + } 84 + 85 + // iOS fetch/BGTask execution is system-managed. Workmanager's 86 + // `registerPeriodicTask` channel method is Android-specific, so avoid 87 + // calling it on iOS. 88 + try { 89 + await Workmanager().registerOneOffTask( 90 + notificationReconcileUniqueName, 91 + notificationReconcileTaskName, 92 + initialDelay: const Duration(minutes: 15), 93 + existingWorkPolicy: ExistingWorkPolicy.replace, 94 + constraints: Constraints(networkType: NetworkType.connected), 95 + ); 96 + } on PlatformException catch (error, stackTrace) { 97 + log.w('Unable to schedule iOS notification reconcile one-off task', error: error, stackTrace: stackTrace); 98 + } 99 + } 100 + } 101 + 102 + Future<bool> runNotificationReconcileTask({required Future<int> Function({int limit}) reconcile}) async { 103 + try { 104 + await reconcile(); 105 + return true; 106 + } catch (error, stackTrace) { 107 + log.w('Notification reconcile task failed', error: error, stackTrace: stackTrace); 108 + return false; 109 + } 110 + } 111 + 112 + Future<NotificationPushProcessingOutcome> runNotificationPushPayloadTask({ 113 + required Map<String, String> payload, 114 + required Future<NotificationPushProcessingOutcome> Function(Map<String, String>) processPayload, 115 + }) async { 116 + return processPayload(payload); 117 + } 118 + 119 + Map<String, String> _coercePayload(Map<String, dynamic> payload) { 120 + final coerced = <String, String>{}; 121 + for (final entry in payload.entries) { 122 + final value = entry.value; 123 + if (value is String) { 124 + coerced[entry.key] = value; 125 + } 126 + } 127 + return coerced; 128 + } 129 + 130 + class _BackgroundNotificationContext { 131 + _BackgroundNotificationContext({ 132 + required this.database, 133 + required this.moderationService, 134 + required this.domainService, 135 + }); 136 + 137 + final AppDatabase database; 138 + final ModerationService moderationService; 139 + final NotificationDomainService domainService; 140 + 141 + static Future<_BackgroundNotificationContext?> create() async { 142 + final database = AppDatabase(); 143 + final authRepository = AuthRepository(database: database); 144 + 145 + try { 146 + final tokens = await authRepository.restoreSession(); 147 + if (tokens == null) { 148 + await database.close(); 149 + return null; 150 + } 151 + 152 + final bluesky = createBlueskyClient(tokens); 153 + if (bluesky == null) { 154 + await database.close(); 155 + return null; 156 + } 157 + 158 + final moderationService = ModerationService( 159 + bluesky: bluesky, 160 + database: database, 161 + accountDid: tokens.did, 162 + userDid: tokens.did, 163 + ); 164 + await moderationService.ensureInitialized(); 165 + 166 + final localNotificationAdapter = FlutterLocalNotificationAdapter(); 167 + await localNotificationAdapter.initialize(onTap: (_) {}); 168 + 169 + final notificationRepository = NotificationRepository(bluesky: bluesky, moderationService: moderationService); 170 + 171 + final domainService = NotificationDomainService( 172 + notificationRepository: notificationRepository, 173 + database: database, 174 + accountDid: tokens.did, 175 + localNotificationAdapter: localNotificationAdapter, 176 + ); 177 + 178 + return _BackgroundNotificationContext( 179 + database: database, 180 + moderationService: moderationService, 181 + domainService: domainService, 182 + ); 183 + } catch (error, stackTrace) { 184 + await database.close(); 185 + log.w('Failed to create notification background context', error: error, stackTrace: stackTrace); 186 + return null; 187 + } 188 + } 189 + 190 + Future<void> dispose() async { 191 + moderationService.dispose(); 192 + await database.close(); 193 + } 194 + }
+82
lib/features/notifications/data/firebase_push_token_provider.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:firebase_core/firebase_core.dart'; 4 + import 'package:firebase_messaging/firebase_messaging.dart'; 5 + import 'package:lazurite/core/logging/app_logger.dart'; 6 + import 'package:lazurite/features/notifications/domain/push_token_provider.dart'; 7 + 8 + class FirebasePushTokenProvider implements PushTokenProvider { 9 + FirebasePushTokenProvider({FirebaseMessaging? messaging}) : _messaging = messaging; 10 + 11 + FirebaseMessaging? _messaging; 12 + final _refreshController = StreamController<String>.broadcast(); 13 + StreamSubscription<String>? _refreshSubscription; 14 + var _initialized = false; 15 + 16 + @override 17 + Stream<String> get onTokenRefresh => _refreshController.stream; 18 + 19 + @override 20 + Future<void> initialize() async { 21 + if (_initialized) { 22 + return; 23 + } 24 + 25 + try { 26 + if (Firebase.apps.isEmpty) { 27 + await Firebase.initializeApp(); 28 + } 29 + 30 + _messaging ??= FirebaseMessaging.instance; 31 + final messaging = _messaging!; 32 + 33 + await messaging.setAutoInitEnabled(true); 34 + 35 + _refreshSubscription = messaging.onTokenRefresh.listen( 36 + (token) { 37 + if (token.trim().isEmpty) { 38 + return; 39 + } 40 + _refreshController.add(token); 41 + }, 42 + onError: (Object error, StackTrace stackTrace) { 43 + log.w('Push token refresh listener failed', error: error, stackTrace: stackTrace); 44 + }, 45 + ); 46 + 47 + _initialized = true; 48 + } catch (error, stackTrace) { 49 + log.w( 50 + 'Push token provider initialization failed; push registration is disabled until Firebase is configured', 51 + error: error, 52 + stackTrace: stackTrace, 53 + ); 54 + } 55 + } 56 + 57 + @override 58 + Future<String?> getToken() async { 59 + await initialize(); 60 + if (!_initialized) { 61 + return null; 62 + } 63 + 64 + try { 65 + final token = await _messaging!.getToken(); 66 + final trimmed = token?.trim(); 67 + if (trimmed == null || trimmed.isEmpty) { 68 + return null; 69 + } 70 + return trimmed; 71 + } catch (error, stackTrace) { 72 + log.w('Failed to acquire push token', error: error, stackTrace: stackTrace); 73 + return null; 74 + } 75 + } 76 + 77 + @override 78 + Future<void> dispose() async { 79 + await _refreshSubscription?.cancel(); 80 + await _refreshController.close(); 81 + } 82 + }
+139
lib/features/notifications/data/notification_repository.dart
··· 1 1 import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 2 + import 'package:bluesky/app_bsky_notification_registerpush.dart'; 3 + import 'package:bluesky/app_bsky_notification_unregisterpush.dart'; 2 4 import 'package:bluesky/bluesky.dart'; 3 5 import 'package:lazurite/core/network/app_view_request_context.dart'; 4 6 import 'package:lazurite/features/moderation/data/moderation_service.dart'; ··· 57 59 ); 58 60 } 59 61 62 + Future<void> registerPush({ 63 + required String token, 64 + required String appId, 65 + required NotificationPushPlatform platform, 66 + bool? ageRestricted, 67 + }) async { 68 + final serviceDid = _appViewContext.notificationServiceDid(); 69 + final headers = _notificationPushHeaders(await _moderationService?.headersForRequest()); 70 + await _bluesky.notification.registerPush( 71 + serviceDid: serviceDid, 72 + token: token, 73 + platform: _registerPlatformFor(platform), 74 + appId: appId, 75 + ageRestricted: ageRestricted, 76 + $headers: headers, 77 + ); 78 + } 79 + 80 + Future<void> unregisterPush({ 81 + required String token, 82 + required String appId, 83 + required NotificationPushPlatform platform, 84 + }) async { 85 + final serviceDid = _appViewContext.notificationServiceDid(); 86 + final headers = _notificationPushHeaders(await _moderationService?.headersForRequest()); 87 + await _bluesky.notification.unregisterPush( 88 + serviceDid: serviceDid, 89 + token: token, 90 + platform: _unregisterPlatformFor(platform), 91 + appId: appId, 92 + $headers: headers, 93 + ); 94 + } 95 + 96 + Future<Notification?> findNotificationByRecordUri({ 97 + required String recordUri, 98 + String? senderDid, 99 + String? reason, 100 + int maxPages = 3, 101 + int limit = 50, 102 + }) async { 103 + if (recordUri.trim().isEmpty) { 104 + return null; 105 + } 106 + 107 + var cursor = ''; 108 + for (var page = 0; page < maxPages; page++) { 109 + final response = await _bluesky.notification.listNotifications( 110 + cursor: cursor.isEmpty ? null : cursor, 111 + limit: limit, 112 + $headers: _appViewContext.appBskyHeadersForEndpoint( 113 + 'app.bsky.notification.listNotifications', 114 + await _moderationService?.headersForRequest(), 115 + ), 116 + ); 117 + 118 + final notifications = _filterNotifications(response.data.notifications); 119 + for (final notification in notifications) { 120 + if (!_matchesRecordUri(notification, recordUri)) { 121 + continue; 122 + } 123 + if (senderDid != null && senderDid.trim().isNotEmpty && notification.author.did != senderDid) { 124 + continue; 125 + } 126 + if (reason != null && reason.trim().isNotEmpty && _reasonName(notification) != reason) { 127 + continue; 128 + } 129 + return notification; 130 + } 131 + 132 + final nextCursor = response.data.cursor; 133 + if (nextCursor == null || nextCursor.trim().isEmpty) { 134 + return null; 135 + } 136 + cursor = nextCursor; 137 + } 138 + 139 + return null; 140 + } 141 + 60 142 List<Notification> _filterNotifications(List<Notification> notifications) { 61 143 final moderationService = _moderationService; 62 144 if (moderationService == null) { ··· 67 149 .where((notification) => !moderationService.shouldFilterNotificationInList(notification)) 68 150 .toList(); 69 151 } 152 + 153 + Map<String, String> _notificationPushHeaders(Map<String, String>? baseHeaders) { 154 + final headers = _appViewContext.appBskyHeadersWithoutProxy(baseHeaders); 155 + headers['atproto-proxy'] = _appViewContext.notificationProxyServiceDid(); 156 + return headers; 157 + } 158 + 159 + NotificationRegisterPushPlatform _registerPlatformFor(NotificationPushPlatform platform) { 160 + return switch (platform) { 161 + NotificationPushPlatform.android => const NotificationRegisterPushPlatform.knownValue( 162 + data: KnownNotificationRegisterPushPlatform.android, 163 + ), 164 + NotificationPushPlatform.ios => const NotificationRegisterPushPlatform.knownValue( 165 + data: KnownNotificationRegisterPushPlatform.ios, 166 + ), 167 + }; 168 + } 169 + 170 + NotificationUnregisterPushPlatform _unregisterPlatformFor(NotificationPushPlatform platform) { 171 + return switch (platform) { 172 + NotificationPushPlatform.android => const NotificationUnregisterPushPlatform.knownValue( 173 + data: KnownNotificationUnregisterPushPlatform.android, 174 + ), 175 + NotificationPushPlatform.ios => const NotificationUnregisterPushPlatform.knownValue( 176 + data: KnownNotificationUnregisterPushPlatform.ios, 177 + ), 178 + }; 179 + } 180 + 181 + bool _matchesRecordUri(Notification notification, String recordUri) { 182 + final notificationUri = notification.uri.toString(); 183 + final reasonSubjectUri = notification.reasonSubject?.toString(); 184 + if (notificationUri == recordUri || reasonSubjectUri == recordUri) { 185 + return true; 186 + } 187 + 188 + final embeddedUri = notification.record['uri']; 189 + if (embeddedUri is String && embeddedUri == recordUri) { 190 + return true; 191 + } 192 + 193 + return false; 194 + } 195 + 196 + String _reasonName(Notification notification) { 197 + final known = notification.reason.knownValue; 198 + if (known != null) { 199 + return known.value; 200 + } 201 + final unknown = notification.reason.unknown; 202 + if (unknown != null) { 203 + return unknown; 204 + } 205 + return 'unknown'; 206 + } 70 207 } 71 208 72 209 class NotificationListResult { ··· 76 213 final String? cursor; 77 214 final DateTime? seenAt; 78 215 } 216 + 217 + enum NotificationPushPlatform { android, ios }
+158
lib/features/notifications/domain/notification_domain_service.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 1 4 import 'package:bluesky/app_bsky_notification_listnotifications.dart'; 2 5 import 'package:lazurite/core/database/app_database.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 3 7 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 4 8 import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 5 9 import 'package:lazurite/features/notifications/domain/notification_local_mappers.dart'; ··· 61 65 62 66 Future<void> markSeen() => _notificationRepository.updateSeen(); 63 67 68 + Future<NotificationPushProcessingOutcome> onPushPayload( 69 + Map<String, String> payload, { 70 + Duration timeout = const Duration(seconds: 10), 71 + }) async { 72 + final timedResult = await _onPushPayloadInternal(payload).timeout( 73 + timeout, 74 + onTimeout: () async { 75 + await _recordPushDrop('timeout'); 76 + return NotificationPushProcessingOutcome.droppedTimeout; 77 + }, 78 + ); 79 + 80 + if (timedResult == NotificationPushProcessingOutcome.processed) { 81 + await _incrementCounter(_pushProcessedCountKey); 82 + } 83 + 84 + return timedResult; 85 + } 86 + 87 + Future<NotificationPushProcessingOutcome> _onPushPayloadInternal(Map<String, String> payload) async { 88 + final parsedPayload = NotificationPushPayload.tryParse(payload); 89 + if (parsedPayload == null) { 90 + await _recordPushDrop('invalid_payload'); 91 + return NotificationPushProcessingOutcome.droppedInvalidPayload; 92 + } 93 + 94 + final accountDid = _accountDid; 95 + if (accountDid != null && parsedPayload.targetDid != accountDid) { 96 + await _recordPushDrop('target_mismatch'); 97 + return NotificationPushProcessingOutcome.droppedTargetMismatch; 98 + } 99 + 100 + final canonical = await _notificationRepository.findNotificationByRecordUri( 101 + recordUri: parsedPayload.recordUri, 102 + senderDid: parsedPayload.senderDid, 103 + reason: parsedPayload.reason, 104 + ); 105 + if (canonical == null) { 106 + await _recordPushDrop('not_found'); 107 + return NotificationPushProcessingOutcome.droppedNotFound; 108 + } 109 + 110 + if (canonical.isRead) { 111 + await _recordPushDrop('already_read'); 112 + return NotificationPushProcessingOutcome.droppedAlreadyRead; 113 + } 114 + 115 + final insertedCount = await persistNotificationDeliveries([canonical], source: NotificationDeliverySource.push); 116 + if (insertedCount == 0) { 117 + await _recordPushDrop('duplicate'); 118 + return NotificationPushProcessingOutcome.droppedDuplicate; 119 + } 120 + 121 + if (_shouldSuppressLocalNotifications?.call() ?? false) { 122 + return NotificationPushProcessingOutcome.processed; 123 + } 124 + 125 + final request = NotificationLocalMapper.requestFromNotification(canonical); 126 + if (request == null) { 127 + await _recordPushDrop('unmappable'); 128 + return NotificationPushProcessingOutcome.droppedUnmappable; 129 + } 130 + 131 + try { 132 + await _localNotificationAdapter?.show(request); 133 + return NotificationPushProcessingOutcome.processed; 134 + } catch (error, stackTrace) { 135 + log.w('Failed to display local notification for push payload', error: error, stackTrace: stackTrace); 136 + await _recordPushDrop('display_error'); 137 + return NotificationPushProcessingOutcome.droppedDisplayError; 138 + } 139 + } 140 + 141 + Future<int> onBackgroundTick({int limit = 50}) async { 142 + final result = await listNotifications(limit: limit, source: NotificationDeliverySource.poll); 143 + return result.notifications.length; 144 + } 145 + 64 146 Future<int> persistNotificationDeliveries( 65 147 Iterable<Notification> notifications, { 66 148 NotificationDeliverySource source = NotificationDeliverySource.poll, ··· 98 180 } 99 181 return 'unknown'; 100 182 } 183 + 184 + Future<void> _recordPushDrop(String reason) async { 185 + await _incrementCounter(_pushDroppedCountKey); 186 + await _incrementCounter('$_pushDroppedReasonPrefix$reason'); 187 + } 188 + 189 + Future<void> _incrementCounter(String key) async { 190 + final database = _database; 191 + if (database == null) { 192 + return; 193 + } 194 + 195 + final currentValue = int.tryParse(await database.getSetting(key) ?? '') ?? 0; 196 + await database.setSetting(key, '${currentValue + 1}'); 197 + } 101 198 } 102 199 103 200 enum NotificationDeliverySource { ··· 108 205 109 206 final String value; 110 207 } 208 + 209 + enum NotificationPushProcessingOutcome { 210 + processed, 211 + droppedInvalidPayload, 212 + droppedTargetMismatch, 213 + droppedNotFound, 214 + droppedAlreadyRead, 215 + droppedDuplicate, 216 + droppedUnmappable, 217 + droppedDisplayError, 218 + droppedTimeout, 219 + } 220 + 221 + class NotificationPushPayload { 222 + NotificationPushPayload({ 223 + required this.senderDid, 224 + required this.targetDid, 225 + required this.recordUri, 226 + required this.reason, 227 + }); 228 + 229 + final String senderDid; 230 + final String targetDid; 231 + final String recordUri; 232 + final String reason; 233 + 234 + static NotificationPushPayload? tryParse(Map<String, String> payload) { 235 + final senderDid = payload['senderDid']?.trim(); 236 + final targetDid = payload['targetDid']?.trim(); 237 + final recordUri = payload['recordUri']?.trim(); 238 + final reason = payload['reason']?.trim(); 239 + 240 + if (senderDid == null || senderDid.isEmpty || !senderDid.startsWith('did:')) { 241 + return null; 242 + } 243 + if (targetDid == null || targetDid.isEmpty || !targetDid.startsWith('did:')) { 244 + return null; 245 + } 246 + if (recordUri == null || recordUri.isEmpty || !_isAtUri(recordUri)) { 247 + return null; 248 + } 249 + if (reason == null || reason.isEmpty) { 250 + return null; 251 + } 252 + 253 + return NotificationPushPayload(senderDid: senderDid, targetDid: targetDid, recordUri: recordUri, reason: reason); 254 + } 255 + 256 + static bool _isAtUri(String value) { 257 + try { 258 + final atUri = AtUri.parse(value); 259 + return atUri.toString().isNotEmpty; 260 + } catch (_) { 261 + return false; 262 + } 263 + } 264 + } 265 + 266 + const _pushProcessedCountKey = 'notification_push_processed_count'; 267 + const _pushDroppedCountKey = 'notification_push_dropped_count'; 268 + const _pushDroppedReasonPrefix = 'notification_push_dropped_reason_';
+228
lib/features/notifications/domain/push_registration_service.dart
··· 1 + import 'dart:async'; 2 + import 'dart:io'; 3 + 4 + import 'package:lazurite/core/logging/app_logger.dart'; 5 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 7 + import 'package:lazurite/features/notifications/domain/push_token_provider.dart'; 8 + 9 + typedef NotificationRepositoryFactory = NotificationRepository Function(AuthTokens tokens); 10 + typedef DelayFn = Future<void> Function(Duration delay); 11 + 12 + class PushRegistrationService { 13 + PushRegistrationService({ 14 + required PushTokenProvider tokenProvider, 15 + required NotificationRepositoryFactory notificationRepositoryFactory, 16 + this.appId = 'org.stormlightlabs.lazurite', 17 + Duration initialBackoff = const Duration(seconds: 1), 18 + int maxAttempts = 4, 19 + DelayFn delayFn = Future.delayed, 20 + bool Function()? isPushPlatformSupported, 21 + }) : _tokenProvider = tokenProvider, 22 + _notificationRepositoryFactory = notificationRepositoryFactory, 23 + _initialBackoff = initialBackoff, 24 + _maxAttempts = maxAttempts, 25 + _delayFn = delayFn, 26 + _isPushPlatformSupported = isPushPlatformSupported ?? (() => Platform.isAndroid || Platform.isIOS); 27 + 28 + final PushTokenProvider _tokenProvider; 29 + final NotificationRepositoryFactory _notificationRepositoryFactory; 30 + final String appId; 31 + final Duration _initialBackoff; 32 + final int _maxAttempts; 33 + final DelayFn _delayFn; 34 + final bool Function() _isPushPlatformSupported; 35 + 36 + StreamSubscription<String>? _tokenRefreshSubscription; 37 + AuthTokens? _activeTokens; 38 + String? _registeredDid; 39 + String? _registeredToken; 40 + var _started = false; 41 + 42 + bool get _supportsPushPlatform => _isPushPlatformSupported(); 43 + 44 + Future<void> start({required AuthTokens? initialTokens}) async { 45 + if (_started || !_supportsPushPlatform) { 46 + _activeTokens = initialTokens; 47 + return; 48 + } 49 + 50 + _started = true; 51 + _activeTokens = initialTokens; 52 + 53 + await _tokenProvider.initialize(); 54 + _tokenRefreshSubscription = _tokenProvider.onTokenRefresh.listen( 55 + (token) { 56 + unawaited(_handleTokenRefresh(token)); 57 + }, 58 + onError: (Object error, StackTrace stackTrace) { 59 + log.w('Push token refresh stream failed', error: error, stackTrace: stackTrace); 60 + }, 61 + ); 62 + 63 + try { 64 + await _syncCurrentSession(); 65 + } catch (error, stackTrace) { 66 + log.w('Initial push registration sync failed', error: error, stackTrace: stackTrace); 67 + } 68 + } 69 + 70 + Future<void> updateSession(AuthTokens? tokens) async { 71 + if (!_supportsPushPlatform) { 72 + _activeTokens = tokens; 73 + return; 74 + } 75 + 76 + final previousTokens = _activeTokens; 77 + _activeTokens = tokens; 78 + 79 + if (previousTokens != null && (tokens == null || tokens.did != previousTokens.did)) { 80 + try { 81 + await _unregisterWithCurrentToken(previousTokens); 82 + } catch (error, stackTrace) { 83 + log.w('Push unregistration during session transition failed', error: error, stackTrace: stackTrace); 84 + } 85 + } 86 + 87 + if (tokens == null) { 88 + return; 89 + } 90 + 91 + try { 92 + await _syncCurrentSession(); 93 + } catch (error, stackTrace) { 94 + log.w('Push registration sync failed', error: error, stackTrace: stackTrace); 95 + } 96 + } 97 + 98 + Future<void> _handleTokenRefresh(String token) async { 99 + final trimmed = token.trim(); 100 + if (trimmed.isEmpty) { 101 + return; 102 + } 103 + 104 + final tokens = _activeTokens; 105 + if (tokens == null) { 106 + return; 107 + } 108 + 109 + final previousToken = _registeredToken; 110 + final previousDid = _registeredDid; 111 + _registeredToken = null; 112 + 113 + if (previousToken != null && previousDid == tokens.did && previousToken != trimmed) { 114 + try { 115 + await _retry( 116 + operation: 'push unregister refresh', 117 + action: () async { 118 + await _notificationRepositoryFactory( 119 + tokens, 120 + ).unregisterPush(token: previousToken, appId: appId, platform: _platform); 121 + }, 122 + ); 123 + } catch (error, stackTrace) { 124 + log.w('Push refresh unregistration failed', error: error, stackTrace: stackTrace); 125 + } 126 + } 127 + 128 + try { 129 + await _register(tokens: tokens, token: trimmed); 130 + } catch (error, stackTrace) { 131 + log.w('Push refresh registration failed', error: error, stackTrace: stackTrace); 132 + } 133 + } 134 + 135 + Future<void> _syncCurrentSession() async { 136 + final tokens = _activeTokens; 137 + if (tokens == null) { 138 + return; 139 + } 140 + 141 + final token = await _tokenProvider.getToken(); 142 + if (token == null || token.trim().isEmpty) { 143 + log.w('Push token unavailable; skipping push registration for ${tokens.did}'); 144 + return; 145 + } 146 + 147 + if (_registeredDid == tokens.did && _registeredToken == token) { 148 + return; 149 + } 150 + 151 + await _register(tokens: tokens, token: token); 152 + } 153 + 154 + Future<void> _register({required AuthTokens tokens, required String token}) async { 155 + await _retry( 156 + operation: 'push register', 157 + action: () async { 158 + await _notificationRepositoryFactory(tokens).registerPush(token: token, appId: appId, platform: _platform); 159 + }, 160 + ); 161 + 162 + _registeredDid = tokens.did; 163 + _registeredToken = token; 164 + } 165 + 166 + Future<void> _unregisterWithCurrentToken(AuthTokens tokens) async { 167 + final registeredToken = _registeredToken; 168 + if (registeredToken == null || _registeredDid != tokens.did) { 169 + return; 170 + } 171 + 172 + await _retry( 173 + operation: 'push unregister', 174 + action: () async { 175 + await _notificationRepositoryFactory( 176 + tokens, 177 + ).unregisterPush(token: registeredToken, appId: appId, platform: _platform); 178 + }, 179 + ); 180 + 181 + _registeredDid = null; 182 + _registeredToken = null; 183 + } 184 + 185 + Future<void> _retry({required String operation, required Future<void> Function() action}) async { 186 + var backoff = Duration.zero; 187 + Object? lastError; 188 + StackTrace? lastStackTrace; 189 + 190 + for (var attempt = 1; attempt <= _maxAttempts; attempt++) { 191 + if (backoff > Duration.zero) { 192 + await _delayFn(backoff); 193 + } 194 + 195 + try { 196 + await action(); 197 + return; 198 + } catch (error, stackTrace) { 199 + lastError = error; 200 + lastStackTrace = stackTrace; 201 + 202 + if (attempt >= _maxAttempts) { 203 + log.e('Push lifecycle operation failed: $operation', error: error, stackTrace: stackTrace); 204 + Error.throwWithStackTrace(error, stackTrace); 205 + } 206 + 207 + backoff = backoff == Duration.zero ? _initialBackoff : backoff * 2; 208 + log.w( 209 + 'Push lifecycle operation retrying: $operation attempt=$attempt/${_maxAttempts - 1}', 210 + error: error, 211 + stackTrace: stackTrace, 212 + ); 213 + } 214 + } 215 + 216 + if (lastError != null) { 217 + Error.throwWithStackTrace(lastError, lastStackTrace ?? StackTrace.current); 218 + } 219 + } 220 + 221 + NotificationPushPlatform get _platform => 222 + Platform.isIOS ? NotificationPushPlatform.ios : NotificationPushPlatform.android; 223 + 224 + Future<void> dispose() async { 225 + await _tokenRefreshSubscription?.cancel(); 226 + await _tokenProvider.dispose(); 227 + } 228 + }
+9
lib/features/notifications/domain/push_token_provider.dart
··· 1 + abstract class PushTokenProvider { 2 + Future<void> initialize(); 3 + 4 + Future<String?> getToken(); 5 + 6 + Stream<String> get onTokenRefresh; 7 + 8 + Future<void> dispose(); 9 + }
+38
lib/main.dart
··· 2 2 3 3 import 'package:bluesky/bluesky.dart'; 4 4 import 'package:bluesky/bluesky_chat.dart'; 5 + import 'package:firebase_messaging/firebase_messaging.dart'; 5 6 import 'package:flutter/material.dart'; 6 7 import 'package:flutter_bloc/flutter_bloc.dart'; 7 8 import 'package:go_router/go_router.dart'; ··· 23 24 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 24 25 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 25 26 import 'package:lazurite/features/auth/data/auth_repository.dart'; 27 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 26 28 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 27 29 import 'package:lazurite/features/connectivity/presentation/connectivity_banner_host.dart'; 28 30 import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; ··· 41 43 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 42 44 import 'package:lazurite/features/notifications/data/notification_repository.dart'; 43 45 import 'package:lazurite/features/notifications/data/flutter_local_notification_adapter.dart'; 46 + import 'package:lazurite/features/notifications/data/firebase_push_token_provider.dart'; 44 47 import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 45 48 import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; 46 49 import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 47 50 import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 51 + import 'package:lazurite/features/notifications/domain/push_registration_service.dart'; 52 + import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; 48 53 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 49 54 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 50 55 import 'package:lazurite/features/profile/data/profile_repository.dart'; ··· 68 73 69 74 await log.initialize(); 70 75 await PostScheduler.initialize(); 76 + FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); 77 + await NotificationBackgroundScheduler.ensureScheduled(); 71 78 Bloc.observer = LoggingBlocObserver(); 72 79 73 80 final database = AppDatabase(); ··· 102 109 final accountSwitcherCubit = AccountSwitcherCubit(database: database, authRepository: authRepository); 103 110 await accountSwitcherCubit.loadAccounts(); 104 111 final localNotificationAdapter = FlutterLocalNotificationAdapter(); 112 + final pushTokenProvider = FirebasePushTokenProvider(); 113 + final pushRegistrationService = PushRegistrationService( 114 + tokenProvider: pushTokenProvider, 115 + notificationRepositoryFactory: (tokens) { 116 + final bluesky = createBlueskyClient(tokens); 117 + if (bluesky == null) { 118 + throw StateError('Unable to create Bluesky client for push registration'); 119 + } 120 + return NotificationRepository( 121 + bluesky: bluesky, 122 + appViewProviderResolver: () => settingsCubit.state.appViewProvider, 123 + ); 124 + }, 125 + ); 105 126 106 127 log.i('AppLogger: App started'); 107 128 ··· 116 137 connectivityCubit, 117 138 accountSwitcherCubit, 118 139 localNotificationAdapter, 140 + pushRegistrationService, 119 141 ), 120 142 ); 121 143 } ··· 132 154 required this.connectivityCubit, 133 155 required this.accountSwitcherCubit, 134 156 required this.localNotificationAdapter, 157 + required this.pushRegistrationService, 135 158 }); 136 159 137 160 final AuthBloc authBloc; ··· 143 166 final ConnectivityCubit connectivityCubit; 144 167 final AccountSwitcherCubit accountSwitcherCubit; 145 168 final LocalNotificationAdapter localNotificationAdapter; 169 + final PushRegistrationService pushRegistrationService; 146 170 147 171 /// factory constructor with positional params 148 172 static LazuriteApp from( ··· 155 179 ConnectivityCubit connectivityCubit, 156 180 AccountSwitcherCubit accountSwitcherCubit, 157 181 LocalNotificationAdapter localNotificationAdapter, 182 + PushRegistrationService pushRegistrationService, 158 183 ) => LazuriteApp( 159 184 authBloc: authBloc, 160 185 database: database, ··· 165 190 connectivityCubit: connectivityCubit, 166 191 accountSwitcherCubit: accountSwitcherCubit, 167 192 localNotificationAdapter: localNotificationAdapter, 193 + pushRegistrationService: pushRegistrationService, 168 194 ); 169 195 170 196 @override ··· 176 202 late GoRouter _router; 177 203 late String _routerSessionKey; 178 204 late final StreamSubscription<String> _authSubscription; 205 + late final StreamSubscription<AuthTokens?> _pushRegistrationSubscription; 206 + StreamSubscription<RemoteMessage>? _pushForegroundMessageSubscription; 179 207 late final StreamSubscription<bool> _simulateOfflineSubscription; 180 208 late final StreamSubscription<String> _appViewProviderSubscription; 181 209 late final StreamSubscription<AppViewRoutingEvent> _appViewEventSubscription; ··· 194 222 return widget.localNotificationAdapter.requestPermissions(); 195 223 }), 196 224 ); 225 + unawaited(widget.pushRegistrationService.start(initialTokens: widget.authBloc.state.tokens)); 226 + _pushRegistrationSubscription = widget.authBloc.stream.map((state) => state.tokens).listen((tokens) { 227 + unawaited(widget.pushRegistrationService.updateSession(tokens)); 228 + }); 229 + _pushForegroundMessageSubscription = FirebaseMessaging.onMessage.listen((message) { 230 + unawaited(notificationPushPayloadEntrypoint(message.data)); 231 + }); 197 232 _authSubscription = widget.authBloc.stream.map(_sessionKeyFor).distinct().listen(_handleSessionKeyChanged); 198 233 _simulateOfflineSubscription = widget.settingsCubit.stream 199 234 .map((state) => state.simulateOffline) ··· 222 257 @override 223 258 void dispose() { 224 259 _authSubscription.cancel(); 260 + _pushRegistrationSubscription.cancel(); 261 + _pushForegroundMessageSubscription?.cancel(); 225 262 _simulateOfflineSubscription.cancel(); 226 263 _appViewProviderSubscription.cancel(); 227 264 _appViewEventSubscription.cancel(); 265 + unawaited(widget.pushRegistrationService.dispose()); 228 266 229 267 widget.connectivityCubit.close(); 230 268 widget.appViewFallbackService.dispose();
+56
pubspec.lock
··· 9 9 url: "https://pub.dev" 10 10 source: hosted 11 11 version: "93.0.0" 12 + _flutterfire_internals: 13 + dependency: transitive 14 + description: 15 + name: _flutterfire_internals 16 + sha256: bda3b7b55958bfd867addc40d067b4b11f7b8846d57671f5b5a6e7f9a56fe3ad 17 + url: "https://pub.dev" 18 + source: hosted 19 + version: "1.3.69" 12 20 analyzer: 13 21 dependency: transitive 14 22 description: ··· 489 497 url: "https://pub.dev" 490 498 source: hosted 491 499 version: "0.9.3+5" 500 + firebase_core: 501 + dependency: "direct main" 502 + description: 503 + name: firebase_core 504 + sha256: d5a94b884dcb1e6d3430298e94bfe002238094cdfd5e29202d536ee2120f9158 505 + url: "https://pub.dev" 506 + source: hosted 507 + version: "4.7.0" 508 + firebase_core_platform_interface: 509 + dependency: transitive 510 + description: 511 + name: firebase_core_platform_interface 512 + sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce" 513 + url: "https://pub.dev" 514 + source: hosted 515 + version: "6.0.3" 516 + firebase_core_web: 517 + dependency: transitive 518 + description: 519 + name: firebase_core_web 520 + sha256: dc5096257cd67292d34d78ceeb90836f02a4be921b5f3934311a02bb2376118c 521 + url: "https://pub.dev" 522 + source: hosted 523 + version: "3.6.0" 524 + firebase_messaging: 525 + dependency: "direct main" 526 + description: 527 + name: firebase_messaging 528 + sha256: e5c93e8e7a9b0513f94bb684d2cf100e32e7dcdf2949574386b1955fc9a9b96a 529 + url: "https://pub.dev" 530 + source: hosted 531 + version: "16.2.0" 532 + firebase_messaging_platform_interface: 533 + dependency: transitive 534 + description: 535 + name: firebase_messaging_platform_interface 536 + sha256: "8cbb7d842e5071bba836452aff262f7db4b14bb3a0d00c1896cf176df886d65a" 537 + url: "https://pub.dev" 538 + source: hosted 539 + version: "4.7.9" 540 + firebase_messaging_web: 541 + dependency: transitive 542 + description: 543 + name: firebase_messaging_web 544 + sha256: "8750bacf50573c0383535fc3f9c58c6a2f9dff5320a16a82c30631b9dad894f1" 545 + url: "https://pub.dev" 546 + source: hosted 547 + version: "4.1.5" 492 548 fixnum: 493 549 dependency: transitive 494 550 description:
+2
pubspec.yaml
··· 54 54 cached_network_image: ^3.4.1 55 55 flutter_cache_manager: ^3.4.1 56 56 flutter_local_notifications: ^19.4.2 57 + firebase_core: ^4.0.0 58 + firebase_messaging: ^16.0.0 57 59 58 60 dev_dependencies: 59 61 flutter_test:
+46
test/features/notifications/background/notification_background_worker_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; 3 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 4 + 5 + void main() { 6 + group('Notification background worker harness', () { 7 + test('reconcile task returns true when reconcile succeeds', () async { 8 + var called = 0; 9 + 10 + final result = await runNotificationReconcileTask( 11 + reconcile: ({int limit = 50}) async { 12 + called += 1; 13 + return 1; 14 + }, 15 + ); 16 + 17 + expect(result, isTrue); 18 + expect(called, 1); 19 + }); 20 + 21 + test('reconcile task returns false when reconcile throws', () async { 22 + final result = await runNotificationReconcileTask( 23 + reconcile: ({int limit = 50}) async { 24 + throw Exception('boom'); 25 + }, 26 + ); 27 + 28 + expect(result, isFalse); 29 + }); 30 + 31 + test('push payload task delegates to provided processor', () async { 32 + Map<String, String>? capturedPayload; 33 + 34 + final result = await runNotificationPushPayloadTask( 35 + payload: const {'senderDid': 'did:plc:sender'}, 36 + processPayload: (payload) async { 37 + capturedPayload = payload; 38 + return NotificationPushProcessingOutcome.processed; 39 + }, 40 + ); 41 + 42 + expect(capturedPayload, const {'senderDid': 'did:plc:sender'}); 43 + expect(result, NotificationPushProcessingOutcome.processed); 44 + }); 45 + }); 46 + }
+179
test/features/notifications/domain/notification_push_payload_processing_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; 4 + import 'package:drift/native.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 8 + import 'package:lazurite/features/notifications/domain/local_notification_adapter.dart'; 9 + import 'package:lazurite/features/notifications/domain/notification_domain_service.dart'; 10 + import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockNotificationRepository extends Mock implements NotificationRepository {} 14 + 15 + class MockLocalNotificationAdapter extends Mock implements LocalNotificationAdapter {} 16 + 17 + class FakeLocalNotificationRequest extends Fake implements LocalNotificationRequest {} 18 + 19 + void main() { 20 + late MockNotificationRepository repository; 21 + late MockLocalNotificationAdapter localNotificationAdapter; 22 + 23 + final canonicalNotification = bsky.Notification( 24 + uri: AtUri.parse('at://did:plc:author/app.bsky.feed.post/notif'), 25 + cid: 'cid-123', 26 + author: const ProfileView(did: 'did:plc:author', handle: 'author.bsky.social'), 27 + reason: const bsky.NotificationReason.knownValue(data: bsky.KnownNotificationReason.like), 28 + reasonSubject: AtUri.parse('at://did:plc:author/app.bsky.feed.post/record-rkey'), 29 + record: const {}, 30 + isRead: false, 31 + indexedAt: DateTime.utc(2026, 5, 1, 10), 32 + ); 33 + 34 + setUpAll(() { 35 + registerFallbackValue(FakeLocalNotificationRequest()); 36 + }); 37 + 38 + setUp(() { 39 + repository = MockNotificationRepository(); 40 + localNotificationAdapter = MockLocalNotificationAdapter(); 41 + when(() => localNotificationAdapter.show(any())).thenAnswer((_) async {}); 42 + }); 43 + 44 + Map<String, String> validPayload() => { 45 + 'senderDid': 'did:plc:author', 46 + 'targetDid': 'did:plc:test', 47 + 'recordUri': 'at://did:plc:author/app.bsky.feed.post/record-rkey', 48 + 'reason': 'like', 49 + }; 50 + 51 + group('NotificationDomainService push payload processing', () { 52 + test('drops invalid payloads and increments drop counters', () async { 53 + final database = AppDatabase(executor: NativeDatabase.memory()); 54 + addTearDown(database.close); 55 + 56 + final service = NotificationDomainService( 57 + notificationRepository: repository, 58 + database: database, 59 + accountDid: 'did:plc:test', 60 + localNotificationAdapter: localNotificationAdapter, 61 + ); 62 + 63 + final result = await service.onPushPayload({'recordUri': 'bad'}); 64 + 65 + expect(result, NotificationPushProcessingOutcome.droppedInvalidPayload); 66 + expect(await database.getSetting('notification_push_dropped_count'), '1'); 67 + expect(await database.getSetting('notification_push_dropped_reason_invalid_payload'), '1'); 68 + verifyNever(() => repository.findNotificationByRecordUri(recordUri: any(named: 'recordUri'))); 69 + }); 70 + 71 + test('drops payloads that target another account did', () async { 72 + final database = AppDatabase(executor: NativeDatabase.memory()); 73 + addTearDown(database.close); 74 + 75 + final service = NotificationDomainService( 76 + notificationRepository: repository, 77 + database: database, 78 + accountDid: 'did:plc:test', 79 + localNotificationAdapter: localNotificationAdapter, 80 + ); 81 + 82 + final payload = validPayload()..['targetDid'] = 'did:plc:someone-else'; 83 + final result = await service.onPushPayload(payload); 84 + 85 + expect(result, NotificationPushProcessingOutcome.droppedTargetMismatch); 86 + verifyNever(() => repository.findNotificationByRecordUri(recordUri: any(named: 'recordUri'))); 87 + }); 88 + 89 + test('drops payload when canonical notification cannot be resolved', () async { 90 + final database = AppDatabase(executor: NativeDatabase.memory()); 91 + addTearDown(database.close); 92 + 93 + when( 94 + () => repository.findNotificationByRecordUri( 95 + recordUri: any(named: 'recordUri'), 96 + senderDid: any(named: 'senderDid'), 97 + reason: any(named: 'reason'), 98 + ), 99 + ).thenAnswer((_) async => null); 100 + 101 + final service = NotificationDomainService( 102 + notificationRepository: repository, 103 + database: database, 104 + accountDid: 'did:plc:test', 105 + localNotificationAdapter: localNotificationAdapter, 106 + ); 107 + 108 + final result = await service.onPushPayload(validPayload()); 109 + 110 + expect(result, NotificationPushProcessingOutcome.droppedNotFound); 111 + verify( 112 + () => repository.findNotificationByRecordUri( 113 + recordUri: any(named: 'recordUri'), 114 + senderDid: any(named: 'senderDid'), 115 + reason: any(named: 'reason'), 116 + maxPages: any(named: 'maxPages'), 117 + limit: any(named: 'limit'), 118 + ), 119 + ).called(1); 120 + verifyNever(() => localNotificationAdapter.show(any())); 121 + }); 122 + 123 + test('dedupes repeated payloads and only shows once', () async { 124 + final database = AppDatabase(executor: NativeDatabase.memory()); 125 + addTearDown(database.close); 126 + 127 + when( 128 + () => repository.findNotificationByRecordUri( 129 + recordUri: any(named: 'recordUri'), 130 + senderDid: any(named: 'senderDid'), 131 + reason: any(named: 'reason'), 132 + ), 133 + ).thenAnswer((_) async => canonicalNotification); 134 + 135 + final service = NotificationDomainService( 136 + notificationRepository: repository, 137 + database: database, 138 + accountDid: 'did:plc:test', 139 + localNotificationAdapter: localNotificationAdapter, 140 + ); 141 + 142 + final first = await service.onPushPayload(validPayload()); 143 + final second = await service.onPushPayload(validPayload()); 144 + 145 + expect(first, NotificationPushProcessingOutcome.processed); 146 + expect(second, NotificationPushProcessingOutcome.droppedDuplicate); 147 + verify(() => localNotificationAdapter.show(any())).called(1); 148 + expect(await database.countNotificationDeliveries('did:plc:test'), 1); 149 + }); 150 + 151 + test('drops and accounts timed out processing', () async { 152 + final database = AppDatabase(executor: NativeDatabase.memory()); 153 + addTearDown(database.close); 154 + 155 + when( 156 + () => repository.findNotificationByRecordUri( 157 + recordUri: any(named: 'recordUri'), 158 + senderDid: any(named: 'senderDid'), 159 + reason: any(named: 'reason'), 160 + ), 161 + ).thenAnswer((_) async { 162 + await Future<void>.delayed(const Duration(milliseconds: 20)); 163 + return canonicalNotification; 164 + }); 165 + 166 + final service = NotificationDomainService( 167 + notificationRepository: repository, 168 + database: database, 169 + accountDid: 'did:plc:test', 170 + localNotificationAdapter: localNotificationAdapter, 171 + ); 172 + 173 + final result = await service.onPushPayload(validPayload(), timeout: const Duration(milliseconds: 1)); 174 + 175 + expect(result, NotificationPushProcessingOutcome.droppedTimeout); 176 + expect(await database.getSetting('notification_push_dropped_reason_timeout'), '1'); 177 + }); 178 + }); 179 + }
+231
test/features/notifications/domain/push_registration_service_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 5 + import 'package:lazurite/features/notifications/data/notification_repository.dart'; 6 + import 'package:lazurite/features/notifications/domain/push_registration_service.dart'; 7 + import 'package:lazurite/features/notifications/domain/push_token_provider.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockNotificationRepository extends Mock implements NotificationRepository {} 11 + 12 + class FakePushTokenProvider implements PushTokenProvider { 13 + String? token; 14 + final controller = StreamController<String>.broadcast(); 15 + 16 + @override 17 + Future<void> dispose() async { 18 + await controller.close(); 19 + } 20 + 21 + @override 22 + Future<String?> getToken() async => token; 23 + 24 + @override 25 + Future<void> initialize() async {} 26 + 27 + @override 28 + Stream<String> get onTokenRefresh => controller.stream; 29 + } 30 + 31 + void main() { 32 + late FakePushTokenProvider tokenProvider; 33 + late MockNotificationRepository accountARepository; 34 + late MockNotificationRepository accountBRepository; 35 + 36 + const accountATokens = AuthTokens( 37 + accessToken: 'access-a', 38 + refreshToken: 'refresh-a', 39 + did: 'did:plc:account-a', 40 + handle: 'account-a.bsky.social', 41 + service: 'bsky.social', 42 + ); 43 + const accountBTokens = AuthTokens( 44 + accessToken: 'access-b', 45 + refreshToken: 'refresh-b', 46 + did: 'did:plc:account-b', 47 + handle: 'account-b.bsky.social', 48 + service: 'bsky.social', 49 + ); 50 + 51 + setUp(() { 52 + tokenProvider = FakePushTokenProvider(); 53 + accountARepository = MockNotificationRepository(); 54 + accountBRepository = MockNotificationRepository(); 55 + 56 + when( 57 + () => accountARepository.registerPush( 58 + token: any(named: 'token'), 59 + appId: any(named: 'appId'), 60 + platform: any(named: 'platform'), 61 + ageRestricted: any(named: 'ageRestricted'), 62 + ), 63 + ).thenAnswer((_) async {}); 64 + when( 65 + () => accountARepository.unregisterPush( 66 + token: any(named: 'token'), 67 + appId: any(named: 'appId'), 68 + platform: any(named: 'platform'), 69 + ), 70 + ).thenAnswer((_) async {}); 71 + when( 72 + () => accountBRepository.registerPush( 73 + token: any(named: 'token'), 74 + appId: any(named: 'appId'), 75 + platform: any(named: 'platform'), 76 + ageRestricted: any(named: 'ageRestricted'), 77 + ), 78 + ).thenAnswer((_) async {}); 79 + when( 80 + () => accountBRepository.unregisterPush( 81 + token: any(named: 'token'), 82 + appId: any(named: 'appId'), 83 + platform: any(named: 'platform'), 84 + ), 85 + ).thenAnswer((_) async {}); 86 + }); 87 + 88 + setUpAll(() { 89 + registerFallbackValue(NotificationPushPlatform.android); 90 + }); 91 + 92 + NotificationRepository repositoryFactory(AuthTokens tokens) { 93 + if (tokens.did == accountATokens.did) { 94 + return accountARepository; 95 + } 96 + return accountBRepository; 97 + } 98 + 99 + PushRegistrationService buildService({ 100 + int maxAttempts = 4, 101 + Duration initialBackoff = const Duration(milliseconds: 1), 102 + }) { 103 + return PushRegistrationService( 104 + tokenProvider: tokenProvider, 105 + notificationRepositoryFactory: repositoryFactory, 106 + maxAttempts: maxAttempts, 107 + initialBackoff: initialBackoff, 108 + delayFn: (_) async {}, 109 + isPushPlatformSupported: () => true, 110 + ); 111 + } 112 + 113 + group('PushRegistrationService', () { 114 + test('registers push token on startup for authenticated account', () async { 115 + tokenProvider.token = 'token-a'; 116 + final service = buildService(); 117 + addTearDown(service.dispose); 118 + 119 + await service.start(initialTokens: accountATokens); 120 + 121 + verify( 122 + () => accountARepository.registerPush( 123 + token: 'token-a', 124 + appId: 'org.stormlightlabs.lazurite', 125 + platform: any(named: 'platform'), 126 + ageRestricted: null, 127 + ), 128 + ).called(1); 129 + verifyNever( 130 + () => accountARepository.unregisterPush( 131 + token: any(named: 'token'), 132 + appId: any(named: 'appId'), 133 + platform: any(named: 'platform'), 134 + ), 135 + ); 136 + }); 137 + 138 + test('unregisters old account and registers new account on switch', () async { 139 + tokenProvider.token = 'shared-token'; 140 + final service = buildService(); 141 + addTearDown(service.dispose); 142 + 143 + await service.start(initialTokens: accountATokens); 144 + await service.updateSession(accountBTokens); 145 + 146 + verify( 147 + () => accountARepository.unregisterPush( 148 + token: 'shared-token', 149 + appId: 'org.stormlightlabs.lazurite', 150 + platform: any(named: 'platform'), 151 + ), 152 + ).called(1); 153 + verify( 154 + () => accountBRepository.registerPush( 155 + token: 'shared-token', 156 + appId: 'org.stormlightlabs.lazurite', 157 + platform: any(named: 'platform'), 158 + ageRestricted: null, 159 + ), 160 + ).called(1); 161 + }); 162 + 163 + test('unregisters push token on logout', () async { 164 + tokenProvider.token = 'token-a'; 165 + final service = buildService(); 166 + addTearDown(service.dispose); 167 + 168 + await service.start(initialTokens: accountATokens); 169 + await service.updateSession(null); 170 + 171 + verify( 172 + () => accountARepository.unregisterPush( 173 + token: 'token-a', 174 + appId: 'org.stormlightlabs.lazurite', 175 + platform: any(named: 'platform'), 176 + ), 177 + ).called(1); 178 + }); 179 + 180 + test('retries registration failures with backoff attempts', () async { 181 + tokenProvider.token = 'token-a'; 182 + final service = buildService(maxAttempts: 3); 183 + addTearDown(service.dispose); 184 + 185 + var attempts = 0; 186 + when( 187 + () => accountARepository.registerPush( 188 + token: any(named: 'token'), 189 + appId: any(named: 'appId'), 190 + platform: any(named: 'platform'), 191 + ageRestricted: any(named: 'ageRestricted'), 192 + ), 193 + ).thenAnswer((_) async { 194 + attempts += 1; 195 + if (attempts < 3) { 196 + throw Exception('temporary failure'); 197 + } 198 + }); 199 + 200 + await service.start(initialTokens: accountATokens); 201 + 202 + expect(attempts, 3); 203 + }); 204 + 205 + test('re-registers on token refresh and unregisters old token first', () async { 206 + tokenProvider.token = 'token-a'; 207 + final service = buildService(); 208 + addTearDown(service.dispose); 209 + 210 + await service.start(initialTokens: accountATokens); 211 + tokenProvider.controller.add('token-b'); 212 + await Future<void>.delayed(Duration.zero); 213 + 214 + verify( 215 + () => accountARepository.unregisterPush( 216 + token: 'token-a', 217 + appId: 'org.stormlightlabs.lazurite', 218 + platform: any(named: 'platform'), 219 + ), 220 + ).called(1); 221 + verify( 222 + () => accountARepository.registerPush( 223 + token: 'token-b', 224 + appId: 'org.stormlightlabs.lazurite', 225 + platform: any(named: 'platform'), 226 + ageRestricted: null, 227 + ), 228 + ).called(1); 229 + }); 230 + }); 231 + }