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

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