diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index babc54a..312b1d9 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -7,12 +7,14 @@ import 'package:go_router/go_router.dart'; import 'data/species_repository.dart'; import 'data/variety_repository.dart'; import 'i18n/strings.g.dart'; +import 'services/auto_backup_service.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; import 'state/inventory_cubit.dart'; import 'state/variety_detail_cubit.dart'; import 'ui/about_screen.dart'; +import 'ui/auto_backup_gate.dart'; import 'ui/home_screen.dart'; import 'ui/intro_screen.dart'; import 'ui/inventory_list_screen.dart'; @@ -32,14 +34,15 @@ class TaneApp extends StatelessWidget { this.social, this.socialSettings, this.showIntro = false, + this.autoBackup, super.key, }) : _router = _buildRouter( - repository, - onboarding, - showIntro, - social, - socialSettings, - ); + repository, + onboarding, + showIntro, + social, + socialSettings, + ); final VarietyRepository repository; final SpeciesRepository species; @@ -50,6 +53,10 @@ class TaneApp extends StatelessWidget { final SocialService? social; final SocialSettings? socialSettings; final bool showIntro; + + /// Drives silent periodic backups off the app lifecycle. Null in widget tests + /// and on platforms without file storage (web) — the gate then does nothing. + final AutoBackupService? autoBackup; final GoRouter _router; static GoRouter _buildRouter( @@ -116,22 +123,25 @@ class TaneApp extends StatelessWidget { RepositoryProvider.value(value: repository), RepositoryProvider.value(value: species), ], - child: MaterialApp.router( - onGenerateTitle: (context) => context.t.app.title, - debugShowCheckedModeBanner: false, - theme: buildTaneTheme(), - // Let scrollables & PageViews be dragged with a mouse/trackpad on - // desktop (Flutter disables that by default), so the photo carousel and - // lists swipe there too. - scrollBehavior: const _DragScrollBehavior(), - locale: TranslationProvider.of(context).flutterLocale, - supportedLocales: AppLocaleUtils.supportedLocales, - localizationsDelegates: const [ - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - routerConfig: _router, + child: AutoBackupGate( + service: autoBackup, + child: MaterialApp.router( + onGenerateTitle: (context) => context.t.app.title, + debugShowCheckedModeBanner: false, + theme: buildTaneTheme(), + // Let scrollables & PageViews be dragged with a mouse/trackpad on + // desktop (Flutter disables that by default), so the photo carousel and + // lists swipe there too. + scrollBehavior: const _DragScrollBehavior(), + locale: TranslationProvider.of(context).flutterLocale, + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + routerConfig: _router, + ), ), ); } diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 37bb62e..d14c3b5 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:commons_core/commons_core.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/widgets.dart'; import 'package:get_it/get_it.dart'; @@ -15,6 +16,8 @@ import '../db/encrypted_executor.dart'; import '../i18n/strings.g.dart'; import '../security/secret_store.dart'; import '../security/secure_key_store.dart'; +import '../services/auto_backup_service.dart'; +import '../services/auto_backup_store.dart'; import '../services/export_import_service.dart'; import '../services/file_picker_file_service.dart'; import '../services/file_service.dart'; @@ -98,6 +101,23 @@ Future configureDependencies() async { ..registerSingleton( RecoverySheetService(files: fileService), ); + + // Automatic silent backups need real file storage; the web build has none, so + // it simply goes without (the manual "save a copy" still works there). + if (!kIsWeb) { + getIt.registerSingleton( + AutoBackupService( + exporter: getIt(), + store: AutoBackupStore(secretStore), + directory: _backupsDir, + ), + ); + } +} + +Future _backupsDir() async { + final dir = await getApplicationSupportDirectory(); + return Directory(p.join(dir.path, 'backups')); } Future _databaseFile() async { diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index afa8dfa..7492817 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -46,6 +46,9 @@ }, "backup": { "title": "Backup & restore", + "autoBackupTitle": "Automatic backups", + "autoBackupLast": "Last copy saved {date}", + "autoBackupNone": "A copy is kept automatically in the background", "exportJson": "Save a backup", "exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device", "importJson": "Restore a backup", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index e783be2..969a4b4 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -46,6 +46,9 @@ }, "backup": { "title": "Copia de seguridad", + "autoBackupTitle": "Copias automáticas", + "autoBackupLast": "Última copia guardada el {date}", + "autoBackupNone": "Se guarda una copia automáticamente en segundo plano", "exportJson": "Guardar una copia de seguridad", "exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo", "importJson": "Restaurar una copia", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 5d6d5b5..6fa21f2 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -46,6 +46,9 @@ }, "backup": { "title": "Cópia de segurança", + "autoBackupTitle": "Cópias automáticas", + "autoBackupLast": "Última cópia guardada em {date}", + "autoBackupNone": "Uma cópia é guardada automaticamente em segundo plano", "exportJson": "Guardar uma cópia de segurança", "exportJsonSubtitle": "Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho", "importJson": "Restaurar uma cópia", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 05d30f8..9af19c7 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 3 -/// Strings: 923 (307 per locale) +/// Strings: 932 (310 per locale) /// -/// Built on 2026-07-10 at 01:06 UTC +/// Built on 2026-07-10 at 08:15 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 21d8709..21c968d 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -236,6 +236,15 @@ class Translations$backup$en { /// en: 'Backup & restore' String get title => 'Backup & restore'; + /// en: 'Automatic backups' + String get autoBackupTitle => 'Automatic backups'; + + /// en: 'Last copy saved {date}' + String autoBackupLast({required Object date}) => 'Last copy saved ${date}'; + + /// en: 'A copy is kept automatically in the background' + String get autoBackupNone => 'A copy is kept automatically in the background'; + /// en: 'Save a backup' String get exportJson => 'Save a backup'; @@ -1592,6 +1601,9 @@ extension on Translations { 'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.', 'settings.aboutOpen' => 'About Tanemaki', 'backup.title' => 'Backup & restore', + 'backup.autoBackupTitle' => 'Automatic backups', + 'backup.autoBackupLast' => ({required Object date}) => 'Last copy saved ${date}', + 'backup.autoBackupNone' => 'A copy is kept automatically in the background', 'backup.exportJson' => 'Save a backup', 'backup.exportJsonSubtitle' => 'A complete copy to keep safe, restore later or move to another device', 'backup.importJson' => 'Restore a backup', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index a3ac6ee..c63e86f 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -166,6 +166,9 @@ class _Translations$backup$es extends Translations$backup$en { // Translations @override String get title => 'Copia de seguridad'; + @override String get autoBackupTitle => 'Copias automáticas'; + @override String autoBackupLast({required Object date}) => 'Última copia guardada el ${date}'; + @override String get autoBackupNone => 'Se guarda una copia automáticamente en segundo plano'; @override String get exportJson => 'Guardar una copia de seguridad'; @override String get exportJsonSubtitle => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo'; @override String get importJson => 'Restaurar una copia'; @@ -994,6 +997,9 @@ extension on TranslationsEs { 'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.', 'settings.aboutOpen' => 'Acerca de Tanemaki', 'backup.title' => 'Copia de seguridad', + 'backup.autoBackupTitle' => 'Copias automáticas', + 'backup.autoBackupLast' => ({required Object date}) => 'Última copia guardada el ${date}', + 'backup.autoBackupNone' => 'Se guarda una copia automáticamente en segundo plano', 'backup.exportJson' => 'Guardar una copia de seguridad', 'backup.exportJsonSubtitle' => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo', 'backup.importJson' => 'Restaurar una copia', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 592db84..aa7bf68 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -166,6 +166,9 @@ class _Translations$backup$pt extends Translations$backup$en { // Translations @override String get title => 'Cópia de segurança'; + @override String get autoBackupTitle => 'Cópias automáticas'; + @override String autoBackupLast({required Object date}) => 'Última cópia guardada em ${date}'; + @override String get autoBackupNone => 'Uma cópia é guardada automaticamente em segundo plano'; @override String get exportJson => 'Guardar uma cópia de segurança'; @override String get exportJsonSubtitle => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho'; @override String get importJson => 'Restaurar uma cópia'; @@ -990,6 +993,9 @@ extension on TranslationsPt { 'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.', 'settings.aboutOpen' => 'Acerca do Tanemaki', 'backup.title' => 'Cópia de segurança', + 'backup.autoBackupTitle' => 'Cópias automáticas', + 'backup.autoBackupLast' => ({required Object date}) => 'Última cópia guardada em ${date}', + 'backup.autoBackupNone' => 'Uma cópia é guardada automaticamente em segundo plano', 'backup.exportJson' => 'Guardar uma cópia de segurança', 'backup.exportJsonSubtitle' => 'Uma cópia completa para guardar em segurança, restaurar depois ou levar para outro aparelho', 'backup.importJson' => 'Restaurar uma cópia', diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index a16db49..561c8cc 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -5,6 +5,7 @@ import 'data/species_repository.dart'; import 'data/variety_repository.dart'; import 'di/injector.dart'; import 'i18n/strings.g.dart'; +import 'services/auto_backup_service.dart'; import 'services/onboarding_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; @@ -23,6 +24,9 @@ Future main() async { social: getIt(), socialSettings: getIt(), showIntro: !await onboarding.introSeen(), + autoBackup: getIt.isRegistered() + ? getIt() + : null, ), ), ); diff --git a/apps/app_seeds/lib/services/auto_backup_service.dart b/apps/app_seeds/lib/services/auto_backup_service.dart new file mode 100644 index 0000000..f39e50c --- /dev/null +++ b/apps/app_seeds/lib/services/auto_backup_service.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'auto_backup_store.dart'; +import 'export_import_service.dart'; + +/// Makes a silent, encrypted safety copy of the inventory on a schedule — a net +/// against in-app data loss or DB corruption, with zero friction. Triggered +/// from the app lifecycle (see [AutoBackupGate]), it writes into the app's +/// private storage; the manual "save a copy" stays the way to move one off the +/// device. +/// +/// Copies are **sealed** (same [ExportImportService.buildSealedBackup] bytes as +/// a manual backup): they reveal nothing at rest and open with the recovery +/// code. The newest few are kept and older ones rotated out. +class AutoBackupService { + AutoBackupService({ + required ExportImportService exporter, + required AutoBackupStore store, + required Future Function() directory, + DateTime Function() now = DateTime.now, + }) : _exporter = exporter, + _store = store, + _directory = directory, + _now = now; + + final ExportImportService _exporter; + final AutoBackupStore _store; + final Future Function() _directory; + final DateTime Function() _now; + + /// Filename prefix so rotation only ever touches automatic copies, never a + /// backup the user saved by hand. + static const _prefix = 'tanemaki-auto-'; + + /// How many automatic copies to keep. A rotating window (not a single file) + /// so a corrupt copy can't overwrite the only backup. + static const _keep = 3; + + /// When the last automatic copy was made, or null if none has run yet. For a + /// reassurance line in Settings. + Future lastBackupAt() => _store.lastBackupAt(); + + /// Makes a fresh copy when at least [interval] has passed since the last one. + /// Returns true when it wrote a copy, false when not due (or on any failure — + /// a backup must never crash startup or the move to the background). + Future runIfDue({Duration interval = const Duration(days: 7)}) async { + try { + final now = _now(); + final last = await _store.lastBackupAt(); + if (last != null && now.difference(last) < interval) return false; + + final dir = await _directory(); + await dir.create(recursive: true); + final sealed = await _exporter.buildSealedBackup(); + final date = now.toIso8601String().substring(0, 10); + final file = File('${dir.path}/$_prefix$date.tanemaki'); + await file.writeAsBytes(sealed, flush: true); + + await _rotate(dir); + await _store.markBackupAt(now); + return true; + } on Object { + // Silent net: a failed copy is logged nowhere sensitive and never thrown. + return false; + } + } + + /// Keeps the newest [_keep] automatic copies; deletes the rest. Filenames end + /// in an ISO date, so a lexical sort is chronological. + Future _rotate(Directory dir) async { + final copies = + (await dir.list().toList()) + .whereType() + .where((f) => f.uri.pathSegments.last.startsWith(_prefix)) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + final excess = copies.length - _keep; + if (excess <= 0) return; + for (final stale in copies.take(excess)) { + await stale.delete(); + } + } +} diff --git a/apps/app_seeds/lib/services/auto_backup_store.dart b/apps/app_seeds/lib/services/auto_backup_store.dart new file mode 100644 index 0000000..69bba8c --- /dev/null +++ b/apps/app_seeds/lib/services/auto_backup_store.dart @@ -0,0 +1,24 @@ +import '../security/secret_store.dart'; + +/// Remembers when the last automatic backup ran, so the app only makes a fresh +/// copy once the interval has elapsed. Backed by the OS keystore (via +/// [SecretStore]) to honour the "no plaintext at rest" rule — mirrors +/// [OnboardingStore]; there is no shared_preferences here. +class AutoBackupStore { + AutoBackupStore(this._store); + + final SecretStore _store; + + static const _lastKey = 'tane.auto_backup.last'; + + /// When the last automatic backup succeeded, or null if none has run yet. + Future lastBackupAt() async { + final raw = await _store.read(_lastKey); + if (raw == null) return null; + return DateTime.tryParse(raw); + } + + /// Records that an automatic backup succeeded at [when]. + Future markBackupAt(DateTime when) => + _store.write(_lastKey, when.toIso8601String()); +} diff --git a/apps/app_seeds/lib/services/export_import_service.dart b/apps/app_seeds/lib/services/export_import_service.dart index 0981682..bda48e7 100644 --- a/apps/app_seeds/lib/services/export_import_service.dart +++ b/apps/app_seeds/lib/services/export_import_service.dart @@ -49,15 +49,22 @@ class ExportImportService { /// The backup file extension. The content is the sealed interchange JSON. static const backupExtension = 'tanemaki'; - /// Exports the full inventory as a sealed backup file. Returns true when - /// saved, false when the user cancelled the save dialog. - Future exportBackup() async { + /// Builds the full inventory as a sealed (encrypted) backup — the same bytes + /// [exportBackup] writes to a file. Shared with the automatic backup so both + /// paths seal identically. + Future buildSealedBackup() async { final snapshot = await _repository.exportInventory(); final plain = utf8.encode(_jsonCodec.encode(snapshot)); - final sealed = await BackupBox().seal( + return BackupBox().seal( plain, rootSeed: _hexToBytes(await _keys.rootSeedHex()), ); + } + + /// Exports the full inventory as a sealed backup file. Returns true when + /// saved, false when the user cancelled the save dialog. + Future exportBackup() async { + final sealed = await buildSealedBackup(); final path = await _files.saveFile( suggestedName: _fileName(backupExtension), bytes: sealed, diff --git a/apps/app_seeds/lib/ui/auto_backup_gate.dart b/apps/app_seeds/lib/ui/auto_backup_gate.dart new file mode 100644 index 0000000..8fb514c --- /dev/null +++ b/apps/app_seeds/lib/ui/auto_backup_gate.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; + +import '../services/auto_backup_service.dart'; + +/// Drives the automatic backup off the app lifecycle: once on startup and again +/// each time the app becomes hidden (backgrounded on mobile, window hidden on +/// desktop). The [AutoBackupService] decides whether a copy is actually due, so +/// these are cheap no-ops most of the time. +/// +/// When [service] is null (widget tests, or platforms without file storage such +/// as web) it wraps [child] untouched and does nothing. +class AutoBackupGate extends StatefulWidget { + const AutoBackupGate({required this.child, this.service, super.key}); + + final Widget child; + final AutoBackupService? service; + + @override + State createState() => _AutoBackupGateState(); +} + +class _AutoBackupGateState extends State { + AppLifecycleListener? _listener; + + @override + void initState() { + super.initState(); + final service = widget.service; + if (service == null) return; + _listener = AppLifecycleListener(onHide: () => service.runIfDue()); + WidgetsBinding.instance.addPostFrameCallback((_) => service.runIfDue()); + } + + @override + void dispose() { + _listener?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/app_seeds/lib/ui/backup_section.dart b/apps/app_seeds/lib/ui/backup_section.dart index 7278d95..f54b46b 100644 --- a/apps/app_seeds/lib/ui/backup_section.dart +++ b/apps/app_seeds/lib/ui/backup_section.dart @@ -1,10 +1,12 @@ import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; import '../data/export_import/inventory_snapshot.dart'; import '../di/injector.dart'; import '../i18n/strings.g.dart'; +import '../services/auto_backup_service.dart'; import '../services/export_import_service.dart'; import '../services/recovery_sheet_service.dart'; @@ -15,21 +17,32 @@ import '../services/recovery_sheet_service.dart'; /// for confirmation first (it merges into the live inventory), then reports /// what happened in a SnackBar. class BackupSection extends StatelessWidget { - const BackupSection({this.service, this.sheet, super.key}); + const BackupSection({this.service, this.sheet, this.autoBackup, super.key}); /// Injectable for widget tests; the app resolves them lazily (on tap) from /// the service locator so merely mounting Settings needs no DI setup. final ExportImportService? service; final RecoverySheetService? sheet; + /// Drives the reassurance line about automatic copies. When null (and none is + /// registered — e.g. web) the line is simply hidden. + final AutoBackupService? autoBackup; + ExportImportService get _service => service ?? getIt(); RecoverySheetService get _sheet => sheet ?? getIt(); + AutoBackupService? get _autoBackup => + autoBackup ?? + (getIt.isRegistered() + ? getIt() + : null); + @override Widget build(BuildContext context) { final t = context.t; return Column( children: [ + _AutoBackupTile(service: _autoBackup), ListTile( leading: const Icon(Icons.save_alt_outlined), title: Text(t.backup.exportJson), @@ -299,3 +312,37 @@ class BackupSection extends StatelessWidget { messenger.showSnackBar(SnackBar(content: Text(message))); } } + +/// A quiet reassurance line: the app already keeps copies on its own, and this +/// says when the last one was made. Hidden entirely when there is no automatic +/// backup on this platform. +class _AutoBackupTile extends StatelessWidget { + const _AutoBackupTile({required this.service}); + + final AutoBackupService? service; + + @override + Widget build(BuildContext context) { + final service = this.service; + if (service == null) return const SizedBox.shrink(); + final t = context.t; + return FutureBuilder( + future: service.lastBackupAt(), + builder: (context, snapshot) { + final last = snapshot.data; + final subtitle = last == null + ? t.backup.autoBackupNone + : t.backup.autoBackupLast( + date: DateFormat.yMMMd( + LocaleSettings.currentLocale.languageCode, + ).format(last), + ); + return ListTile( + leading: const Icon(Icons.shield_outlined), + title: Text(t.backup.autoBackupTitle), + subtitle: Text(subtitle), + ); + }, + ); + } +} diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index e722c3b..16eea04 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: # i18n (Weblate-friendly JSON per locale). slang: ^4.7.0 slang_flutter: ^4.7.0 + # ICU date/number formatting (locale-aware, never ad-hoc). + intl: ^0.20.2 # Secure key storage (OS keystore). flutter_secure_storage: ^9.2.4 diff --git a/apps/app_seeds/test/services/auto_backup_service_test.dart b/apps/app_seeds/test/services/auto_backup_service_test.dart new file mode 100644 index 0000000..3ff4e08 --- /dev/null +++ b/apps/app_seeds/test/services/auto_backup_service_test.dart @@ -0,0 +1,152 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:commons_core/commons_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/security/secure_key_store.dart'; +import 'package:tane/services/auto_backup_service.dart'; +import 'package:tane/services/auto_backup_store.dart'; +import 'package:tane/services/export_import_service.dart'; +import 'package:tane/services/file_service.dart'; + +import '../support/test_support.dart'; + +/// [FileService] the exporter never actually calls for [buildSealedBackup]; the +/// automatic path writes files itself. +class _UnusedFiles implements FileService { + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async => null; + + @override + Future pickFileBytes({List? allowedExtensions}) async => + null; +} + +/// An exporter whose backup build always fails, to prove failures stay silent. +class _ThrowingExporter extends ExportImportService { + _ThrowingExporter(AppDatabase db) + : super( + repository: newTestRepository(db), + files: _UnusedFiles(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ); + + @override + Future buildSealedBackup() => throw Exception('disk full'); +} + +void main() { + late AppDatabase db; + late Directory dir; + + setUp(() async { + db = newTestDatabase(); + dir = await Directory.systemTemp.createTemp('tane_auto_backup_test'); + }); + tearDown(() async { + await db.close(); + if (dir.existsSync()) await dir.delete(recursive: true); + }); + + ExportImportService newExporter() => ExportImportService( + repository: newTestRepository(db), + files: _UnusedFiles(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ); + + AutoBackupService newService( + ExportImportService exporter, { + required AutoBackupStore store, + required DateTime now, + }) => AutoBackupService( + exporter: exporter, + store: store, + directory: () async => dir, + now: () => now, + ); + + List autoCopies() => + dir + .listSync() + .whereType() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.startsWith('tanemaki-auto-')) + .toList() + ..sort(); + + test('first run writes one sealed copy', () async { + await newTestRepository(db).addQuickVariety(label: 'Maize'); + final service = newService( + newExporter(), + store: AutoBackupStore(InMemorySecretStore()), + now: DateTime.utc(2026, 7, 10), + ); + + expect(await service.runIfDue(), isTrue); + + final copies = autoCopies(); + expect(copies, ['tanemaki-auto-2026-07-10.tanemaki']); + final bytes = File('${dir.path}/${copies.single}').readAsBytesSync(); + expect(BackupBox.looksSealed(bytes), isTrue); + }); + + test('a second run within the interval is a no-op', () async { + final store = AutoBackupStore(InMemorySecretStore()); + await store.markBackupAt(DateTime.utc(2026, 7, 8)); + final service = newService( + newExporter(), + store: store, + now: DateTime.utc(2026, 7, 10), // 2 days later < 7-day interval + ); + + expect(await service.runIfDue(), isFalse); + expect(autoCopies(), isEmpty); + }); + + test('a run after the interval writes again', () async { + final store = AutoBackupStore(InMemorySecretStore()); + await store.markBackupAt(DateTime.utc(2026, 7, 1)); + final service = newService( + newExporter(), + store: store, + now: DateTime.utc(2026, 7, 10), // 9 days later >= 7 + ); + + expect(await service.runIfDue(), isTrue); + expect(autoCopies(), ['tanemaki-auto-2026-07-10.tanemaki']); + }); + + test('rotation keeps only the newest three copies', () async { + for (final d in ['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04']) { + File('${dir.path}/tanemaki-auto-$d.tanemaki').writeAsBytesSync([0]); + } + final service = newService( + newExporter(), + store: AutoBackupStore(InMemorySecretStore()), + now: DateTime.utc(2026, 7, 10), + ); + + await service.runIfDue(); + + expect(autoCopies(), [ + 'tanemaki-auto-2020-01-03.tanemaki', + 'tanemaki-auto-2020-01-04.tanemaki', + 'tanemaki-auto-2026-07-10.tanemaki', + ]); + }); + + test('a failed backup returns false and never throws', () async { + final service = newService( + _ThrowingExporter(db), + store: AutoBackupStore(InMemorySecretStore()), + now: DateTime.utc(2026, 7, 10), + ); + + expect(await service.runIfDue(), isFalse); + expect(autoCopies(), isEmpty); + }); +} diff --git a/apps/app_seeds/test/services/auto_backup_store_test.dart b/apps/app_seeds/test/services/auto_backup_store_test.dart new file mode 100644 index 0000000..840f573 --- /dev/null +++ b/apps/app_seeds/test/services/auto_backup_store_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/auto_backup_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('lastBackupAt is null until the first backup is marked', () async { + final store = AutoBackupStore(InMemorySecretStore()); + expect(await store.lastBackupAt(), isNull); + }); + + test('markBackupAt round-trips to the second', () async { + final store = AutoBackupStore(InMemorySecretStore()); + final when = DateTime.utc(2026, 7, 10, 8, 30); + + await store.markBackupAt(when); + + expect(await store.lastBackupAt(), when); + }); +} diff --git a/apps/app_seeds/test/ui/auto_backup_gate_test.dart b/apps/app_seeds/test/ui/auto_backup_gate_test.dart new file mode 100644 index 0000000..6484e62 --- /dev/null +++ b/apps/app_seeds/test/ui/auto_backup_gate_test.dart @@ -0,0 +1,86 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/security/secure_key_store.dart'; +import 'package:tane/services/auto_backup_service.dart'; +import 'package:tane/services/auto_backup_store.dart'; +import 'package:tane/services/export_import_service.dart'; +import 'package:tane/services/file_service.dart'; +import 'package:tane/ui/auto_backup_gate.dart'; + +import '../support/test_support.dart'; + +class _NoFiles implements FileService { + @override + Future saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async => null; + + @override + Future pickFileBytes({List? allowedExtensions}) async => + null; +} + +/// Counts how many times the lifecycle asked for a backup. +class _SpyAutoBackup extends AutoBackupService { + _SpyAutoBackup(AppDatabase db) + : super( + exporter: ExportImportService( + repository: newTestRepository(db), + files: _NoFiles(), + keys: SecureKeyStore(store: InMemorySecretStore()), + ), + store: AutoBackupStore(InMemorySecretStore()), + directory: () async => throw UnimplementedError(), + ); + + int calls = 0; + + @override + Future runIfDue({Duration interval = const Duration(days: 7)}) async { + calls++; + return false; + } +} + +void main() { + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('runs a backup on startup', (tester) async { + final spy = _SpyAutoBackup(db); + await tester.pumpWidget( + AutoBackupGate(service: spy, child: const SizedBox()), + ); + await tester.pump(); // let the post-frame callback fire + + expect(spy.calls, 1); + }); + + testWidgets('runs a backup again when the app is hidden', (tester) async { + final spy = _SpyAutoBackup(db); + await tester.pumpWidget( + AutoBackupGate(service: spy, child: const SizedBox()), + ); + await tester.pump(); // startup backup + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + + expect(spy.calls, 2); + }); + + testWidgets('does nothing when no service is provided', (tester) async { + await tester.pumpWidget( + const AutoBackupGate(child: Text('ok', textDirection: TextDirection.ltr)), + ); + await tester.pump(); + + expect(find.text('ok'), findsOneWidget); + }); +}