diff --git a/lib/core/sync/account_sync_manager.dart b/lib/core/sync/account_sync_manager.dart index 75bfa49..91a45ea 100644 --- a/lib/core/sync/account_sync_manager.dart +++ b/lib/core/sync/account_sync_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:enough_mail/enough_mail.dart' as imap; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart' show SyncEmailsResult; import 'package:sharedinbox/core/repositories/account_repository.dart'; @@ -294,6 +295,7 @@ class _AccountSync implements _SyncLoop { bool _isPermanentError(Object e) { if (isTlsConfigError(e)) return true; + if (e is MissingPluginException) return true; final s = e.toString().toLowerCase(); // enough_mail doesn't always have typed exceptions for auth, so we check strings. return s.contains('invalid credentials') || @@ -546,6 +548,7 @@ class _JmapAccountSync implements _SyncLoop { bool _isPermanentError(Object e) { if (isTlsConfigError(e)) return true; + if (e is MissingPluginException) return true; final s = e.toString().toLowerCase(); return s.contains('invalid credentials') || s.contains('authentication failed') || diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 55371a5..d053583 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:flutter/services.dart' show MissingPluginException; import 'package:mockito/annotations.dart'; +import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart'; @@ -30,6 +32,40 @@ void main() { // This is hard to test without real loops, but we can verify it doesn't crash. manager.syncNow('unknown'); }); + + // Regression test for issue #200: when flutter_secure_storage throws + // MissingPluginException (channel unavailable on the device), the IMAP sync + // loop must stop permanently instead of retrying indefinitely with backoff. + test( + 'MissingPluginException from secure storage stops IMAP sync loop permanently', + () async { + final syncLog = FakeSyncLogRepository(); + + final m = AccountSyncManager( + _AccountRepositoryWithMissingPlugin(), + FakeMailboxRepositoryWithInbox(), + FakeEmailRepository(), + syncLog: syncLog, + ); + + m.start(); + + // Allow the first sync cycle to run and fail. + await Future.delayed(const Duration(milliseconds: 100)); + + expect(syncLog.logs, hasLength(1)); + expect(syncLog.logs.first.success, isFalse); + + // Kicking the loop should have no effect once it has stopped permanently. + m.syncNow('1'); + await Future.delayed(const Duration(milliseconds: 100)); + + // Before the fix: kick triggers a retry → 2 log entries. + // After the fix: loop is permanently stopped → still exactly 1 entry. + expect(syncLog.logs, hasLength(1)); + + m.dispose(); + }); } class FakeEmailRepository implements EmailRepository { @@ -187,3 +223,34 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository { @override Future clearForResync(String accountId) async {} } + +class _AccountRepositoryWithMissingPlugin implements AccountRepository { + static const _account = Account( + id: '1', + displayName: 'Test', + email: 'test@example.com', + ); + + @override + Stream> observeAccounts() => Stream.value([_account]); + + @override + Future getAccount(String id) async => _account; + + @override + Future getPassword(String accountId) => Future.error( + MissingPluginException( + 'No implementation found for method read on channel ' + 'plugins.it.nomads.com/flutter_secure_storage', + ), + ); + + @override + Future addAccount(Account account, String password) async {} + + @override + Future updateAccount(Account account, {String? password}) async {} + + @override + Future removeAccount(String id) async {} +}