tane/apps/app_seeds/lib/services/auto_backup_service.dart
vjrj 064248144a refactor(branding): rename app Tanemaki → Tane
Public name is now Tane (種, "seed"); Tanemaki (種まき, "to scatter seeds")
is kept only as etymology in the About screen and onboarding.

App not yet distributed, so format tokens rename cleanly with no migration:
backup extension .tanemaki→.tane, QR scheme tanemaki://→tane://, file
prefixes tane-*. Website link and species-catalog User-Agent →
tane.comunes.org. Android label capitalised to "Tane".

Identity/crypto constants already use org.comunes.tane (derivation, HKDF
backup, sync namespace, TANE1 recovery tag) — left untouched, so keys,
backups and sync are unaffected. Tests updated; analyze + full suite green.
2026-07-12 13:04:16 +02:00

92 lines
3.4 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 = 'tane-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 last = await _store.lastBackupAt();
if (last != null && _now().difference(last) < interval) return false;
} on Object {
return false;
}
return backupNow();
}
/// Makes a copy right now, ignoring the schedule — for an explicit "back up
/// now" tap. Returns true on success, false on any failure (never throws).
Future<bool> backupNow() async {
try {
final now = _now();
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.tane');
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();
}
}
}