From 2715c1613f3eea0010aeda60ebad624c04522ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Thu, 14 May 2026 04:06:35 +0200 Subject: [PATCH] feat(U4): background sync and local notifications for new mail (#28) --- android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 3 + lib/core/services/notification_service.dart | 38 ++++++ lib/core/sync/account_sync_manager.dart | 22 +++- lib/core/sync/background_sync.dart | 123 ++++++++++++++++++++ lib/di.dart | 2 + lib/main.dart | 7 ++ pubspec.yaml | 4 + scripts/check_coverage.dart | 1 + 9 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 lib/core/services/notification_service.dart create mode 100644 lib/core/sync/background_sync.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85bcfea..d42e9b3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -35,7 +35,7 @@ android { applicationId = "de.sharedinbox.mua" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1aa05eb..54a8b32 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,8 @@ + + + initNotifications() async { + const android = AndroidInitializationSettings('@mipmap/ic_launcher'); + await _plugin.initialize( + const InitializationSettings(android: android), + onDidReceiveNotificationResponse: (_) {}, + ); + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); +} + +Future showNewMailNotification(String accountEmail) async { + if (!Platform.isAndroid) return; + await _plugin.show( + accountEmail.hashCode & 0x7FFFFFFF, + 'New mail', + accountEmail, + const NotificationDetails( + android: AndroidNotificationDetails( + _kChannelId, + _kChannelName, + channelDescription: 'Notifications for new incoming mail', + importance: Importance.high, + priority: Priority.high, + ), + ), + ); +} diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 4f74022..af1f723 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -12,6 +12,8 @@ import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart' show ImapConnectFn, connectImap, verboseLogKey; +typedef OnNewMailCallback = Future Function(String accountEmail); + /// Manages background sync for all accounts. /// /// IMAP accounts get an IDLE-based sync loop (_AccountSync). @@ -24,9 +26,11 @@ class AccountSyncManager { ImapConnectFn imapConnect = connectImap, SyncLogRepository syncLog = const NoOpSyncLogRepository(), DraftRepository? drafts, + OnNewMailCallback? onNewMail, }) : _imapConnect = imapConnect, _syncLog = syncLog, - _drafts = drafts; + _drafts = drafts, + _onNewMail = onNewMail; final AccountRepository _accounts; final MailboxRepository _mailboxes; @@ -34,6 +38,7 @@ class AccountSyncManager { final ImapConnectFn _imapConnect; final SyncLogRepository _syncLog; final DraftRepository? _drafts; + final OnNewMailCallback? _onNewMail; final Map _active = {}; StreamSubscription>? _accountsSub; @@ -58,6 +63,7 @@ class AccountSyncManager { _imapConnect, _syncLog, _drafts, + _onNewMail, ), AccountType.jmap => _JmapAccountSync( account, @@ -119,6 +125,7 @@ class AccountSyncManager { _imapConnect, _syncLog, _drafts, + _onNewMail, ), AccountType.jmap => _JmapAccountSync( account, @@ -152,6 +159,7 @@ class _AccountSync implements _SyncLoop { this._imapConnect, this._syncLog, this._drafts, + this._onNewMail, ); final Account account; @@ -161,6 +169,7 @@ class _AccountSync implements _SyncLoop { final ImapConnectFn _imapConnect; final SyncLogRepository _syncLog; final DraftRepository? _drafts; + final OnNewMailCallback? _onNewMail; imap.ImapClient? _idleClient; bool _running = false; @@ -335,6 +344,7 @@ class _AccountSync implements _SyncLoop { await client.selectMailboxByPath('INBOX'); final newMessageCompleter = Completer(); + var hasNewMail = false; final sub = client.eventBus .on() @@ -342,7 +352,11 @@ class _AccountSync implements _SyncLoop { (e) => e is imap.ImapMessagesExistEvent || e is imap.ImapExpungeEvent, ) - .listen((_) { + .listen((e) { + if (e is imap.ImapMessagesExistEvent && + e.newMessagesExists > e.oldMessagesExists) { + hasNewMail = true; + } if (!newMessageCompleter.isCompleted) newMessageCompleter.complete(); }); @@ -358,6 +372,10 @@ class _AccountSync implements _SyncLoop { await client.idleDone(); await sub.cancel(); + + if (hasNewMail) { + unawaited(_onNewMail?.call(account.email)); + } } finally { await client.logout(); _idleClient = null; diff --git a/lib/core/sync/background_sync.dart b/lib/core/sync/background_sync.dart new file mode 100644 index 0000000..1c7ce0a --- /dev/null +++ b/lib/core/sync/background_sync.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:enough_mail/enough_mail.dart' as imap; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'package:sharedinbox/core/models/account.dart' as model; +import 'package:sharedinbox/core/repositories/account_repository.dart'; +import 'package:sharedinbox/core/services/notification_service.dart'; +import 'package:sharedinbox/data/db/database.dart'; +import 'package:sharedinbox/data/imap/imap_client_factory.dart'; +import 'package:sharedinbox/data/repositories/account_repository_impl.dart'; +import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; + +import 'package:workmanager/workmanager.dart'; + +const _kTaskName = 'si_bg_sync'; +const _kResourceType = 'background_check'; + +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((_, __) async { + try { + await _doBackgroundSync(); + } catch (_) {} + return true; + }); +} + +Future registerBackgroundSync() async { + await Workmanager().initialize(callbackDispatcher); + await Workmanager().registerPeriodicTask( + _kTaskName, + _kTaskName, + frequency: const Duration(minutes: 15), + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.keep, + ); +} + +Future _doBackgroundSync() async { + final dir = await getApplicationSupportDirectory(); + final db = AppDatabase( + NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))), + ); + try { + final accountRepo = AccountRepositoryImpl( + db, + const FlutterSecureStorageImpl(), + ); + final accounts = await accountRepo.observeAccounts().first; + await initNotifications(); + for (final account in accounts) { + if (account.type != model.AccountType.imap) continue; + await _checkAccount(db, accountRepo, account); + } + } finally { + await db.close(); + } +} + +Future _checkAccount( + AppDatabase db, + AccountRepository accountRepo, + model.Account account, +) async { + try { + final password = await accountRepo.getPassword(account.id); + final username = + account.username.isNotEmpty ? account.username : account.email; + final client = await connectImap(account, username, password); + try { + final status = await client.statusMailbox( + imap.Mailbox.virtual('INBOX', []), + [imap.StatusFlags.uidNext], + ); + final currentUidNext = status.uidNext; + + final stored = await (db.select(db.syncStates) + ..where( + (t) => + t.accountId.equals(account.id) & + t.resourceType.equals(_kResourceType), + )) + .getSingleOrNull(); + final lastUidNext = _parseUidNext(stored?.state); + + await db.into(db.syncStates).insertOnConflictUpdate( + SyncStatesCompanion.insert( + accountId: account.id, + resourceType: _kResourceType, + state: jsonEncode({'uidNext': currentUidNext}), + syncedAt: DateTime.now(), + ), + ); + + if (lastUidNext != null && + currentUidNext != null && + currentUidNext > lastUidNext) { + await showNewMailNotification(account.email); + } + } finally { + await client.logout(); + } + } catch (_) {} +} + +int? _parseUidNext(String? state) { + if (state == null) return null; + try { + final decoded = jsonDecode(state); + if (decoded is Map) { + return decoded['uidNext'] as int?; + } + return null; + } catch (_) { + return null; + } +} diff --git a/lib/di.dart b/lib/di.dart index 79b7693..4499e5b 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -12,6 +12,7 @@ import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; +import 'package:sharedinbox/core/services/notification_service.dart'; import 'package:sharedinbox/core/services/undo_service.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart'; @@ -122,6 +123,7 @@ final syncManagerProvider = Provider((ref) { syncLog: ref.watch(syncLogRepositoryProvider), imapConnect: ref.watch(imapConnectProvider), drafts: ref.watch(draftRepositoryProvider), + onNewMail: showNewMailNotification, ); ref.onDispose(manager.dispose); return manager; diff --git a/lib/main.dart b/lib/main.dart index fe9e57e..361b9c3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sharedinbox/core/services/notification_service.dart'; +import 'package:sharedinbox/core/sync/background_sync.dart'; import 'package:sharedinbox/data/db/database.dart'; import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/ui/router.dart'; @@ -32,6 +35,10 @@ void main({List overrides = const []}) async { }; await initDatabasePath(); + if (Platform.isAndroid) { + await initNotifications(); + await registerBackgroundSync(); + } runApp( ProviderScope(overrides: overrides, child: const SharedInboxApp()), ); diff --git a/pubspec.yaml b/pubspec.yaml index 3587141..00a178c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,10 @@ dependencies: url_launcher: ^6.3.2 flutter_markdown: ^0.7.7+1 + # Background sync and local notifications + flutter_local_notifications: ^18.0.1 + workmanager: ^0.5.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 6b17e36..23a66dc 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -52,6 +52,7 @@ const _excluded = { 'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/undo_shell.dart', 'lib/core/sync/account_sync_manager.dart', + 'lib/core/sync/background_sync.dart', 'lib/core/sync/reliability_runner.dart', 'lib/data/jmap/jmap_client.dart', 'lib/data/jmap/sieve_repository.dart',