background_fetch_service.dart
1 import 'dart:async'; 2 3 import 'package:background_fetch/background_fetch.dart'; 4 import 'package:path_provider/path_provider.dart'; 5 import 'package:shared_preferences/shared_preferences.dart'; 6 7 import '../core/utils/logger.dart'; 8 import '../providers/messages_provider.dart'; 9 import '../src/rust/api.dart' as api; 10 import '../src/rust/frb_generated.dart'; 11 import 'dead_drop_service.dart'; 12 import 'notification_service.dart'; 13 14 final _log = LoggerService.instance; 15 16 const _passphraseKey = 'dead_drop_passphrase'; 17 18 const _defaultBootstrapNodes = [ 19 'router.bittorrent.com:6881', 20 'router.utorrent.com:6881', 21 'dht.transmissionbt.com:6881', 22 ]; 23 24 /// Service managing background DHT message fetching. 25 /// 26 /// Uses `background_fetch` to periodically check for new DHT messages 27 /// when the app is backgrounded or terminated. 28 class BackgroundFetchService { 29 static final instance = BackgroundFetchService._(); 30 BackgroundFetchService._(); 31 32 /// Configure background fetch. Call once from main(). 33 Future<void> configure() async { 34 final status = await BackgroundFetch.configure( 35 BackgroundFetchConfig( 36 minimumFetchInterval: 15, 37 stopOnTerminate: false, 38 startOnBoot: true, 39 enableHeadless: true, 40 requiredNetworkType: NetworkType.ANY, 41 requiresCharging: false, 42 ), 43 _onBackgroundFetch, 44 _onBackgroundTimeout, 45 ); 46 _log.info('BackgroundFetch', 'Configured with status: $status'); 47 } 48 49 /// Warm callback: app process alive, same Dart isolate. 50 static Future<void> _onBackgroundFetch(String taskId) async { 51 _log.info('BackgroundFetch', 'Event: $taskId'); 52 53 try { 54 await _performDhtFetch(isHeadless: false); 55 } catch (e) { 56 _log.error('BackgroundFetch', 'Fetch failed', e); 57 } 58 59 try { 60 await _pollIrohIncoming(); 61 } catch (e) { 62 _log.error('BackgroundFetch', 'iroh poll failed', e); 63 } 64 65 BackgroundFetch.finish(taskId); 66 } 67 68 /// Timeout callback: must finish immediately. 69 static void _onBackgroundTimeout(String taskId) { 70 _log.warning('BackgroundFetch', 'TIMEOUT: $taskId'); 71 BackgroundFetch.finish(taskId); 72 } 73 74 /// Core fetch logic shared by warm and headless callbacks. 75 static Future<void> _performDhtFetch({required bool isHeadless}) async { 76 // Phase 1: Ensure services are initialized 77 if (!DeadDropService.instance.isInitialized) { 78 final ok = await _initializeServices(isHeadless: isHeadless); 79 if (!ok) return; 80 } 81 82 // Phase 2: Ensure DHT is connected 83 final dhtState = api.getDhtState(); 84 if (dhtState.state != 'connected') { 85 _log.info('BackgroundFetch', 'DHT not connected, bootstrapping...'); 86 try { 87 await api 88 .bootstrapDht(nodes: _defaultBootstrapNodes) 89 .timeout(const Duration(seconds: 20)); 90 } catch (e) { 91 _log.error('BackgroundFetch', 'DHT bootstrap failed/timed out', e); 92 return; 93 } 94 95 // Poll for connected state (up to 5s) 96 var connected = false; 97 for (var i = 0; i < 10; i++) { 98 await Future.delayed(const Duration(milliseconds: 500)); 99 if (api.getDhtState().state == 'connected') { 100 connected = true; 101 break; 102 } 103 } 104 if (!connected) { 105 _log.warning( 106 'BackgroundFetch', 'DHT did not connect within timeout'); 107 return; 108 } 109 } 110 111 // Phase 3: Fetch messages 112 final result = await api 113 .fetchMessagesFromDht() 114 .timeout(const Duration(seconds: 5)); 115 final messages = result.messages; 116 117 if (messages.isEmpty && result.contactsWithReactions.isEmpty) return; 118 119 _log.info( 120 'BackgroundFetch', 'Fetched ${messages.length} new messages from DHT'); 121 122 // Phase 4: Show notifications grouped by contact 123 final prefs = await SharedPreferences.getInstance(); 124 final globalEnabled = prefs.getBool('notification_settings_enabled') ?? true; 125 final soundEnabled = prefs.getBool('notification_settings_sound') ?? true; 126 127 final contactIds = messages.map((m) => bytesToHex(m.contactId)).toSet(); 128 129 for (final contactIdHex in contactIds) { 130 // Skip group messages from 1:1 notifications 131 final contactMsgs = messages 132 .where((m) => bytesToHex(m.contactId) == contactIdHex) 133 .where((m) => m.groupId == null); 134 final count = contactMsgs.length; 135 if (count == 0) continue; 136 137 final contactIdBytes = hexToBytes(contactIdHex); 138 bool isMuted; 139 try { 140 isMuted = api.getContactMuted(contactId: contactIdBytes); 141 } catch (_) { 142 isMuted = false; 143 } 144 145 String contactName; 146 try { 147 final contact = await DeadDropService.instance 148 .getContact(contactIdBytes); 149 contactName = contact.displayName; 150 } catch (_) { 151 contactName = 'Unknown'; 152 } 153 154 await NotificationService.instance.showMessageNotification( 155 contactIdHex: contactIdHex, 156 contactName: contactName, 157 messageCount: count, 158 isMuted: isMuted, 159 globalEnabled: globalEnabled, 160 soundEnabled: soundEnabled, 161 ); 162 } 163 } 164 165 /// Poll iroh for pending incoming messages (best-effort). 166 /// 167 /// Drains any queued iroh messages so they're available when the app resumes. 168 /// Full message processing and notifications happen in the foreground. 169 static Future<void> _pollIrohIncoming() async { 170 // Only attempt if iroh endpoint is active 171 try { 172 final irohState = api.getIrohState(); 173 if (irohState.state != 'running') return; 174 } catch (_) { 175 return; 176 } 177 178 // Drain up to 10 pending messages 179 for (var i = 0; i < 10; i++) { 180 final msg = await api.pollIrohIncoming(); 181 if (msg == null) break; 182 183 _log.info('BackgroundFetch', 'iroh message received in background'); 184 } 185 } 186 187 /// Initialize services needed for background fetch. 188 static Future<bool> _initializeServices({required bool isHeadless}) async { 189 try { 190 if (isHeadless) { 191 await RustLib.init(); 192 await LoggerService.instance.initialize(); 193 await NotificationService.instance.initialize(); 194 } 195 196 final prefs = await SharedPreferences.getInstance(); 197 final passphrase = prefs.getString(_passphraseKey); 198 if (passphrase == null) { 199 _log.warning('BackgroundFetch', 'No passphrase stored, skipping'); 200 return false; 201 } 202 203 final appDir = await getApplicationDocumentsDirectory(); 204 final dbPath = '${appDir.path}/dead_drop.db'; 205 206 await DeadDropService.instance.initialize(dbPath, passphrase); 207 _log.info('BackgroundFetch', 'Services initialized'); 208 return true; 209 } catch (e) { 210 _log.error('BackgroundFetch', 'Init failed', e); 211 return false; 212 } 213 } 214 } 215 216 /// Top-level headless callback for when the app has been terminated. 217 /// 218 /// Must be a top-level function (not a closure or instance method). 219 @pragma('vm:entry-point') 220 void backgroundFetchHeadlessTask(HeadlessTask task) async { 221 final taskId = task.taskId; 222 223 if (task.timeout) { 224 BackgroundFetch.finish(taskId); 225 return; 226 } 227 228 try { 229 await BackgroundFetchService._performDhtFetch(isHeadless: true); 230 } catch (e) { 231 // Logger may not be initialized in headless mode 232 print('BackgroundFetch headless error: $e'); 233 } 234 235 try { 236 await BackgroundFetchService._pollIrohIncoming(); 237 } catch (e) { 238 print('BackgroundFetch headless iroh error: $e'); 239 } 240 241 BackgroundFetch.finish(taskId); 242 }