/ app / lib / services / background_fetch_service.dart
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  }