mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
feat: JSON export/import on device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
This commit is contained in:
parent
e75e2bc89a
commit
1b5e201136
@ -1,11 +1,6 @@
|
||||
/// App settings: kcal goal, notifications, and links to the two sync
|
||||
/// surfaces.
|
||||
///
|
||||
/// "Sync settings" is the shared `sync_settings_ui` package (Firebase
|
||||
/// only -- diet_guard has no local backup format, so `BackupSlot` is null).
|
||||
/// "Advanced sync (GitHub)" stays app-local ([GitHubMirrorScreen]) because
|
||||
/// connecting it also triggers a real log sync via `runSync`, which the
|
||||
/// shared package's Firebase section does not do -- it only saves settings.
|
||||
/// App settings: kcal goal, notifications, data export/import, and links to
|
||||
/// the shared Firebase "Sync settings" and the app-local GitHub mirror (its
|
||||
/// connect also runs a real `runSync`, which the shared screen does not).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
@ -21,6 +16,7 @@ import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/google_sign_in_backend.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/ui/theme.dart';
|
||||
import 'package:diet_guard_app/widgets/data_backup_row.dart';
|
||||
import 'package:diet_guard_app/widgets/meal_schedule_editor.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -203,6 +199,11 @@ class _SettingsScreenState extends State<SettingsScreen>
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => unawaited(_openGitHubMirror()),
|
||||
),
|
||||
DataBackupRow(
|
||||
onImported: () => _kcalGoalController.text = AppSettingsService
|
||||
.dailyKcalGoal
|
||||
.toString(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
144
app/lib/services/data_export.dart
Normal file
144
app/lib/services/data_export.dart
Normal file
@ -0,0 +1,144 @@
|
||||
/// User-owned export and merge-only import of every synced document.
|
||||
///
|
||||
/// Import is `mergeLogs` plus the sync tick's own write-back, so it never
|
||||
/// deletes and a repeat is a no-op. The catering login (a password) travels
|
||||
/// only as an AEAD-sealed `kuchnia` section; see `data_export_kuchnia.dart`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/data_export_kuchnia.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring_default.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_budget_local.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_body.dart';
|
||||
|
||||
/// The `format` marker an export carries.
|
||||
const exportFormat = 'diet_guard_export';
|
||||
|
||||
/// The newest export layout this build reads and writes.
|
||||
const exportVersion = 1;
|
||||
|
||||
class _Document {
|
||||
const _Document(this.key, this.readLocal, this.apply);
|
||||
|
||||
final String key;
|
||||
final Future<Log> Function() readLocal;
|
||||
final Future<void> Function(Log merged) apply;
|
||||
}
|
||||
|
||||
Future<void> _applyFoodLog(Log merged) async {
|
||||
final log = logToDayLog(merged);
|
||||
await LogStorageService.instance.writeLog(log);
|
||||
await FoodBankService.instance.rebuildAndPersist(log);
|
||||
}
|
||||
|
||||
Future<Log> _readBody() async =>
|
||||
BodyService.isInitialized ? bodyToLog(BodyService.instance.document) : {};
|
||||
|
||||
Future<void> _applyBody(Log merged) async {
|
||||
if (merged.isEmpty || !BodyService.isInitialized) return;
|
||||
await BodyService.instance.applyMerged(logToBody(merged));
|
||||
}
|
||||
|
||||
/// Food log first: applying it rebuilds the derived bank merged next.
|
||||
final List<_Document> _documents = [
|
||||
_Document(
|
||||
'food_log',
|
||||
() async => dayLogToLog(await LogStorageService.instance.readLog()),
|
||||
_applyFoodLog,
|
||||
),
|
||||
_Document(
|
||||
'food_bank',
|
||||
() async => foodBankToLog(await FoodBankService.instance.readBank()),
|
||||
(merged) => FoodBankService.instance.writeBank(logToFoodBank(merged)),
|
||||
),
|
||||
_Document(
|
||||
'food_bank_manual',
|
||||
() async =>
|
||||
manualBankToLog(await FoodBankService.instance.readManualBank()),
|
||||
(merged) =>
|
||||
FoodBankService.instance.applyMergedManualBank(logToManualBank(merged)),
|
||||
),
|
||||
_Document('budget', () async => localBudgetLog(), applyBudgetLog),
|
||||
const _Document('body', _readBody, _applyBody),
|
||||
];
|
||||
|
||||
Map<String, Object?> _encodeLog(Log log) => {
|
||||
for (final entry in log.entries) entry.key: entry.value.toJson(),
|
||||
};
|
||||
|
||||
Log _decodeLog(String key, Object? payload) {
|
||||
if (payload is! Map) throw FormatException('"$key" is not an object');
|
||||
try {
|
||||
return {
|
||||
for (final entry in payload.entries)
|
||||
entry.key as String: Record.fromJson(
|
||||
(entry.value as Map).cast<String, dynamic>(),
|
||||
),
|
||||
};
|
||||
} on Object catch (error) {
|
||||
throw FormatException('"$key" has a malformed record: $error');
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns every synced document as one pretty-printed JSON export.
|
||||
///
|
||||
/// The encrypted login section uses the newest key of [keyring] (default: the
|
||||
/// OS-keystore ring).
|
||||
Future<String> exportAllData({DateTime? now, ExportKeyring? keyring}) async {
|
||||
final documents = <String, Object?>{
|
||||
for (final doc in _documents) doc.key: _encodeLog(await doc.readLocal()),
|
||||
};
|
||||
return const JsonEncoder.withIndent(' ').convert({
|
||||
'format': exportFormat,
|
||||
'version': exportVersion,
|
||||
'exportedAt': (now ?? DateTime.now()).toIso8601String(),
|
||||
'documents': documents,
|
||||
...await kuchniaExportEntries(keyring ?? defaultKeyring()),
|
||||
});
|
||||
}
|
||||
|
||||
/// Merges an [exportAllData] payload into the local store.
|
||||
///
|
||||
/// Parses every document before writing any, so a bad file changes nothing.
|
||||
/// Throws [FormatException] when [text] is not a diet_guard export. A sealed
|
||||
/// login that cannot be opened (unknown key id even after [pullKeys], or
|
||||
/// tampered data) is skipped after the rest merged, then reported as
|
||||
/// [KuchniaImportException]. An existing local login is kept.
|
||||
Future<void> importAllData(
|
||||
String text, {
|
||||
ExportKeyring? keyring,
|
||||
Future<void> Function() pullKeys = pullExportKeys,
|
||||
}) async {
|
||||
final Object? raw;
|
||||
try {
|
||||
raw = jsonDecode(text);
|
||||
} on FormatException {
|
||||
throw const FormatException('not valid JSON');
|
||||
}
|
||||
if (raw is! Map || raw['format'] != exportFormat) {
|
||||
throw const FormatException('not a diet_guard export');
|
||||
}
|
||||
final version = raw['version'];
|
||||
final documents = raw['documents'];
|
||||
if (version is! int || version > exportVersion || documents is! Map) {
|
||||
throw const FormatException('unsupported diet_guard export');
|
||||
}
|
||||
final incoming = <_Document, Log>{
|
||||
for (final doc in _documents)
|
||||
if (documents[doc.key] != null)
|
||||
doc: _decodeLog(doc.key, documents[doc.key]),
|
||||
};
|
||||
final before = {for (final doc in incoming.keys) doc: await doc.readLocal()};
|
||||
for (final MapEntry(key: doc, value: log) in incoming.entries) {
|
||||
final local = mergeLogs(before[doc]!, await doc.readLocal());
|
||||
await doc.apply(mergeLogs(local, log));
|
||||
}
|
||||
await importKuchnia(raw, keyring ?? defaultKeyring(), pullKeys);
|
||||
}
|
||||
107
app/lib/services/data_export_kuchnia.dart
Normal file
107
app/lib/services/data_export_kuchnia.dart
Normal file
@ -0,0 +1,107 @@
|
||||
/// The catering login inside the data export, as an encrypted section.
|
||||
///
|
||||
/// The password is never written in clear: the whole credential document is
|
||||
/// sealed with AES-256-GCM under the newest key of the export keyring (see
|
||||
/// `export_keyring.dart`), and the section names that key by `kid`. If the
|
||||
/// keystore is unavailable the section is omitted and `kuchnia_skipped` says
|
||||
/// so; there is no plaintext fallback.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/services/export_crypto.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
||||
|
||||
/// Export key holding the sealed login.
|
||||
const kuchniaKey = 'kuchnia';
|
||||
|
||||
/// Export key explaining why [kuchniaKey] is absent.
|
||||
const kuchniaSkippedKey = 'kuchnia_skipped';
|
||||
|
||||
/// A login in the file could not be restored; the rest of the import did run.
|
||||
class KuchniaImportException implements Exception {
|
||||
/// Creates the exception; [reason] is `unknown_key` or `unreadable`.
|
||||
const KuchniaImportException(this.reason);
|
||||
|
||||
/// Why the section was skipped.
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
String toString() => 'KuchniaImportException: $reason';
|
||||
}
|
||||
|
||||
/// Top-level export entries for the login: the sealed box, or the skip flag.
|
||||
Future<Map<String, Object?>> kuchniaExportEntries(ExportKeyring keyring) async {
|
||||
if (!KuchniaCredentialService.isInitialized ||
|
||||
!KuchniaCredentialService.isConfigured) {
|
||||
return {};
|
||||
}
|
||||
final ExportKey key;
|
||||
try {
|
||||
key = await keyring.ensureNewest();
|
||||
} on Object {
|
||||
return {kuchniaSkippedKey: 'keystore_unavailable'};
|
||||
}
|
||||
final clear = jsonEncode({
|
||||
'username': KuchniaCredentialService.username,
|
||||
'password': KuchniaCredentialService.password,
|
||||
't': KuchniaCredentialService.editedAt,
|
||||
});
|
||||
return {kuchniaKey: await encryptWithKey(clear, key)};
|
||||
}
|
||||
|
||||
/// The key for [kid]; pulls the synced keyring once if it is not held yet.
|
||||
Future<ExportKey?> _findKey(
|
||||
ExportKeyring keyring,
|
||||
String kid,
|
||||
Future<void> Function() pullKeys,
|
||||
) async {
|
||||
final held = await keyring.byId(kid);
|
||||
if (held != null) return held;
|
||||
try {
|
||||
await pullKeys();
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
final pulled = await keyring.byId(kid);
|
||||
return pulled;
|
||||
}
|
||||
|
||||
/// Opens the section from [root] and writes it only into an empty login.
|
||||
///
|
||||
/// Throws [KuchniaImportException] when it is present but cannot be opened.
|
||||
/// A configured local login is never overwritten.
|
||||
Future<void> importKuchnia(
|
||||
Map<dynamic, dynamic> root,
|
||||
ExportKeyring keyring,
|
||||
Future<void> Function() pullKeys,
|
||||
) async {
|
||||
final box = root[kuchniaKey];
|
||||
if (box == null) return;
|
||||
final kid = envelopeKeyId(box);
|
||||
final Map<dynamic, dynamic> doc;
|
||||
try {
|
||||
final key = kid == null ? null : await _findKey(keyring, kid, pullKeys);
|
||||
if (key == null) throw const KuchniaImportException('unknown_key');
|
||||
doc = jsonDecode(await decryptWithKey(box, key)) as Map;
|
||||
} on KuchniaImportException {
|
||||
rethrow;
|
||||
} on Object {
|
||||
throw const KuchniaImportException('unreadable');
|
||||
}
|
||||
final username = doc['username'];
|
||||
final password = doc['password'];
|
||||
if (username is! String || password is! String) {
|
||||
throw const KuchniaImportException('unreadable');
|
||||
}
|
||||
if (!KuchniaCredentialService.isInitialized ||
|
||||
KuchniaCredentialService.isConfigured) {
|
||||
return;
|
||||
}
|
||||
await KuchniaCredentialService.instance.applySynced(
|
||||
username,
|
||||
password,
|
||||
doc['t'] is String ? doc['t'] as String : '',
|
||||
);
|
||||
}
|
||||
100
app/lib/services/export_crypto.dart
Normal file
100
app/lib/services/export_crypto.dart
Normal file
@ -0,0 +1,100 @@
|
||||
/// AES-256-GCM sealing for the data export, keyed by an [ExportKey].
|
||||
///
|
||||
/// Pure Dart (`package:cryptography`), no `dart:io`, so it runs on Android and
|
||||
/// in the web build alike. Nothing here falls back to plaintext: any failure
|
||||
/// is an [ExportCryptoException].
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
|
||||
/// The `enc` marker of the only envelope layout this build reads and writes.
|
||||
const encryptionVersion = 'v2';
|
||||
|
||||
const _nonceBytes = 12;
|
||||
const _macBytes = 16;
|
||||
|
||||
/// Binds a ciphertext to its role and key id, so it cannot be replayed.
|
||||
List<int> _aad(String kid) =>
|
||||
utf8.encode('diet_guard_export:kuchnia:$encryptionVersion:$kid');
|
||||
|
||||
/// Wrong key, tampered or malformed envelope.
|
||||
class ExportCryptoException implements Exception {
|
||||
/// Creates the exception.
|
||||
const ExportCryptoException(this.message);
|
||||
|
||||
/// What was wrong; never contains key material.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => 'ExportCryptoException: $message';
|
||||
}
|
||||
|
||||
/// Seals [plaintext] under [key] with a fresh random nonce.
|
||||
///
|
||||
/// `ciphertext` is the encrypted bytes followed by the 16-byte GCM tag.
|
||||
Future<Map<String, Object?>> encryptWithKey(
|
||||
String plaintext,
|
||||
ExportKey key,
|
||||
) async {
|
||||
final rng = Random.secure();
|
||||
final nonce = Uint8List.fromList([
|
||||
for (var i = 0; i < _nonceBytes; i++) rng.nextInt(256),
|
||||
]);
|
||||
final box = await AesGcm.with256bits().encrypt(
|
||||
utf8.encode(plaintext),
|
||||
secretKey: SecretKey(key.bytes),
|
||||
nonce: nonce,
|
||||
aad: _aad(key.kid),
|
||||
);
|
||||
return {
|
||||
'enc': encryptionVersion,
|
||||
'alg': 'AES-256-GCM',
|
||||
'kid': key.kid,
|
||||
'nonce': base64Encode(nonce),
|
||||
'ciphertext': base64Encode([...box.cipherText, ...box.mac.bytes]),
|
||||
};
|
||||
}
|
||||
|
||||
/// The key id an envelope was sealed with, or null when it has none.
|
||||
String? envelopeKeyId(Object? envelope) {
|
||||
if (envelope is! Map || envelope['enc'] != encryptionVersion) return null;
|
||||
final kid = envelope['kid'];
|
||||
return kid is String ? kid : null;
|
||||
}
|
||||
|
||||
/// Opens an [encryptWithKey] envelope; throws [ExportCryptoException].
|
||||
Future<String> decryptWithKey(Object? envelope, ExportKey key) async {
|
||||
final Uint8List nonce;
|
||||
final Uint8List sealed;
|
||||
try {
|
||||
final map = envelope! as Map;
|
||||
if (envelopeKeyId(map) != key.kid) throw const FormatException('kid');
|
||||
nonce = base64Decode(map['nonce']! as String);
|
||||
sealed = base64Decode(map['ciphertext']! as String);
|
||||
} on Object {
|
||||
throw const ExportCryptoException('malformed envelope');
|
||||
}
|
||||
if (nonce.length != _nonceBytes || sealed.length < _macBytes) {
|
||||
throw const ExportCryptoException('malformed envelope');
|
||||
}
|
||||
final cut = sealed.length - _macBytes;
|
||||
try {
|
||||
final clear = await AesGcm.with256bits().decrypt(
|
||||
SecretBox(
|
||||
sealed.sublist(0, cut),
|
||||
nonce: nonce,
|
||||
mac: Mac(sealed.sublist(cut)),
|
||||
),
|
||||
secretKey: SecretKey(key.bytes),
|
||||
aad: _aad(key.kid),
|
||||
);
|
||||
return utf8.decode(clear);
|
||||
} on Object {
|
||||
throw const ExportCryptoException('wrong key or tampered data');
|
||||
}
|
||||
}
|
||||
126
app/lib/services/export_keyring.dart
Normal file
126
app/lib/services/export_keyring.dart
Normal file
@ -0,0 +1,126 @@
|
||||
/// The export keyring: random 256-bit AES keys that unlock data exports.
|
||||
///
|
||||
/// Keys live in the OS keystore and also sync between devices as the
|
||||
/// `export_keys` document (see `export_keyring_sync.dart`), so an export made
|
||||
/// on one device opens on another. It is a keyring, not a single key: a key
|
||||
/// is never deleted or overwritten, each id is its own record, and two
|
||||
/// devices that mint a key at the same moment both keep both.
|
||||
///
|
||||
/// NOTE: the synced copy sits in Firebase RTDB in plaintext, so Firebase can
|
||||
/// read these keys (and therefore the sealed login in any export it could get
|
||||
/// hold of) until crdt E2E lands. `export_keys` must move under that
|
||||
/// encryption too: `~/src/utils/TODO-crdt-e2e-encryption.md`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
|
||||
/// Keystore entry holding the whole ring as JSON.
|
||||
const keyringStorageKey = 'diet_guard.export_keyring';
|
||||
|
||||
final _kidShape = RegExp(r'^[0-9a-f]{16}$');
|
||||
const _keyBytes = 32;
|
||||
|
||||
/// One export key.
|
||||
class ExportKey {
|
||||
/// Creates a key; [kid] is 16 hex chars, [bytes] 32 bytes.
|
||||
const ExportKey(this.kid, this.bytes, this.createdAt);
|
||||
|
||||
/// Random id written into every export sealed with this key.
|
||||
final String kid;
|
||||
|
||||
/// The AES-256 key.
|
||||
final List<int> bytes;
|
||||
|
||||
/// When it was minted (UTC); the newest key signs new exports.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// True when [kid] and [bytes] have the shape a real key has.
|
||||
bool get isWellFormed => _kidShape.hasMatch(kid) && bytes.length == _keyBytes;
|
||||
}
|
||||
|
||||
/// A keyring over an injected keystore.
|
||||
///
|
||||
/// Reads and writes throw when the keystore is unavailable or holds garbage;
|
||||
/// callers treat that as "no keyring" and never fall back to plaintext. A
|
||||
/// garbled ring is not replaced, since that would destroy the keys in it.
|
||||
class ExportKeyring {
|
||||
/// Creates a ring over [read]/[write] (the OS keystore in the app).
|
||||
ExportKeyring({required this.read, required this.write});
|
||||
|
||||
/// Reads a keystore entry, or null when absent.
|
||||
final SecretReader read;
|
||||
|
||||
/// Writes a keystore entry.
|
||||
final SecretWriter write;
|
||||
|
||||
/// Every key held, in no particular order.
|
||||
Future<List<ExportKey>> all() async {
|
||||
final raw = await read(keyringStorageKey);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) throw StateError('export keyring is corrupt');
|
||||
return [
|
||||
for (final MapEntry(key: kid, value: entry) in decoded.entries)
|
||||
ExportKey(
|
||||
kid as String,
|
||||
base64Decode((entry as Map)['k'] as String),
|
||||
DateTime.parse(entry['t'] as String).toUtc(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// The key with id [kid], or null when this device does not hold it.
|
||||
Future<ExportKey?> byId(String kid) async {
|
||||
for (final key in await all()) {
|
||||
if (key.kid == kid) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The newest key, minting and storing one if the ring is empty.
|
||||
Future<ExportKey> ensureNewest() async {
|
||||
var keys = await all();
|
||||
if (keys.isEmpty) {
|
||||
final rng = Random.secure();
|
||||
final fresh = ExportKey(
|
||||
[
|
||||
for (var i = 0; i < 8; i++) rng.nextInt(256),
|
||||
].map((b) => b.toRadixString(16).padLeft(2, '0')).join(),
|
||||
[for (var i = 0; i < _keyBytes; i++) rng.nextInt(256)],
|
||||
DateTime.now().toUtc(),
|
||||
);
|
||||
await mergeKeys([fresh]);
|
||||
keys = [fresh];
|
||||
}
|
||||
return keys.reduce(
|
||||
(a, b) =>
|
||||
a.createdAt.isAfter(b.createdAt) ||
|
||||
(a.createdAt == b.createdAt && a.kid.compareTo(b.kid) > 0)
|
||||
? a
|
||||
: b,
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds the well-formed keys whose id is not held yet; never overwrites.
|
||||
Future<void> mergeKeys(Iterable<ExportKey> incoming) async {
|
||||
final held = {for (final key in await all()) key.kid: key};
|
||||
final before = held.length;
|
||||
for (final key in incoming) {
|
||||
if (key.isWellFormed) held.putIfAbsent(key.kid, () => key);
|
||||
}
|
||||
if (held.length == before) return;
|
||||
await write(
|
||||
keyringStorageKey,
|
||||
jsonEncode({
|
||||
for (final key in held.values)
|
||||
key.kid: {
|
||||
'k': base64Encode(key.bytes),
|
||||
't': key.createdAt.toIso8601String(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/lib/services/export_keyring_default.dart
Normal file
50
app/lib/services/export_keyring_default.dart
Normal file
@ -0,0 +1,50 @@
|
||||
/// The app's real export keyring and its on-demand key pull.
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring_sync.dart';
|
||||
import 'package:diet_guard_app/services/firebase_client.dart';
|
||||
import 'package:diet_guard_app/services/github_client_factory.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_settings.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
// Platform-channel code (keystore, network); the logic it wires lives in
|
||||
// export_keyring*.dart and is covered there.
|
||||
// coverage:ignore-start
|
||||
const _secure = FlutterSecureStorage();
|
||||
|
||||
/// The keyring backed by the OS keystore.
|
||||
ExportKeyring defaultKeyring() => ExportKeyring(
|
||||
read: (key) => _secure.read(key: key),
|
||||
write: (key, value) => _secure.write(key: key, value: value),
|
||||
);
|
||||
|
||||
/// One sync pull of the keyring, for an import that met an unknown key id.
|
||||
///
|
||||
/// Best effort: offline or signed out just leaves the key unknown, and the
|
||||
/// import reports that.
|
||||
Future<void> pullExportKeys() async {
|
||||
try {
|
||||
final client = createGitHubClient(await SyncSettings.load());
|
||||
try {
|
||||
await syncExportKeys(
|
||||
await syncBackend(client),
|
||||
keyring: defaultKeyring(),
|
||||
devicesDir: syncDevicesDir,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'export key pull failed',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
// coverage:ignore-end
|
||||
85
app/lib/services/export_keyring_sync.dart
Normal file
85
app/lib/services/export_keyring_sync.dart
Normal file
@ -0,0 +1,85 @@
|
||||
/// The `export_keys` document: the keyring as a crdt_sync log.
|
||||
///
|
||||
/// One record per key id, never deleted, so the merge is a plain union and
|
||||
/// concurrent mints on two devices both survive. See `export_keyring.dart`
|
||||
/// for the plaintext-at-Firebase caveat.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
|
||||
const _keyField = 'k';
|
||||
const _createdField = 't';
|
||||
|
||||
Log _toLog(Iterable<ExportKey> keys, String deviceId) => {
|
||||
for (final key in keys)
|
||||
key.kid: Record(
|
||||
id: key.kid,
|
||||
fields: {
|
||||
_keyField: (
|
||||
base64Encode(key.bytes),
|
||||
Hlc.newTick(
|
||||
deviceId,
|
||||
wallTimeMsOverride: key.createdAt.millisecondsSinceEpoch,
|
||||
),
|
||||
),
|
||||
_createdField: (
|
||||
key.createdAt.toIso8601String(),
|
||||
Hlc.newTick(
|
||||
deviceId,
|
||||
wallTimeMsOverride: key.createdAt.millisecondsSinceEpoch,
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
List<ExportKey> _fromLog(Log log) {
|
||||
final keys = <ExportKey>[];
|
||||
for (final record in log.values) {
|
||||
try {
|
||||
keys.add(
|
||||
ExportKey(
|
||||
record.id,
|
||||
base64Decode(record.fields[_keyField]!.$1! as String),
|
||||
DateTime.parse(record.fields[_createdField]!.$1! as String).toUtc(),
|
||||
),
|
||||
);
|
||||
} on Object {
|
||||
continue; // a malformed peer record is skipped, never fatal
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// Pulls peers' keys into [keyring], pushes this device's keys.
|
||||
///
|
||||
/// Runs even when the ring is empty, so a fresh device still receives keys.
|
||||
Future<void> syncExportKeys(
|
||||
RemoteStore client, {
|
||||
required ExportKeyring keyring,
|
||||
required String devicesDir,
|
||||
String? deviceId,
|
||||
}) async {
|
||||
final id = deviceId ?? currentSyncDeviceId;
|
||||
final merged = await syncLog(
|
||||
client: client,
|
||||
deviceId: id,
|
||||
legacyDeviceId: deviceId == null ? legacySyncDeviceId : null,
|
||||
pathPrefix: devicesDir,
|
||||
localLog: _toLog(await keyring.all(), id),
|
||||
encode: (log) => jsonEncode({
|
||||
for (final entry in log.entries) entry.key: entry.value.toJson(),
|
||||
}),
|
||||
decode: (text) => (jsonDecode(text) as Map).cast<String, dynamic>().map(
|
||||
(kid, data) =>
|
||||
MapEntry(kid, Record.fromJson(data as Map<String, dynamic>)),
|
||||
),
|
||||
filename: 'export_keys.json',
|
||||
commitMessage: 'diet_guard_app sync',
|
||||
);
|
||||
await keyring.mergeKeys(_fromLog(merged));
|
||||
}
|
||||
49
app/lib/services/sync_budget_local.dart
Normal file
49
app/lib/services/sync_budget_local.dart
Normal file
@ -0,0 +1,49 @@
|
||||
/// Budget record local side, shared by the sync tick and the data import.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
|
||||
|
||||
/// This device's budget record; schedule-only edits stamp it at the epoch.
|
||||
Log localBudgetLog() {
|
||||
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
|
||||
final scheduleEntries = MealScheduleService.history.entries;
|
||||
final recordStamp =
|
||||
updatedAt ??
|
||||
(scheduleEntries.isEmpty ? null : MealScheduleService.updatedAt);
|
||||
final localRecord = recordStamp == null
|
||||
? null
|
||||
: <String, dynamic>{
|
||||
'v': 2,
|
||||
'b': AppSettingsService.dailyKcalGoal,
|
||||
't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
|
||||
.toIso8601String(),
|
||||
};
|
||||
return budgetToLog(
|
||||
localRecord,
|
||||
BudgetHistoryService.schedule.entries,
|
||||
scheduleEntries,
|
||||
);
|
||||
}
|
||||
|
||||
/// Applies a merged budget [Log]: history, schedule, then the scalar.
|
||||
Future<void> applyBudgetLog(Log merged) async {
|
||||
await BudgetHistoryService.instance.applyMerged(logToHistory(merged));
|
||||
if (MealScheduleService.isInitialized) {
|
||||
await MealScheduleService.instance.applyMerged(
|
||||
logToScheduleHistory(merged),
|
||||
updatedAt: MealScheduleService.updatedAt,
|
||||
);
|
||||
}
|
||||
final budget = logToBudget(merged);
|
||||
final mergedKcal = budget?['b'];
|
||||
if (budget == null || mergedKcal is! int) return;
|
||||
await AppSettingsService.instance.applySyncedBudget(
|
||||
mergedKcal,
|
||||
updatedAt: DateTime.tryParse(budget['t'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,9 @@ import 'package:diet_guard_app/services/github_client_factory.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Where every device's synced files live on the remote.
|
||||
const syncDevicesDir = 'diet-guard-sync/devices';
|
||||
|
||||
/// SharedPreferences key holding this install's device id.
|
||||
///
|
||||
/// The same key the sibling apps (`todo`, `home_inventory`, `wake_alarm`)
|
||||
|
||||
@ -12,22 +12,23 @@
|
||||
/// since a budget (unlike a food-log entry) can be edited repeatedly.
|
||||
library;
|
||||
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring_default.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring_sync.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_body.dart';
|
||||
import 'package:diet_guard_app/services/sync_budget_local.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_kuchnia.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
|
||||
import 'package:diet_guard_app/services/sync_state_factory.dart';
|
||||
|
||||
const _devicesDir = 'diet-guard-sync/devices';
|
||||
const String _devicesDir = syncDevicesDir;
|
||||
|
||||
/// Runs one full sync tick: pull, merge, persist, push.
|
||||
///
|
||||
@ -63,6 +64,7 @@ Future<DayLog> runSync(RemoteStore client, {SyncStateStore? stateStore}) async {
|
||||
await _syncFoodBank(client);
|
||||
await _syncManualBank(client);
|
||||
await _syncKuchniaCredential(client);
|
||||
await _syncExportKeys(client);
|
||||
await syncBody(client, devicesDir: _devicesDir);
|
||||
return merged;
|
||||
}
|
||||
@ -110,72 +112,37 @@ Future<void> _syncManualBank(RemoteStore client) async {
|
||||
}
|
||||
|
||||
/// Pulls other devices' budgets, merges, applies the winner locally, pushes.
|
||||
///
|
||||
/// Reuses [syncLog] (it always pushes the merged result, even an empty
|
||||
/// one -- harmless and consistent with the food-log sync above). This
|
||||
/// device contributes nothing to the merge until the goal has been
|
||||
/// explicitly set at least once (see
|
||||
/// [AppSettingsService.dailyKcalGoalUpdatedAt]), so a fresh install's
|
||||
/// unset 2200 default can never spuriously outrank a real budget synced
|
||||
/// from elsewhere.
|
||||
Future<void> _syncBudget(RemoteStore client) async {
|
||||
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
|
||||
final scheduleEntries = MealScheduleService.history.entries;
|
||||
// `budgetToLog` returns an empty Log for a null record, which would also
|
||||
// drop the `sched:` fields. A device that has edited its schedule but never
|
||||
// its budget still has something to contribute, so synthesise a record from
|
||||
// the schedule's own edit time in that case. Its `value` carries the
|
||||
// unset-default budget, but that loses every LWW race against a device that
|
||||
// has actually set one, because this stamp is older than any real edit.
|
||||
final scheduleStamp = MealScheduleService.updatedAt;
|
||||
final recordStamp =
|
||||
updatedAt ?? (scheduleEntries.isEmpty ? null : scheduleStamp);
|
||||
final localRecord = recordStamp == null
|
||||
? null
|
||||
: <String, dynamic>{
|
||||
'v': 2,
|
||||
'b': AppSettingsService.dailyKcalGoal,
|
||||
't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
|
||||
.toIso8601String(),
|
||||
};
|
||||
|
||||
final mergedBudgetLog = await syncLog(
|
||||
client: client,
|
||||
deviceId: currentSyncDeviceId,
|
||||
legacyDeviceId: legacySyncDeviceId,
|
||||
pathPrefix: _devicesDir,
|
||||
localLog: budgetToLog(
|
||||
localRecord,
|
||||
BudgetHistoryService.schedule.entries,
|
||||
scheduleEntries,
|
||||
),
|
||||
localLog: localBudgetLog(),
|
||||
encode: encodeBudgetForPush,
|
||||
decode: parseRemoteBudget,
|
||||
filename: 'budget.json',
|
||||
commitMessage: 'diet_guard_app sync',
|
||||
);
|
||||
await applyBudgetLog(mergedBudgetLog);
|
||||
}
|
||||
|
||||
// The history merges by field union, so it is applied unconditionally --
|
||||
// independently of whether the scalar budget resolved to anything.
|
||||
await BudgetHistoryService.instance.applyMerged(
|
||||
logToHistory(mergedBudgetLog),
|
||||
);
|
||||
// Same reasoning for the meal schedule: field-union merge, so applied
|
||||
// regardless of how the scalar budget resolved.
|
||||
if (MealScheduleService.isInitialized) {
|
||||
await MealScheduleService.instance.applyMerged(
|
||||
logToScheduleHistory(mergedBudgetLog),
|
||||
updatedAt: scheduleStamp,
|
||||
/// Syncs the export keyring; best effort, since sync must not fail on it.
|
||||
Future<void> _syncExportKeys(RemoteStore client) async {
|
||||
try {
|
||||
await syncExportKeys(
|
||||
client,
|
||||
keyring: defaultKeyring(),
|
||||
devicesDir: _devicesDir,
|
||||
);
|
||||
} on Object catch (error, stackTrace) {
|
||||
log(
|
||||
'export key sync failed',
|
||||
level: 900,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
final merged = logToBudget(mergedBudgetLog);
|
||||
final mergedKcal = merged?['b'];
|
||||
if (merged == null || mergedKcal is! int) return;
|
||||
await AppSettingsService.instance.applySyncedBudget(
|
||||
mergedKcal,
|
||||
updatedAt: DateTime.tryParse(merged['t'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pulls peers' catering credentials, merges, applies the winner locally.
|
||||
|
||||
107
app/lib/widgets/data_backup_row.dart
Normal file
107
app/lib/widgets/data_backup_row.dart
Normal file
@ -0,0 +1,107 @@
|
||||
/// Icon-only export and import of every synced document.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:data_transfer_flutter/data_transfer_flutter.dart';
|
||||
import 'package:diet_guard_app/services/data_export.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Suggested file name, identical on every platform.
|
||||
const exportFileName = 'diet-guard-export.json';
|
||||
|
||||
enum _Outcome { idle, ok, failed }
|
||||
|
||||
/// Export (Save-as picker / download) and merge-only import buttons.
|
||||
///
|
||||
/// Feedback is the button's own icon (check or error) plus haptics.
|
||||
class DataBackupRow extends StatefulWidget {
|
||||
/// Creates a [DataBackupRow]; [onImported] runs after a successful import.
|
||||
const DataBackupRow({super.key, this.onImported});
|
||||
|
||||
/// Called after an import merged, so the host can refresh shown values.
|
||||
final VoidCallback? onImported;
|
||||
|
||||
@override
|
||||
State<DataBackupRow> createState() => _DataBackupRowState();
|
||||
}
|
||||
|
||||
class _DataBackupRowState extends State<DataBackupRow> {
|
||||
_Outcome _export = _Outcome.idle;
|
||||
_Outcome _import = _Outcome.idle;
|
||||
Timer? _reset;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reset?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<_Outcome> _run(Future<bool> Function() action) async {
|
||||
try {
|
||||
if (!await action()) return _Outcome.idle;
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
return _Outcome.ok;
|
||||
} on Object {
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
return _Outcome.failed;
|
||||
}
|
||||
}
|
||||
|
||||
void _show(void Function() update) {
|
||||
if (!mounted) return;
|
||||
setState(update);
|
||||
_reset?.cancel();
|
||||
_reset = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) setState(() => _export = _import = _Outcome.idle);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onExport() async {
|
||||
final outcome = await _run(() async {
|
||||
final text = await exportAllData();
|
||||
return await exportJsonText(fileName: exportFileName, text: text);
|
||||
});
|
||||
_show(() => _export = outcome);
|
||||
}
|
||||
|
||||
Future<void> _onImport() async {
|
||||
final outcome = await _run(() async {
|
||||
final text = await pickJsonText();
|
||||
if (text == null) return false;
|
||||
await importAllData(text);
|
||||
return true;
|
||||
});
|
||||
if (outcome == _Outcome.ok) widget.onImported?.call();
|
||||
_show(() => _import = outcome);
|
||||
}
|
||||
|
||||
Widget _button(
|
||||
_Outcome outcome,
|
||||
IconData idle,
|
||||
String label,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (icon, color, semantic) = switch (outcome) {
|
||||
_Outcome.idle => (idle, null, label),
|
||||
_Outcome.ok => (Icons.check_circle, scheme.primary, '$label: done'),
|
||||
_Outcome.failed => (Icons.error, scheme.error, '$label: failed'),
|
||||
};
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, color: color, semanticLabel: semantic),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_button(_export, Icons.save_alt, 'Export data', _onExport),
|
||||
_button(_import, Icons.file_open, 'Import data', _onImport),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -122,8 +122,16 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
cryptography:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cryptography
|
||||
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.0"
|
||||
data_transfer_flutter:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: data_transfer_flutter
|
||||
ref: "data_transfer_flutter-v0.2.0"
|
||||
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.124+1
|
||||
version: 1.0.125+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
@ -14,6 +14,14 @@ dependencies:
|
||||
ref: crdt_sync_dart-v0.11.3
|
||||
path: crdt_sync_dart
|
||||
crypto: 3.0.8
|
||||
cryptography: 2.9.0
|
||||
|
||||
# Export/import file plumbing (Save-as picker, open picker), shared.
|
||||
data_transfer_flutter:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: data_transfer_flutter-v0.2.0
|
||||
path: data_transfer_flutter
|
||||
fl_chart: 1.3.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
@ -90,9 +90,9 @@ void main() {
|
||||
expect(ok, isTrue);
|
||||
// syncLog always pushes, even an empty merged result: food_log.json,
|
||||
// budget.json, food_bank.json, food_bank_manual.json, kuchnia.json,
|
||||
// body.json. Six data files plus this device's revision, which is what
|
||||
// lets a later tick skip an unchanged peer.
|
||||
expect(puts, 7);
|
||||
// export_keys.json, body.json. Seven data files plus this device's
|
||||
// revision, which is what lets a later tick skip an unchanged peer.
|
||||
expect(puts, 8);
|
||||
});
|
||||
|
||||
test('pushes under the persisted uuid, not the legacy role id', () async {
|
||||
|
||||
194
app/test/services/data_export_kuchnia_test.dart
Normal file
194
app/test/services/data_export_kuchnia_test.dart
Normal file
@ -0,0 +1,194 @@
|
||||
// The catering password rides in the export only as AEAD ciphertext under a
|
||||
// random export key that the keyring syncs between devices.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/services/data_export.dart';
|
||||
import 'package:diet_guard_app/services/data_export_kuchnia.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring_sync.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
import 'export_test_support.dart';
|
||||
|
||||
Future<void> _noPull() async {}
|
||||
|
||||
Future<Map<String, dynamic>> _root(MemorySecrets s) async =>
|
||||
jsonDecode(await exportAllData(keyring: s.keyring()))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
Future<Object?> _importError(
|
||||
String text,
|
||||
MemorySecrets s, {
|
||||
Future<void> Function() pull = _noPull,
|
||||
}) async {
|
||||
try {
|
||||
await importAllData(text, keyring: s.keyring(), pullKeys: pull);
|
||||
} on Object catch (error) {
|
||||
return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _tamper(String export) {
|
||||
final root = jsonDecode(export) as Map<String, dynamic>;
|
||||
final box = root['kuchnia'] as Map<String, dynamic>;
|
||||
final bytes = base64Decode(box['ciphertext'] as String);
|
||||
bytes[0] ^= 0x01;
|
||||
box['ciphertext'] = base64Encode(bytes);
|
||||
return jsonEncode(root);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('same device: login is restored into an empty local store', () async {
|
||||
final secrets = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
final stamp = KuchniaCredentialService.editedAt;
|
||||
final file = await exportAllData(keyring: secrets.keyring());
|
||||
|
||||
await bindFresh();
|
||||
expect(KuchniaCredentialService.isConfigured, isFalse);
|
||||
await importAllData(file, keyring: secrets.keyring(), pullKeys: _noPull);
|
||||
expect(KuchniaCredentialService.username, testMail);
|
||||
expect(KuchniaCredentialService.password, testPassword);
|
||||
expect(KuchniaCredentialService.editedAt, stamp);
|
||||
});
|
||||
|
||||
test('envelope: v2 AES-256-GCM, kid, fresh nonce, no KDF', () async {
|
||||
final secrets = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
final a = (await _root(secrets))['kuchnia'] as Map<String, dynamic>;
|
||||
final b = (await _root(secrets))['kuchnia'] as Map<String, dynamic>;
|
||||
expect(a['enc'], 'v2');
|
||||
expect(a['alg'], 'AES-256-GCM');
|
||||
expect(a['kid'], b['kid']);
|
||||
expect(a.containsKey('kdf'), isFalse);
|
||||
expect(base64Decode(a['nonce'] as String), hasLength(12));
|
||||
expect(a['nonce'], isNot(b['nonce']));
|
||||
expect(a['ciphertext'], isNot(b['ciphertext']));
|
||||
expect(secrets.data, hasLength(1));
|
||||
});
|
||||
|
||||
test('export contains neither the plaintext login nor the key', () async {
|
||||
final secrets = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
final file = await exportAllData(keyring: secrets.keyring());
|
||||
final key = (await secrets.keyring().ensureNewest()).bytes;
|
||||
expect(file, isNot(contains(testPassword)));
|
||||
expect(file, isNot(contains(testMail)));
|
||||
expect(file, isNot(contains(base64Encode(utf8.encode(testPassword)))));
|
||||
expect(file, isNot(contains(base64Encode(key))));
|
||||
expect(
|
||||
file,
|
||||
isNot(
|
||||
contains(key.map((b) => b.toRadixString(16).padLeft(2, '0')).join()),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('second device imports after its keyring sync', () async {
|
||||
final remote = MemoryRemote();
|
||||
final a = MemorySecrets();
|
||||
final b = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
await seedMeal('oats');
|
||||
final file = await exportAllData(keyring: a.keyring());
|
||||
await syncExportKeys(
|
||||
remote,
|
||||
keyring: a.keyring(),
|
||||
devicesDir: 'devs',
|
||||
deviceId: 'dev-a',
|
||||
);
|
||||
|
||||
await bindFresh();
|
||||
var pulls = 0;
|
||||
await importAllData(
|
||||
file,
|
||||
keyring: b.keyring(),
|
||||
pullKeys: () async {
|
||||
pulls++;
|
||||
await syncExportKeys(
|
||||
remote,
|
||||
keyring: b.keyring(),
|
||||
devicesDir: 'devs',
|
||||
deviceId: 'dev-b',
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(pulls, 1);
|
||||
expect(KuchniaCredentialService.password, testPassword);
|
||||
expect(await meals(), contains('oats'));
|
||||
});
|
||||
|
||||
test('unknown kid: reported cleanly, rest still imports', () async {
|
||||
final a = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
await seedMeal('soup');
|
||||
final file = await exportAllData(keyring: a.keyring());
|
||||
|
||||
await bindFresh();
|
||||
final error = await _importError(file, MemorySecrets());
|
||||
expect(error, isA<KuchniaImportException>());
|
||||
expect(KuchniaCredentialService.isConfigured, isFalse);
|
||||
expect(await meals(), contains('soup'));
|
||||
});
|
||||
|
||||
test('a failing key pull is the same clean failure', () async {
|
||||
final a = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
final file = await exportAllData(keyring: a.keyring());
|
||||
await bindFresh();
|
||||
final error = await _importError(
|
||||
file,
|
||||
MemorySecrets(),
|
||||
pull: () async => throw StateError('offline'),
|
||||
);
|
||||
expect(error, isA<KuchniaImportException>());
|
||||
});
|
||||
|
||||
test('tampered ciphertext is rejected, rest still imports', () async {
|
||||
final a = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
await seedMeal('rice');
|
||||
final file = _tamper(await exportAllData(keyring: a.keyring()));
|
||||
|
||||
await bindFresh();
|
||||
final error = await _importError(file, a);
|
||||
expect(error, isA<KuchniaImportException>());
|
||||
expect(KuchniaCredentialService.isConfigured, isFalse);
|
||||
expect(await meals(), contains('rice'));
|
||||
});
|
||||
|
||||
test('keystore unavailable: kuchnia omitted and the skip recorded', () async {
|
||||
final secrets = MemorySecrets()..broken = true;
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
final file = await exportAllData(keyring: secrets.keyring());
|
||||
final root = jsonDecode(file) as Map<String, dynamic>;
|
||||
expect(root.containsKey('kuchnia'), isFalse);
|
||||
expect(root['kuchnia_skipped'], 'keystore_unavailable');
|
||||
expect(file, isNot(contains(testPassword)));
|
||||
});
|
||||
|
||||
test('an existing local login is kept, never overwritten', () async {
|
||||
final a = MemorySecrets();
|
||||
await bindFresh(kuchnia: (testMail, testPassword));
|
||||
final file = await exportAllData(keyring: a.keyring());
|
||||
|
||||
await bindFresh(kuchnia: ('other@example.pl', 'local-pw'));
|
||||
final stamp = KuchniaCredentialService.editedAt;
|
||||
await importAllData(file, keyring: a.keyring(), pullKeys: _noPull);
|
||||
expect(KuchniaCredentialService.username, 'other@example.pl');
|
||||
expect(KuchniaCredentialService.password, 'local-pw');
|
||||
expect(KuchniaCredentialService.editedAt, stamp);
|
||||
});
|
||||
|
||||
test('no configured login: no box, no flag, no key minted', () async {
|
||||
final secrets = MemorySecrets();
|
||||
await bindFresh();
|
||||
final root = await _root(secrets);
|
||||
expect(root.containsKey('kuchnia'), isFalse);
|
||||
expect(root.containsKey('kuchnia_skipped'), isFalse);
|
||||
expect(secrets.data, isEmpty);
|
||||
});
|
||||
}
|
||||
172
app/test/services/data_export_test.dart
Normal file
172
app/test/services/data_export_test.dart
Normal file
@ -0,0 +1,172 @@
|
||||
// Export -> import must be lossless, idempotent and merge-only.
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:diet_guard_app/models/food_bank_record.dart';
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/data_export.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
/// Points every service at a fresh in-memory store.
|
||||
Future<MemoryDocStore> _bindFreshStore() async {
|
||||
final store = MemoryDocStore();
|
||||
LogStorageService.resetForTesting(store: store);
|
||||
FoodBankService.resetForTesting(store: store);
|
||||
await AppSettingsService.initForTesting(store);
|
||||
await BudgetHistoryService.initForTesting(store);
|
||||
await MealScheduleService.initForTesting(store);
|
||||
await BodyService.initForTesting(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
FoodEntry _entry(
|
||||
String? id,
|
||||
String time,
|
||||
String desc, {
|
||||
bool deleted = false,
|
||||
}) => FoodEntry(
|
||||
id: id,
|
||||
time: time,
|
||||
desc: desc,
|
||||
grams: 100,
|
||||
kcal: 250,
|
||||
proteinG: 10,
|
||||
carbsG: 30,
|
||||
fatG: 8,
|
||||
source: 'manual',
|
||||
slotMinute: 480,
|
||||
deleted: deleted,
|
||||
);
|
||||
|
||||
/// Seeds one of every document type, incl. a tombstone and a legacy entry.
|
||||
Future<void> _seed({String suffix = ''}) async {
|
||||
final log = {
|
||||
'2026-10-01': [
|
||||
_entry('id-a$suffix', '2026-10-01T08:05:00', 'oats$suffix'),
|
||||
_entry('id-b$suffix', '2026-10-01T12:10:00', 'soup', deleted: true),
|
||||
_entry(null, '2026-10-01T16:00:00', 'legacy apple$suffix'),
|
||||
],
|
||||
};
|
||||
await LogStorageService.instance.writeLog(log);
|
||||
await FoodBankService.instance.rebuildAndPersist(log);
|
||||
await FoodBankService.instance.applyMergedManualBank({
|
||||
'skyr$suffix': FoodBankRecord(
|
||||
desc: 'skyr$suffix',
|
||||
kcal: 60,
|
||||
proteinG: 11,
|
||||
carbsG: 4,
|
||||
fatG: 0,
|
||||
grams: 100,
|
||||
count: 1,
|
||||
editedAt: '2026-10-02T09:00:00',
|
||||
),
|
||||
});
|
||||
await AppSettingsService.instance.saveDailyKcalGoal(1900);
|
||||
await MealScheduleService.instance.recordChange(
|
||||
const MealSchedule(firstMinute: 450, lastMinute: 1230, count: 5),
|
||||
when: DateTime(2026, 10, 3, 10),
|
||||
);
|
||||
await BodyService.instance.setWeight(
|
||||
'2026-10-04',
|
||||
81.5,
|
||||
now: DateTime(2026, 10, 4, 7),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _documents(String export) =>
|
||||
(jsonDecode(export) as Map<String, dynamic>)['documents']
|
||||
as Map<String, dynamic>;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'round trip: import into an empty store reproduces the export',
|
||||
() async {
|
||||
await _bindFreshStore();
|
||||
await _seed();
|
||||
final original = await exportAllData();
|
||||
final docs = _documents(original);
|
||||
for (final key in ['food_log', 'food_bank', 'food_bank_manual']) {
|
||||
expect(docs[key], isNotEmpty, reason: key);
|
||||
}
|
||||
expect(docs['budget'], isNotEmpty);
|
||||
expect(docs['body'], isNotEmpty);
|
||||
|
||||
await _bindFreshStore();
|
||||
await importAllData(original);
|
||||
expect(_documents(await exportAllData()), _documents(original));
|
||||
},
|
||||
);
|
||||
|
||||
test('importing the same file twice equals importing it once', () async {
|
||||
await _bindFreshStore();
|
||||
await _seed();
|
||||
final file = await exportAllData();
|
||||
|
||||
final store = await _bindFreshStore();
|
||||
await importAllData(file);
|
||||
final once = Map.of(store.documents);
|
||||
await importAllData(file);
|
||||
expect(store.documents, once);
|
||||
});
|
||||
|
||||
test('import into a non-empty store keeps the existing records', () async {
|
||||
await _bindFreshStore();
|
||||
await _seed(suffix: '-file');
|
||||
final file = await exportAllData();
|
||||
|
||||
await _bindFreshStore();
|
||||
await _seed(suffix: '-local');
|
||||
final before = _documents(await exportAllData());
|
||||
await importAllData(file);
|
||||
final after = _documents(await exportAllData());
|
||||
|
||||
for (final key in ['food_log', 'food_bank', 'food_bank_manual']) {
|
||||
final kept = (after[key] as Map).keys.toSet();
|
||||
expect(kept, containsAll((before[key] as Map).keys), reason: key);
|
||||
expect(kept, containsAll((_documents(file)[key] as Map).keys));
|
||||
}
|
||||
final descs = (await LogStorageService.instance.allEntriesNewestFirst())
|
||||
.map((e) => e.desc);
|
||||
expect(
|
||||
descs,
|
||||
containsAll(['oats-local', 'oats-file', 'legacy apple-file']),
|
||||
);
|
||||
});
|
||||
|
||||
test('import keeps food-bank counts the log cannot rebuild', () async {
|
||||
await _bindFreshStore();
|
||||
await _seed(suffix: '-file');
|
||||
final file = await exportAllData();
|
||||
|
||||
await _bindFreshStore();
|
||||
await FoodBankService.instance.writeBank({
|
||||
'pierogi': const FoodBankRecord(
|
||||
desc: 'pierogi',
|
||||
kcal: 300,
|
||||
proteinG: 9,
|
||||
carbsG: 40,
|
||||
fatG: 11,
|
||||
grams: 200,
|
||||
count: 7,
|
||||
),
|
||||
});
|
||||
await importAllData(file);
|
||||
final bank = await FoodBankService.instance.readBank();
|
||||
expect(bank['pierogi']?.count, 7);
|
||||
});
|
||||
|
||||
test('rejects a file that is not a diet_guard export', () async {
|
||||
await _bindFreshStore();
|
||||
expect(() => importAllData('{"days": []}'), throwsFormatException);
|
||||
expect(() => importAllData('not json'), throwsFormatException);
|
||||
});
|
||||
}
|
||||
84
app/test/services/export_keyring_test.dart
Normal file
84
app/test/services/export_keyring_test.dart
Normal file
@ -0,0 +1,84 @@
|
||||
// The export keyring: a never-overwritten set of random keys that syncs.
|
||||
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
import 'export_test_support.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'a key is generated once, 256-bit, and persisted in the keystore',
|
||||
() async {
|
||||
final secrets = MemorySecrets();
|
||||
final first = await secrets.keyring().ensureNewest();
|
||||
final again = await secrets.keyring().ensureNewest();
|
||||
expect(first.bytes, hasLength(32));
|
||||
expect(first.kid, matches(RegExp(r'^[0-9a-f]{16}$')));
|
||||
expect(again.kid, first.kid);
|
||||
expect(again.bytes, first.bytes);
|
||||
expect(secrets.data, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'two devices generate concurrently: both keys survive the merge',
|
||||
() async {
|
||||
final remote = MemoryRemote();
|
||||
final a = MemorySecrets();
|
||||
final b = MemorySecrets();
|
||||
final keyA = await a.keyring().ensureNewest();
|
||||
final keyB = await b.keyring().ensureNewest();
|
||||
expect(keyA.kid, isNot(keyB.kid));
|
||||
|
||||
for (final (id, secrets) in [('dev-a', a), ('dev-b', b), ('dev-a', a)]) {
|
||||
await syncExportKeys(
|
||||
remote,
|
||||
keyring: secrets.keyring(),
|
||||
devicesDir: 'devs',
|
||||
deviceId: id,
|
||||
);
|
||||
}
|
||||
for (final secrets in [a, b]) {
|
||||
final keys = {for (final k in await secrets.keyring().all()) k.kid: k};
|
||||
expect(keys.keys, containsAll([keyA.kid, keyB.kid]));
|
||||
expect(keys[keyA.kid]!.bytes, keyA.bytes);
|
||||
expect(keys[keyB.kid]!.bytes, keyB.bytes);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a synced record never replaces a key already held under its id',
|
||||
() async {
|
||||
final secrets = MemorySecrets();
|
||||
final mine = await secrets.keyring().ensureNewest();
|
||||
final other = MemorySecrets();
|
||||
await other.keyring().mergeKeys([
|
||||
ExportKey(mine.kid, List.filled(32, 7), DateTime.utc(2030)),
|
||||
]);
|
||||
await secrets.keyring().mergeKeys(await other.keyring().all());
|
||||
expect((await secrets.keyring().byId(mine.kid))!.bytes, mine.bytes);
|
||||
},
|
||||
);
|
||||
|
||||
test('malformed synced keys are ignored', () async {
|
||||
final secrets = MemorySecrets();
|
||||
await secrets.keyring().mergeKeys([
|
||||
ExportKey('not-a-kid', List.filled(32, 1), DateTime.utc(2030)),
|
||||
ExportKey('0123456789abcdef', List.filled(5, 1), DateTime.utc(2030)),
|
||||
]);
|
||||
expect(await secrets.keyring().all(), isEmpty);
|
||||
});
|
||||
|
||||
test('the newest local key is the one used for export', () async {
|
||||
final secrets = MemorySecrets();
|
||||
final ring = secrets.keyring();
|
||||
await ring.mergeKeys([
|
||||
ExportKey('aaaaaaaaaaaaaaaa', List.filled(32, 1), DateTime.utc(2026)),
|
||||
ExportKey('bbbbbbbbbbbbbbbb', List.filled(32, 2), DateTime.utc(2027)),
|
||||
]);
|
||||
expect((await ring.ensureNewest()).kid, 'bbbbbbbbbbbbbbbb');
|
||||
});
|
||||
}
|
||||
80
app/test/services/export_test_support.dart
Normal file
80
app/test/services/export_test_support.dart
Normal file
@ -0,0 +1,80 @@
|
||||
// Shared fixtures for the export-key tests.
|
||||
|
||||
import 'package:diet_guard_app/models/food_entry.dart';
|
||||
import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/body_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/export_keyring.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/kuchnia_credential_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
|
||||
import 'body_test_support.dart';
|
||||
|
||||
/// The catering login the tests hide.
|
||||
const testPassword = 'hunter2-PANEL-pw';
|
||||
|
||||
/// The catering account the tests hide.
|
||||
const testMail = 'kuhy@example.pl';
|
||||
|
||||
/// An in-memory OS keystore; [broken] simulates it being unavailable.
|
||||
class MemorySecrets {
|
||||
/// Stored secrets by key.
|
||||
final Map<String, String> data = {};
|
||||
|
||||
/// When true every access throws.
|
||||
bool broken = false;
|
||||
|
||||
/// A keyring over this keystore.
|
||||
ExportKeyring keyring() => ExportKeyring(
|
||||
read: (key) async => broken ? throw StateError('no keystore') : data[key],
|
||||
write: (key, value) async {
|
||||
if (broken) throw StateError('no keystore');
|
||||
data[key] = value;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Fresh in-memory stores; [kuchnia] seeds the catering login when given.
|
||||
Future<void> bindFresh({(String, String)? kuchnia}) async {
|
||||
final store = MemoryDocStore();
|
||||
LogStorageService.resetForTesting(store: store);
|
||||
FoodBankService.resetForTesting(store: store);
|
||||
await AppSettingsService.initForTesting(store);
|
||||
await BudgetHistoryService.initForTesting(store);
|
||||
await MealScheduleService.initForTesting(store);
|
||||
await BodyService.initForTesting(store);
|
||||
await KuchniaCredentialService.initForTesting(MemoryDocStore());
|
||||
if (kuchnia != null) {
|
||||
await KuchniaCredentialService.instance.save(kuchnia.$1, kuchnia.$2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one meal into the food log.
|
||||
Future<void> seedMeal(String desc) async {
|
||||
final log = {
|
||||
'2026-10-01': [
|
||||
FoodEntry(
|
||||
id: 'id-$desc',
|
||||
time: '2026-10-01T08:05:00',
|
||||
desc: desc,
|
||||
grams: 100,
|
||||
kcal: 250,
|
||||
proteinG: 10,
|
||||
carbsG: 30,
|
||||
fatG: 8,
|
||||
source: 'manual',
|
||||
slotMinute: 480,
|
||||
),
|
||||
],
|
||||
};
|
||||
await LogStorageService.instance.writeLog(log);
|
||||
await FoodBankService.instance.rebuildAndPersist(log);
|
||||
}
|
||||
|
||||
/// Every meal description in the local food log.
|
||||
Future<List<String>> meals() async =>
|
||||
(await LogStorageService.instance.allEntriesNewestFirst())
|
||||
.map((e) => e.desc)
|
||||
.toList();
|
||||
Loading…
Reference in New Issue
Block a user