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:
parent
cf6975b748
commit
9c449964fd
19 changed files with 559 additions and 29 deletions
|
|
@ -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,6 +34,7 @@ class TaneApp extends StatelessWidget {
|
|||
this.social,
|
||||
this.socialSettings,
|
||||
this.showIntro = false,
|
||||
this.autoBackup,
|
||||
super.key,
|
||||
}) : _router = _buildRouter(
|
||||
repository,
|
||||
|
|
@ -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,6 +123,8 @@ class TaneApp extends StatelessWidget {
|
|||
RepositoryProvider.value(value: repository),
|
||||
RepositoryProvider.value(value: species),
|
||||
],
|
||||
child: AutoBackupGate(
|
||||
service: autoBackup,
|
||||
child: MaterialApp.router(
|
||||
onGenerateTitle: (context) => context.t.app.title,
|
||||
debugShowCheckedModeBanner: false,
|
||||
|
|
@ -133,6 +142,7 @@ class TaneApp extends StatelessWidget {
|
|||
],
|
||||
routerConfig: _router,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
83
apps/app_seeds/lib/services/auto_backup_service.dart
Normal file
83
apps/app_seeds/lib/services/auto_backup_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/app_seeds/lib/services/auto_backup_store.dart
Normal file
24
apps/app_seeds/lib/services/auto_backup_store.dart
Normal 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());
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
42
apps/app_seeds/lib/ui/auto_backup_gate.dart
Normal file
42
apps/app_seeds/lib/ui/auto_backup_gate.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
152
apps/app_seeds/test/services/auto_backup_service_test.dart
Normal file
152
apps/app_seeds/test/services/auto_backup_service_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
20
apps/app_seeds/test/services/auto_backup_store_test.dart
Normal file
20
apps/app_seeds/test/services/auto_backup_store_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
86
apps/app_seeds/test/ui/auto_backup_gate_test.dart
Normal file
86
apps/app_seeds/test/ui/auto_backup_gate_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue