feat(backup): sealed backups + printable recovery sheet
'Save a backup' now writes a sealed .tanemaki file (AES-256-GCM under a key HKDF-derived from the root seed; format TANEBK v1, open and stable). Restoring on the same identity is silent; a copy sealed by another identity asks for the printed recovery code (TANE1 base32, typo-tolerant) and adopts that seed — recovering your bank recovers you. Legacy plain exports still restore. Settings gains a recovery sheet (QR + code PDF). BackupBox/RecoveryCode live in commons_core, pure Dart, TDD.
This commit is contained in:
parent
ba87bf2719
commit
d6781870d9
19 changed files with 1016 additions and 81 deletions
|
|
@ -1,22 +1,29 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../data/export_import/inventory_snapshot.dart';
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/export_import_service.dart';
|
||||
import '../services/recovery_sheet_service.dart';
|
||||
|
||||
/// The backup actions shown in Settings. The primary pair — save/restore a
|
||||
/// full copy — comes first; the spreadsheet list export/import is a secondary
|
||||
/// convenience below. File formats (JSON/CSV) are an implementation detail and
|
||||
/// never surface in the UI. Restore/import asks for confirmation first (it
|
||||
/// merges into the live inventory), then reports what happened in a SnackBar.
|
||||
/// full copy — comes first, then the recovery sheet, then the spreadsheet
|
||||
/// list export/import as a secondary convenience. File formats and crypto are
|
||||
/// implementation details and never surface in the UI. Restore/import asks
|
||||
/// for confirmation first (it merges into the live inventory), then reports
|
||||
/// what happened in a SnackBar.
|
||||
class BackupSection extends StatelessWidget {
|
||||
const BackupSection({this.service, super.key});
|
||||
const BackupSection({this.service, this.sheet, super.key});
|
||||
|
||||
/// Injectable for widget tests; the app resolves it lazily (on tap) from
|
||||
/// Injectable for widget tests; the app resolves them lazily (on tap) from
|
||||
/// the service locator so merely mounting Settings needs no DI setup.
|
||||
final ExportImportService? service;
|
||||
final RecoverySheetService? sheet;
|
||||
|
||||
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
||||
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -27,13 +34,20 @@ class BackupSection extends StatelessWidget {
|
|||
leading: const Icon(Icons.save_alt_outlined),
|
||||
title: Text(t.backup.exportJson),
|
||||
subtitle: Text(t.backup.exportJsonSubtitle),
|
||||
onTap: () => _runExport(context, () => _service.exportJson()),
|
||||
onTap: () => _runExport(context, () => _service.exportBackup()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_backup_restore_outlined),
|
||||
title: Text(t.backup.importJson),
|
||||
subtitle: Text(t.backup.importJsonSubtitle),
|
||||
onTap: () => _runImport(context),
|
||||
onTap: () => _runRestore(context),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('backup.recovery'),
|
||||
leading: const Icon(Icons.qr_code_2_outlined),
|
||||
title: Text(t.backup.recoveryTitle),
|
||||
subtitle: Text(t.backup.recoverySubtitle),
|
||||
onTap: () => _showRecoverySheet(context),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
|
|
@ -66,65 +80,169 @@ class BackupSection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _runImport(BuildContext context) async {
|
||||
/// Shows this device's recovery code and offers to save the printable
|
||||
/// sheet (QR + code + instructions).
|
||||
Future<void> _showRecoverySheet(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
final code = await _service.recoveryCode();
|
||||
if (!context.mounted) return;
|
||||
final save = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.backup.importConfirmTitle),
|
||||
content: Text(t.backup.importConfirmBody),
|
||||
title: Text(t.backup.recoveryTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.backup.recoveryIntro),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText(
|
||||
code,
|
||||
key: const Key('backup.recoveryCode'),
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
key: const Key('backup.recoveryCopy'),
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: code));
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext, false);
|
||||
},
|
||||
child: Text(t.backup.recoveryCopy),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(t.backup.importAction),
|
||||
key: const Key('backup.recoverySave'),
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(t.backup.recoverySave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
if (save != true) return;
|
||||
try {
|
||||
final summary = await _service.importJson();
|
||||
_show(
|
||||
messenger,
|
||||
summary == null
|
||||
? t.backup.cancelled
|
||||
: t.backup.importDone(
|
||||
added: summary.inserted,
|
||||
updated: summary.updated,
|
||||
),
|
||||
final saved = await _sheet.saveSheet(
|
||||
title: t.backup.recoverySheetTitle,
|
||||
intro: t.backup.recoveryIntro,
|
||||
code: code,
|
||||
suggestedName: 'tanemaki-recovery.pdf',
|
||||
);
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.importFailed);
|
||||
_show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled);
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runImportCsv(BuildContext context) async {
|
||||
Future<void> _runRestore(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
title: t.backup.importConfirmTitle,
|
||||
body: t.backup.importConfirmBody,
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
final PendingBackup? pending;
|
||||
try {
|
||||
pending = await _service.pickBackup();
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
return;
|
||||
}
|
||||
if (pending == null) {
|
||||
_show(messenger, t.backup.cancelled);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_report(messenger, t, await _service.restoreBackup(pending));
|
||||
return;
|
||||
} on BackupAuthException {
|
||||
// Sealed under another identity: ask for the printed code below.
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.importFailed);
|
||||
return;
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
final code = await _askRecoveryCode(context);
|
||||
if (code == null) {
|
||||
_show(messenger, t.backup.cancelled);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_report(
|
||||
messenger,
|
||||
t,
|
||||
await _service.restoreBackup(pending, recoveryCode: code),
|
||||
);
|
||||
} on BackupAuthException {
|
||||
_show(messenger, t.backup.recoveryWrongCode);
|
||||
} on FormatException {
|
||||
_show(messenger, t.backup.recoveryWrongCode);
|
||||
} on Object {
|
||||
_show(messenger, t.backup.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the code printed on the recovery sheet.
|
||||
Future<String?> _askRecoveryCode(BuildContext context) {
|
||||
final t = context.t;
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.backup.importCsvConfirmTitle),
|
||||
content: Text(t.backup.importCsvConfirmBody),
|
||||
title: Text(t.backup.recoveryPromptTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.backup.recoveryPromptBody),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('backup.recoveryField'),
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.backup.recoveryTitle,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
key: const Key('backup.recoveryConfirm'),
|
||||
onPressed: () =>
|
||||
Navigator.pop(dialogContext, controller.text.trim()),
|
||||
child: Text(t.backup.importAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runImportCsv(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
title: t.backup.importCsvConfirmTitle,
|
||||
body: t.backup.importCsvConfirmBody,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
final summary = await _service.importCsv();
|
||||
|
|
@ -141,6 +259,42 @@ class BackupSection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Future<bool?> _confirm(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String body,
|
||||
}) {
|
||||
final t = context.t;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(t.backup.importAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _report(
|
||||
ScaffoldMessengerState messenger,
|
||||
Translations t,
|
||||
ImportSummary summary,
|
||||
) {
|
||||
_show(
|
||||
messenger,
|
||||
t.backup.importDone(added: summary.inserted, updated: summary.updated),
|
||||
);
|
||||
}
|
||||
|
||||
void _show(ScaffoldMessengerState messenger, String message) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue