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
0e7eb22c26
commit
39adc423b9
19 changed files with 559 additions and 29 deletions
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