diff --git a/lib/main.dart b/lib/main.dart index ff7daf2..e06a754 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,14 +43,15 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'storage/hydrated_storage_bootstrap.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; import 'utils/app_paths.dart'; import 'utils/downloads/download_manager.dart'; +import 'view/login/account_loading_screen.dart'; import 'view/login/login.dart'; import 'view/login/post_login_splash.dart'; -import 'widget/app_progress_indicator.dart'; import 'widget/avatar_disk_cache.dart'; import 'widget/breaker/breaker.dart'; import 'widget/debug/cache_view.dart'; @@ -58,40 +59,107 @@ import 'widget/downloads/download_tray.dart'; import 'widget/emergency/emergency_notice_gate.dart'; import 'widget_data/widget_sync.dart'; +/// Runs one startup step with a time limit. Anything that throws or hangs +/// before `runApp` would otherwise leave the native launch screen up for +/// good, so failures are logged and reported and the app starts degraded. +Future _startupStep( + String name, + Future Function() step, { + Duration? timeout = const Duration(seconds: 10), + bool report = true, +}) async { + try { + final future = step(); + await (timeout == null ? future : future.timeout(timeout)); + } catch (e, s) { + log('Startup step "$name" failed: $e', stackTrace: s); + if (report) { + ClientErrorReporter.reportPlatformError('Startup step "$name": $e', s); + } + } +} + +void _installErrorHandlers() { + if (kReleaseMode) { + ErrorWidget.builder = (error) => Material( + color: Colors.white, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phonelink_erase_rounded, size: 40), + const SizedBox(height: 12), + Text(error.toStringShort(), textAlign: TextAlign.center), + ], + ), + ), + ), + ); + } + + FlutterError.onError = (details) { + log( + 'Uncaught Flutter error: ${details.exception}', + stackTrace: details.stack, + ); + ClientErrorReporter.reportFlutterError(details); + FlutterError.presentError(details); + }; + PlatformDispatcher.instance.onError = (error, stack) { + log('Uncaught platform error: $error', stackTrace: stack); + ClientErrorReporter.reportPlatformError(error, stack); + return true; + }; +} + Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); + // Before any initialisation so startup failures reach the backend too. + _installErrorHandlers(); - void addCertificateAsTrusted(ByteData certificate) => SecurityContext - .defaultContext - .setTrustedCertificatesBytes(certificate.buffer.asUint8List()); + Future trustCertificate(String asset) => PlatformAssetBundle() + .load(asset) + .then( + (certificate) => SecurityContext.defaultContext + .setTrustedCertificatesBytes(certificate.buffer.asUint8List()), + ); final initialisationTasks = [ - Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) - .then((_) {}) - .onError((error, _) => log('Error initializing Firebase: $error')), - PlatformAssetBundle() - .load('assets/ca/lets-encrypt-r3.pem') - .then(addCertificateAsTrusted), - PlatformAssetBundle() - .load('assets/ca/lets-encrypt-r10.pem') - .then(addCertificateAsTrusted), - PlatformAssetBundle() - .load('assets/ca/lets-encrypt-r13.pem') - .then(addCertificateAsTrusted), - Future(() async { - final storage = await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory( - (await getTemporaryDirectory()).path, - ), - ); - HydratedBloc.storage = storage; - }), - Future(() async { + _startupStep( + 'firebase', + () => Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ), + ), + _startupStep( + 'ca certificates', + () => Future.wait([ + trustCertificate('assets/ca/lets-encrypt-r3.pem'), + trustCertificate('assets/ca/lets-encrypt-r10.pem'), + trustCertificate('assets/ca/lets-encrypt-r13.pem'), + ]), + ), + _startupStep('hydrated storage', () async { + final path = (await getTemporaryDirectory()).path; + HydratedBloc.storage = await buildHydratedStorageWithFallback(path); + }, timeout: null), + _startupStep('documents dir', () async { AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; }), - AccountData().waitForPopulation(), - ShareIntentListener.instance.initialize(), + // The keychain may still be locked right after device unlock; AccountData + // keeps retrying, so on timeout the app starts on the loading screen and + // flips to the real state once the session is readable (see _MainState). + _startupStep( + 'account data', + AccountData().waitForPopulation, + timeout: const Duration(seconds: 5), + // Expected on every background wake of a locked device; not an error. + report: false, + ), + _startupStep('share intent', ShareIntentListener.instance.initialize), ]; log('starting app initialisation...'); @@ -101,8 +169,11 @@ Future main() async { // Local notifications: init the plugin (with tap/action callbacks) and the // Android channels, then register the FCM background isolate handler that // decrypts and renders Nextcloud pushes while the app is not in foreground. - await NotificationService().initializeNotifications(); - await PushRenderer.ensureChannels(); + await _startupStep( + 'notifications', + NotificationService().initializeNotifications, + ); + await _startupStep('notification channels', PushRenderer.ensureChannels); FirebaseMessaging.onBackgroundMessage(pushOnBackgroundMessage); // Wire up the native background downloader (progress notifications + tap @@ -116,7 +187,7 @@ Future main() async { // Wire up the home-screen widget bridge before runApp so any widget render // triggered during startup hits initialised native storage. - await WidgetSync.ensureInitialized(); + await _startupStep('widget sync', WidgetSync.ensureInitialized); unawaited( WidgetBackgroundTask.initialize().onError( (e, _) => log('Workmanager init failed: $e'), @@ -157,50 +228,13 @@ Future main() async { // placeholder flash. AvatarDiskCache.instance.warmUp(); - if (kReleaseMode) { - ErrorWidget.builder = (error) => Material( - color: Colors.white, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.phonelink_erase_rounded, size: 40), - const SizedBox(height: 12), - Text(error.toStringShort(), textAlign: TextAlign.center), - ], - ), - ), - ), - ); - } - - FlutterError.onError = (details) { - log( - 'Uncaught Flutter error: ${details.exception}', - stackTrace: details.stack, - ); - ClientErrorReporter.reportFlutterError(details); - FlutterError.presentError(details); - }; - PlatformDispatcher.instance.onError = (error, stack) { - log('Uncaught platform error: $error', stackTrace: stack); - ClientErrorReporter.reportPlatformError(error, stack); - return true; - }; - log('running app...'); runApp( MultiBlocProvider( providers: [ BlocProvider(create: (_) => SettingsCubit()), BlocProvider( - create: (_) => AccountBloc( - initialStatus: AccountData().isPopulated() - ? AccountStatus.loggedIn - : AccountStatus.loggedOut, - ), + create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), ), BlocProvider(create: (_) => BreakerBloc()), BlocProvider(create: (_) => CapabilitiesCubit()), @@ -218,6 +252,12 @@ Future main() async { ); } +AccountStatus _initialAccountStatus() { + final account = AccountData(); + if (account.isPopulated()) return AccountStatus.loggedIn; + return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; +} + class Main extends StatefulWidget { const Main({super.key}); @@ -365,111 +405,99 @@ class _MainState extends State
{ child: LoaderOverlay( child: Breaker( breaker: BreakerArea.global, - child: BlocConsumer( - listenWhen: (previous, current) => - previous.status != current.status, - listener: (context, accountState) { - // Fresh login (loggedOut -> loggedIn): pull capability flags - // for the newly authenticated user, then register push right - // away instead of deferring it to the next app start. - if (accountState.status == AccountStatus.loggedIn) { - final settingsCubit = context.read(); - final capabilitiesCubit = context.read(); - unawaited( - capabilitiesCubit.load().then((_) { - if (!mounted) return; - _syncPush(settingsCubit, capabilitiesCubit); - }), - ); - unawaited( - context.read().load(), - ); - _showPostLoginSplash = true; - _appMounted = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() => _appMounted = true); - }); - _prefetchBaseData(context); - } - if (accountState.status != AccountStatus.loggedOut) return; - // A pending share would otherwise survive logout and be - // re-applied after re-login with file paths the OS may - // already have evicted from the cache. - ShareIntentListener.instance.clear(); - // Routes pushed via AppRoutes (e.g. Settings) live on the - // root navigator and survive the home swap below, so they - // would still cover the Login screen after logout. Pop - // them here so the user immediately sees Login. - final navigator = Navigator.of(context); - if (navigator.canPop()) { - navigator.popUntil((route) => route.isFirst); - } - // Capture bloc references before the post-frame callback - // — by the time it runs the dialog/Settings context is - // gone but this listener context is still valid. - final settingsCubit = context.read(); - final timetableBloc = context.read(); - final chatListBloc = context.read(); - final chatBloc = context.read(); - final breakerBloc = context.read(); - final capabilitiesCubit = context.read(); - final nextcloudCapabilitiesCubit = context - .read(); - // Defer the actual wipe until after this frame so the - // App tree (TimetableBloc/ChatListBloc watchers etc.) - // is already torn down. Resetting blocs while App is - // still in front caused a black-frame race. - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited( - _wipeUserState( - settingsCubit: settingsCubit, - timetableBloc: timetableBloc, - chatListBloc: chatListBloc, - chatBloc: chatBloc, - breakerBloc: breakerBloc, - capabilitiesCubit: capabilitiesCubit, - nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit, - ), - ); - }); - }, - builder: (context, accountState) { - switch (accountState.status) { - case AccountStatus.loggedIn: - return Stack( - fit: StackFit.expand, - children: [ - if (_appMounted) - const App(key: ValueKey('app-shell')), - if (_showPostLoginSplash) - PostLoginSplash( - key: const ValueKey('post-login-splash'), - onComplete: () => - setState(() => _showPostLoginSplash = false), - ), - ], + child: BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status, + listener: (context, accountState) { + // Fresh login (loggedOut -> loggedIn): pull capability flags + // for the newly authenticated user, then register push right + // away instead of deferring it to the next app start. + if (accountState.status == AccountStatus.loggedIn) { + final settingsCubit = context.read(); + final capabilitiesCubit = context + .read(); + unawaited( + capabilitiesCubit.load().then((_) { + if (!mounted) return; + _syncPush(settingsCubit, capabilitiesCubit); + }), ); - case AccountStatus.loggedOut: - return const Login(); - case AccountStatus.undefined: - return Scaffold( - backgroundColor: LightAppTheme.marianumRed, - body: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppProgressIndicator.large(color: Colors.white), - SizedBox(height: 16), - Text( - 'Konto wird geladen…', - style: TextStyle(color: Colors.white), - ), - ], - ), + unawaited( + context.read().load(), + ); + _showPostLoginSplash = true; + _appMounted = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _appMounted = true); + }); + _prefetchBaseData(context); + } + if (accountState.status != AccountStatus.loggedOut) return; + // A pending share would otherwise survive logout and be + // re-applied after re-login with file paths the OS may + // already have evicted from the cache. + ShareIntentListener.instance.clear(); + // Routes pushed via AppRoutes (e.g. Settings) live on the + // root navigator and survive the home swap below, so they + // would still cover the Login screen after logout. Pop + // them here so the user immediately sees Login. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + // Capture bloc references before the post-frame callback + // — by the time it runs the dialog/Settings context is + // gone but this listener context is still valid. + final settingsCubit = context.read(); + final timetableBloc = context.read(); + final chatListBloc = context.read(); + final chatBloc = context.read(); + final breakerBloc = context.read(); + final capabilitiesCubit = context.read(); + final nextcloudCapabilitiesCubit = context + .read(); + // Defer the actual wipe until after this frame so the + // App tree (TimetableBloc/ChatListBloc watchers etc.) + // is already torn down. Resetting blocs while App is + // still in front caused a black-frame race. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + _wipeUserState( + settingsCubit: settingsCubit, + timetableBloc: timetableBloc, + chatListBloc: chatListBloc, + chatBloc: chatBloc, + breakerBloc: breakerBloc, + capabilitiesCubit: capabilitiesCubit, + nextcloudCapabilitiesCubit: + nextcloudCapabilitiesCubit, ), ); - } - }, + }); + }, + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return Stack( + fit: StackFit.expand, + children: [ + if (_appMounted) + const App(key: ValueKey('app-shell')), + if (_showPostLoginSplash) + PostLoginSplash( + key: const ValueKey('post-login-splash'), + onComplete: () => setState( + () => _showPostLoginSplash = false, + ), + ), + ], + ); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.undefined: + return const AccountLoadingScreen(); + } + }, ), ), ), diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 29c0f3b..4f96459 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -1,11 +1,14 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../push/push_secure_storage.dart'; +import '../utils/exponential_backoff.dart'; class AccountData { static const _usernameField = 'username'; @@ -26,7 +29,22 @@ class AccountData { // Keeps isPopulated()/getPassword() valid; demo mode never uses a real one. static const _demoPasswordPlaceholder = 'demo'; - static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); + // `first_unlock` so a background launch on a locked device (silent push, + // BGAppRefresh) can still read the session. Items written by older versions + // carry the plugin default `unlocked` and are invisible to this instance + // until _migrateKeychainAccessibility moved them over. + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), + ); + static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage( + iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked), + ); + static const List _sessionFields = [ + _usernameField, + _passwordField, + _demoField, + _loginFlowField, + ]; static final AccountData _instance = AccountData._construct(); Completer _populated = Completer(); @@ -34,7 +52,7 @@ class AccountData { factory AccountData() => _instance; AccountData._construct() { - _migrateAndLoad(); + unawaited(_loadWithRetry()); } String? _username; @@ -175,12 +193,36 @@ class AccountData { } } + /// iOS keychain reads fail while protected data is unavailable (app launch + /// racing the unlock, background wake on a locked device). Without a retry + /// the completer never resolved and the app stayed on the launch screen. + Future _loadWithRetry() async { + for (var attempt = 1; !_populated.isCompleted; attempt++) { + try { + await _migrateAndLoad(); + return; + } catch (e, s) { + log('AccountData load failed (attempt $attempt): $e', stackTrace: s); + await Future.delayed(exponentialBackoff(attempt)); + } + } + } + + /// Stops waiting for the stored session; the app then behaves as logged + /// out. The keychain entries stay untouched so a later start can still + /// restore the session. + void abandonLoad() { + if (!_populated.isCompleted) _populated.complete(); + } + Future _migrateAndLoad() async { await _migrateFromLegacyStorage(); + await _migrateKeychainAccessibility(); _username = await _secureStorage.read(key: _usernameField); _password = await _secureStorage.read(key: _passwordField); _isDemo = (await _secureStorage.read(key: _demoField)) == 'true'; - _usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true'; + _usesLoginFlow = + (await _secureStorage.read(key: _loginFlowField)) == 'true'; try { _appPassword = await pushSecureStorage.read(key: _appPasswordField); _appPasswordTalk = await pushSecureStorage.read( @@ -210,11 +252,25 @@ class AccountData { await prefs.remove(_passwordField); } + Future _migrateKeychainAccessibility() async { + if (!Platform.isIOS) return; + for (final field in _sessionFields) { + final value = await _legacySecureStorage.read(key: field); + if (value == null) continue; + // Same account+service: the legacy item has to go before the re-add. + await _legacySecureStorage.delete(key: field); + await _secureStorage.write(key: field, value: value); + } + } + Future waitForPopulation() async { await _populated.future; return isPopulated(); } + /// True once the stored session has been read (or given up on). + bool get isLoaded => _populated.isCompleted; + bool isPopulated() => _username != null && _password != null; /// Returns the value for an HTTP `Authorization` header using HTTP Basic. diff --git a/lib/storage/hydrated_storage_bootstrap.dart b/lib/storage/hydrated_storage_bootstrap.dart new file mode 100644 index 0000000..4a44969 --- /dev/null +++ b/lib/storage/hydrated_storage_bootstrap.dart @@ -0,0 +1,54 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +/// Opens the HydratedBloc box; a corrupt box is dropped and rebuilt, and if +/// even that fails the app runs on [InMemoryStorage] instead of never +/// reaching `runApp`. +Future buildHydratedStorageWithFallback(String directoryPath) async { + final directory = HydratedStorageDirectory(directoryPath); + try { + return await HydratedStorage.build(storageDirectory: directory); + } catch (e, s) { + log('HydratedStorage open failed, rebuilding: $e', stackTrace: s); + } + try { + await _deleteHydratedBox(directoryPath); + return await HydratedStorage.build(storageDirectory: directory); + } catch (e, s) { + log('HydratedStorage rebuild failed, using memory: $e', stackTrace: s); + return InMemoryStorage(); + } +} + +Future _deleteHydratedBox(String directoryPath) async { + final directory = Directory(directoryPath); + if (!directory.existsSync()) return; + await for (final entity in directory.list()) { + final name = entity.uri.pathSegments.lastWhere((s) => s.isNotEmpty); + if (entity is File && name.startsWith('hydrated_box')) { + await entity.delete(); + } + } +} + +/// Non-persistent [Storage]; state lives for the session only. +class InMemoryStorage implements Storage { + final Map _values = {}; + + @override + dynamic read(String key) => _values[key]; + + @override + Future write(String key, dynamic value) async => _values[key] = value; + + @override + Future delete(String key) async => _values.remove(key); + + @override + Future clear() async => _values.clear(); + + @override + Future close() async {} +} diff --git a/lib/utils/exponential_backoff.dart b/lib/utils/exponential_backoff.dart new file mode 100644 index 0000000..1488503 --- /dev/null +++ b/lib/utils/exponential_backoff.dart @@ -0,0 +1,11 @@ +/// Delay before retry number [attempt] (1-based): [base] doubled per attempt, +/// capped at [max]. +Duration exponentialBackoff( + int attempt, { + Duration base = const Duration(milliseconds: 500), + Duration max = const Duration(seconds: 5), +}) { + final exponent = (attempt - 1).clamp(0, 30); + final millis = base.inMilliseconds * (1 << exponent); + return millis >= max.inMilliseconds ? max : Duration(milliseconds: millis); +} diff --git a/lib/view/login/account_loading_screen.dart b/lib/view/login/account_loading_screen.dart new file mode 100644 index 0000000..649f175 --- /dev/null +++ b/lib/view/login/account_loading_screen.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../model/account_data.dart'; +import '../../theming/light_app_theme.dart'; +import '../../widget/app_progress_indicator.dart'; + +/// Shown while the stored session is still being read. Normally gone within +/// a second; after [hintDelay] it offers a way out so a keychain that keeps +/// failing can never trap the user on a spinner. +class AccountLoadingScreen extends StatefulWidget { + const AccountLoadingScreen({super.key}); + + static const Duration hintDelay = Duration(seconds: 15); + + @override + State createState() => _AccountLoadingScreenState(); +} + +class _AccountLoadingScreenState extends State { + Timer? _hintTimer; + bool _showHint = false; + + @override + void initState() { + super.initState(); + _hintTimer = Timer(AccountLoadingScreen.hintDelay, () { + if (mounted) setState(() => _showHint = true); + }); + } + + @override + void dispose() { + _hintTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: LightAppTheme.marianumRed, + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AppProgressIndicator.large(color: Colors.white), + const SizedBox(height: 16), + const Text( + 'Konto wird geladen…', + style: TextStyle(color: Colors.white), + ), + if (_showHint) ...[ + const SizedBox(height: 32), + const Text( + 'Das dauert länger als üblich. Die gespeicherte Anmeldung ' + 'konnte noch nicht gelesen werden.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 8), + TextButton( + onPressed: AccountData().abandonLoad, + style: TextButton.styleFrom(foregroundColor: Colors.white), + child: const Text('Zur Anmeldung'), + ), + ], + ], + ), + ), + ), + ); +} diff --git a/test/storage/in_memory_storage_test.dart b/test/storage/in_memory_storage_test.dart new file mode 100644 index 0000000..3deff42 --- /dev/null +++ b/test/storage/in_memory_storage_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/storage/hydrated_storage_bootstrap.dart'; + +void main() { + test('InMemoryStorage round-trips, deletes and clears', () async { + final storage = InMemoryStorage(); + expect(storage.read('a'), isNull); + + await storage.write('a', {'x': 1}); + await storage.write('b', 'two'); + expect(storage.read('a'), {'x': 1}); + expect(storage.read('b'), 'two'); + + await storage.delete('a'); + expect(storage.read('a'), isNull); + expect(storage.read('b'), 'two'); + + await storage.clear(); + expect(storage.read('b'), isNull); + }); +} diff --git a/test/utils/exponential_backoff_test.dart b/test/utils/exponential_backoff_test.dart new file mode 100644 index 0000000..1aa97cb --- /dev/null +++ b/test/utils/exponential_backoff_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/exponential_backoff.dart'; + +void main() { + group('exponentialBackoff', () { + test('doubles per attempt starting at base', () { + expect(exponentialBackoff(1), const Duration(milliseconds: 500)); + expect(exponentialBackoff(2), const Duration(seconds: 1)); + expect(exponentialBackoff(3), const Duration(seconds: 2)); + expect(exponentialBackoff(4), const Duration(seconds: 4)); + }); + + test('caps at max', () { + expect(exponentialBackoff(5), const Duration(seconds: 5)); + expect(exponentialBackoff(40), const Duration(seconds: 5)); + expect(exponentialBackoff(1000), const Duration(seconds: 5)); + }); + + test('treats attempt 0 and negatives like the first attempt', () { + expect(exponentialBackoff(0), const Duration(milliseconds: 500)); + expect(exponentialBackoff(-3), const Duration(milliseconds: 500)); + }); + + test('honours custom base and max', () { + expect( + exponentialBackoff( + 3, + base: const Duration(seconds: 1), + max: const Duration(seconds: 3), + ), + const Duration(seconds: 3), + ); + }); + }); +}