From 1b5e201136d5307bd9a9c8ed83d300597fb4610e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 20:12:37 +0100 Subject: [PATCH] feat: JSON export/import on device Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4 --- app/lib/screens/settings_screen.dart | 17 +- app/lib/services/data_export.dart | 144 +++++++++++++ app/lib/services/data_export_kuchnia.dart | 107 ++++++++++ app/lib/services/export_crypto.dart | 100 +++++++++ app/lib/services/export_keyring.dart | 126 ++++++++++++ app/lib/services/export_keyring_default.dart | 50 +++++ app/lib/services/export_keyring_sync.dart | 85 ++++++++ app/lib/services/sync_budget_local.dart | 49 +++++ app/lib/services/sync_device_id.dart | 3 + app/lib/services/sync_service.dart | 81 +++----- app/lib/widgets/data_backup_row.dart | 107 ++++++++++ app/pubspec.lock | 10 +- app/pubspec.yaml | 10 +- .../background_sync_service_test.dart | 6 +- .../services/data_export_kuchnia_test.dart | 194 ++++++++++++++++++ app/test/services/data_export_test.dart | 172 ++++++++++++++++ app/test/services/export_keyring_test.dart | 84 ++++++++ app/test/services/export_test_support.dart | 80 ++++++++ 18 files changed, 1355 insertions(+), 70 deletions(-) create mode 100644 app/lib/services/data_export.dart create mode 100644 app/lib/services/data_export_kuchnia.dart create mode 100644 app/lib/services/export_crypto.dart create mode 100644 app/lib/services/export_keyring.dart create mode 100644 app/lib/services/export_keyring_default.dart create mode 100644 app/lib/services/export_keyring_sync.dart create mode 100644 app/lib/services/sync_budget_local.dart create mode 100644 app/lib/widgets/data_backup_row.dart create mode 100644 app/test/services/data_export_kuchnia_test.dart create mode 100644 app/test/services/data_export_test.dart create mode 100644 app/test/services/export_keyring_test.dart create mode 100644 app/test/services/export_test_support.dart diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index b911ab7..60af2ce 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -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 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), diff --git a/app/lib/services/data_export.dart b/app/lib/services/data_export.dart new file mode 100644 index 0000000..90e115d --- /dev/null +++ b/app/lib/services/data_export.dart @@ -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 Function() readLocal; + final Future Function(Log merged) apply; +} + +Future _applyFoodLog(Log merged) async { + final log = logToDayLog(merged); + await LogStorageService.instance.writeLog(log); + await FoodBankService.instance.rebuildAndPersist(log); +} + +Future _readBody() async => + BodyService.isInitialized ? bodyToLog(BodyService.instance.document) : {}; + +Future _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 _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(), + ), + }; + } 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 exportAllData({DateTime? now, ExportKeyring? keyring}) async { + final documents = { + 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 importAllData( + String text, { + ExportKeyring? keyring, + Future 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); +} diff --git a/app/lib/services/data_export_kuchnia.dart b/app/lib/services/data_export_kuchnia.dart new file mode 100644 index 0000000..6c9a1ce --- /dev/null +++ b/app/lib/services/data_export_kuchnia.dart @@ -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> 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 _findKey( + ExportKeyring keyring, + String kid, + Future 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 importKuchnia( + Map root, + ExportKeyring keyring, + Future Function() pullKeys, +) async { + final box = root[kuchniaKey]; + if (box == null) return; + final kid = envelopeKeyId(box); + final Map 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 : '', + ); +} diff --git a/app/lib/services/export_crypto.dart b/app/lib/services/export_crypto.dart new file mode 100644 index 0000000..49ef604 --- /dev/null +++ b/app/lib/services/export_crypto.dart @@ -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 _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> 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 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'); + } +} diff --git a/app/lib/services/export_keyring.dart b/app/lib/services/export_keyring.dart new file mode 100644 index 0000000..4c517e8 --- /dev/null +++ b/app/lib/services/export_keyring.dart @@ -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 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> 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 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 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 mergeKeys(Iterable 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(), + }, + }), + ); + } +} diff --git a/app/lib/services/export_keyring_default.dart b/app/lib/services/export_keyring_default.dart new file mode 100644 index 0000000..7f56d31 --- /dev/null +++ b/app/lib/services/export_keyring_default.dart @@ -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 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 diff --git a/app/lib/services/export_keyring_sync.dart b/app/lib/services/export_keyring_sync.dart new file mode 100644 index 0000000..dc90288 --- /dev/null +++ b/app/lib/services/export_keyring_sync.dart @@ -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 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 _fromLog(Log log) { + final keys = []; + 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 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().map( + (kid, data) => + MapEntry(kid, Record.fromJson(data as Map)), + ), + filename: 'export_keys.json', + commitMessage: 'diet_guard_app sync', + ); + await keyring.mergeKeys(_fromLog(merged)); +} diff --git a/app/lib/services/sync_budget_local.dart b/app/lib/services/sync_budget_local.dart new file mode 100644 index 0000000..a589235 --- /dev/null +++ b/app/lib/services/sync_budget_local.dart @@ -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 + : { + '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 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? ?? ''), + ); +} diff --git a/app/lib/services/sync_device_id.dart b/app/lib/services/sync_device_id.dart index eb6c04b..a48eb42 100644 --- a/app/lib/services/sync_device_id.dart +++ b/app/lib/services/sync_device_id.dart @@ -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`) diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 7ab926f..c745f7c 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -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 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 _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 _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 - : { - '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 _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. diff --git a/app/lib/widgets/data_backup_row.dart b/app/lib/widgets/data_backup_row.dart new file mode 100644 index 0000000..0ae404e --- /dev/null +++ b/app/lib/widgets/data_backup_row.dart @@ -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 createState() => _DataBackupRowState(); +} + +class _DataBackupRowState extends State { + _Outcome _export = _Outcome.idle; + _Outcome _import = _Outcome.idle; + Timer? _reset; + + @override + void dispose() { + _reset?.cancel(); + super.dispose(); + } + + Future<_Outcome> _run(Future 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 _onExport() async { + final outcome = await _run(() async { + final text = await exportAllData(); + return await exportJsonText(fileName: exportFileName, text: text); + }); + _show(() => _export = outcome); + } + + Future _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), + ], + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 877d815..f6c5d26 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 440beea..ccba945 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/services/background_sync_service_test.dart b/app/test/services/background_sync_service_test.dart index 599ad02..cc172fc 100644 --- a/app/test/services/background_sync_service_test.dart +++ b/app/test/services/background_sync_service_test.dart @@ -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 { diff --git a/app/test/services/data_export_kuchnia_test.dart b/app/test/services/data_export_kuchnia_test.dart new file mode 100644 index 0000000..d4f4003 --- /dev/null +++ b/app/test/services/data_export_kuchnia_test.dart @@ -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 _noPull() async {} + +Future> _root(MemorySecrets s) async => + jsonDecode(await exportAllData(keyring: s.keyring())) + as Map; + +Future _importError( + String text, + MemorySecrets s, { + Future 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; + final box = root['kuchnia'] as Map; + 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; + final b = (await _root(secrets))['kuchnia'] as Map; + 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()); + 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()); + }); + + 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()); + 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; + 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); + }); +} diff --git a/app/test/services/data_export_test.dart b/app/test/services/data_export_test.dart new file mode 100644 index 0000000..dc4ce93 --- /dev/null +++ b/app/test/services/data_export_test.dart @@ -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 _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 _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 _documents(String export) => + (jsonDecode(export) as Map)['documents'] + as Map; + +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); + }); +} diff --git a/app/test/services/export_keyring_test.dart b/app/test/services/export_keyring_test.dart new file mode 100644 index 0000000..958719a --- /dev/null +++ b/app/test/services/export_keyring_test.dart @@ -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'); + }); +} diff --git a/app/test/services/export_test_support.dart b/app/test/services/export_test_support.dart new file mode 100644 index 0000000..e3787ca --- /dev/null +++ b/app/test/services/export_test_support.dart @@ -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 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 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 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> meals() async => + (await LogStorageService.instance.allEntriesNewestFirst()) + .map((e) => e.desc) + .toList();