feat(backup): add automatic weekly encrypted backups

A silent safety net against in-app data loss or DB corruption, with zero
friction. Driven off the app lifecycle (startup + when hidden), it writes a
sealed .tanemaki copy into the app's private storage once a week, keeping the
newest 3 (rotating). Reuses the manual backup's encryption via the extracted
ExportImportService.buildSealedBackup(); never throws, so a failed copy can't
crash startup or backgrounding. Not registered on web (no file storage).

Settings shows a quiet reassurance line with the date of the last automatic
copy (localised via intl), hidden where no automatic backup exists.
This commit is contained in:
vjrj 2026-07-10 10:17:36 +02:00
parent cf6975b748
commit 9c449964fd
19 changed files with 559 additions and 29 deletions

View file

@ -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,
),
),
);
}

View file

@ -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<void> configureDependencies() async {
..registerSingleton<RecoverySheetService>(
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>(
AutoBackupService(
exporter: getIt<ExportImportService>(),
store: AutoBackupStore(secretStore),
directory: _backupsDir,
),
);
}
}
Future<Directory> _backupsDir() async {
final dir = await getApplicationSupportDirectory();
return Directory(p.join(dir.path, 'backups'));
}
Future<File> _databaseFile() async {

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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<void> main() async {
social: getIt<SocialService>(),
socialSettings: getIt<SocialSettings>(),
showIntro: !await onboarding.introSeen(),
autoBackup: getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>()
: null,
),
),
);

View file

@ -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<Directory> Function() directory,
DateTime Function() now = DateTime.now,
}) : _exporter = exporter,
_store = store,
_directory = directory,
_now = now;
final ExportImportService _exporter;
final AutoBackupStore _store;
final Future<Directory> 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<DateTime?> 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<bool> 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<void> _rotate(Directory dir) async {
final copies =
(await dir.list().toList())
.whereType<File>()
.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();
}
}
}

View file

@ -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<DateTime?> 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<void> markBackupAt(DateTime when) =>
_store.write(_lastKey, when.toIso8601String());
}

View file

@ -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<bool> 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<Uint8List> 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<bool> exportBackup() async {
final sealed = await buildSealedBackup();
final path = await _files.saveFile(
suggestedName: _fileName(backupExtension),
bytes: sealed,

View file

@ -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<AutoBackupGate> createState() => _AutoBackupGateState();
}
class _AutoBackupGateState extends State<AutoBackupGate> {
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;
}

View file

@ -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<ExportImportService>();
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
AutoBackupService? get _autoBackup =>
autoBackup ??
(getIt.isRegistered<AutoBackupService>()
? getIt<AutoBackupService>()
: 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<DateTime?>(
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),
);
},
);
}
}

View file

@ -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

View file

@ -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<String?> saveFile({
required String suggestedName,
required Uint8List bytes,
}) async => null;
@override
Future<Uint8List?> pickFileBytes({List<String>? 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<Uint8List> 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<String> autoCopies() =>
dir
.listSync()
.whereType<File>()
.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);
});
}

View file

@ -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);
});
}

View file

@ -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<String?> saveFile({
required String suggestedName,
required Uint8List bytes,
}) async => null;
@override
Future<Uint8List?> pickFileBytes({List<String>? 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<bool> 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);
});
}