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.
83 lines
3.1 KiB
Dart
83 lines
3.1 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|