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
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue