diff --git a/lib/features/account/providers/backup_reminder_provider.dart b/lib/features/account/providers/backup_reminder_provider.dart index 59631982..21a17276 100644 --- a/lib/features/account/providers/backup_reminder_provider.dart +++ b/lib/features/account/providers/backup_reminder_provider.dart @@ -1,6 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mostro/src/rust/api/identity.dart' as identity_api; + const kBackupReminderDismissedKey = 'backupReminderDismissed'; const kBackupReminderActiveKey = 'backupReminderActive'; @@ -19,8 +22,8 @@ const kBackupSnoozedUntilKey = 'backupSnoozedUntilMillis'; /// Dismissed permanently after `confirmBackupComplete()` is called. final backupReminderProvider = StateNotifierProvider( - (ref) => BackupReminderNotifier(), -); + (ref) => BackupReminderNotifier(), + ); /// Whether the user has ever completed a backup of the current identity. /// @@ -28,8 +31,8 @@ final backupReminderProvider = /// identity is generated or imported. final backupCompletedProvider = StateNotifierProvider( - (ref) => BackupCompletedNotifier(), -); + (ref) => BackupCompletedNotifier(), + ); class BackupReminderNotifier extends StateNotifier { /// When [initialValue] is provided the notifier starts with the correct @@ -112,7 +115,25 @@ class BackupReminderNotifier extends StateNotifier { } class BackupCompletedNotifier extends StateNotifier { - BackupCompletedNotifier({bool? initialValue}) : super(initialValue ?? false) { + /// The three bridge calls are injectable so the notifier is testable without + /// a live Rust runtime; they default to the real identity-bridge functions + /// (issue #141). + BackupCompletedNotifier({ + bool? initialValue, + Future Function()? getConfirmed, + Future Function(bool confirmed)? setConfirmed, + Future Function()? resetConfirmed, + // Test seam: force the web (SharedPreferences-authoritative) path off-web. + // Defaults to the real platform flag. + bool? isWebOverride, + }) : _getConfirmed = getConfirmed ?? identity_api.getBackupConfirmed, + _setConfirmed = + setConfirmed ?? + ((confirmed) => + identity_api.setBackupConfirmed(confirmed: confirmed)), + _resetConfirmed = resetConfirmed ?? identity_api.resetBackupConfirmation, + _isWeb = isWebOverride ?? kIsWeb, + super(initialValue ?? false) { if (initialValue == null) { load(); } else { @@ -120,33 +141,122 @@ class BackupCompletedNotifier extends StateNotifier { } } + final Future Function() _getConfirmed; + final Future Function(bool confirmed) _setConfirmed; + final Future Function() _resetConfirmed; + final bool _isWeb; + + // Web has no durable Rust identity store until #233, but SharedPreferences + // (backed by localStorage) IS durable there. So on web the backup-confirmed + // flag is read/written/cleared directly in kBackupCompletedKey, and the Rust + // bridge is used only on native. This keeps a confirmed backup surviving a + // page reload on web, instead of resetting to the session-only Rust default. + // (#141 review — CodeRabbit) + Future _readConfirmed() async { + if (_isWeb) { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(kBackupCompletedKey) ?? false; + } + return _getConfirmed(); + } + + Future _writeConfirmed(bool confirmed) async { + if (_isWeb) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(kBackupCompletedKey, confirmed); + return; + } + await _setConfirmed(confirmed); + } + + Future _clearConfirmed() async { + if (_isWeb) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(kBackupCompletedKey, false); + return; + } + await _resetConfirmed(); + } + bool _loaded = false; - Future load() async { - if (_loaded) return; - final prefs = await SharedPreferences.getInstance(); - // Legacy installs only have the dismissed flag, which was set exclusively - // by the explicit "I have written down my secret words" confirmation — - // treat it as a completed backup. - state = prefs.getBool(kBackupCompletedKey) ?? - prefs.getBool(kBackupReminderDismissedKey) ?? - false; - _loaded = true; + /// Marks that the one-time SharedPreferences -> Rust migration has run, so + /// the legacy key is only ever read once (issue #141). + static const _kMigratedKey = 'backupCompletedMigratedToRust'; + + // Coalesce concurrent load()s: the constructor fires load() un-awaited, and a + // caller (or test) may await load() before it finishes. Without sharing the + // in-flight future, both could pass the _loaded check, run the one-time + // migration, and call _setConfirmed twice. Cleared on completion so a failed + // load (which leaves _loaded false) can be retried. (#141 review — CodeRabbit) + Future? _loading; + + Future load() { + if (_loaded) return Future.value(); + return _loading ??= _load().whenComplete(() => _loading = null); + } + + Future _load() async { + // The backup-confirmed flag now lives in the Rust identity record. On the + // first run after upgrading, copy the legacy SharedPreferences value into + // Rust once, then read from Rust exclusively. + try { + // #141 review: skip the migration entirely on web. There, initDb is + // never called (main.dart guards it with !kIsWeb), so set_backup_confirmed + // has no store and returns Ok WITHOUT persisting. Running the migration + // would burn the durable _kMigratedKey (localStorage) against that + // non-durable write, consuming the legacy SharedPreferences value and + // re-arming the reminder on every reload. Until IndexedDB save_identity + // lands (#233), the legacy SharedPreferences flag stays authoritative on + // web, so we neither migrate nor mark it migrated. + if (!_isWeb) { + final prefs = await SharedPreferences.getInstance(); + final migrated = prefs.getBool(_kMigratedKey) ?? false; + if (!migrated) { + // Legacy installs only have the dismissed flag, which was set + // exclusively by the explicit "I have written down my secret words" + // confirmation — treat it as a completed backup. + final legacy = + prefs.getBool(kBackupCompletedKey) ?? + prefs.getBool(kBackupReminderDismissedKey) ?? + false; + if (legacy) { + // Best-effort: if no identity is loaded yet, the bridge throws and + // we simply leave Rust at its default (false); the reminder stays + // armed, which is safe. The marker is only set once the copy + // sticks — and on native the write is always durable here. + await _setConfirmed(true); + } + await prefs.setBool(_kMigratedKey, true); + } + } + state = await _readConfirmed(); + // Only mark loaded once the read succeeded. If the bridge was not + // ready (no identity yet), leaving _loaded false lets the next load() + // retry instead of pinning the UI to `false` for the whole session. + _loaded = true; + } catch (e) { + // Rust unavailable (e.g. no identity yet, or tests without the bridge): + // fall back to unconfirmed so the reminder stays armed, and let a later + // load() retry (we deliberately do NOT set _loaded here). + debugPrint('[backup] load() failed, reminder stays armed: $e'); + state = false; + } } - /// Persist that the current identity has been backed up. + /// Persist that the current identity has been backed up (Rust identity + /// record, #141). Future markCompleted() async { await load(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(kBackupCompletedKey, true); + await _writeConfirmed(true); state = true; } - /// Clear the backed-up flag (new identity generated or imported). + /// Clear the backed-up flag (new identity generated or imported). The Rust + /// side is also reset in `create_identity`; this keeps the UI in sync (#141). Future reset() async { await load(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool(kBackupCompletedKey, false); + await _clearConfirmed(); state = false; } } diff --git a/lib/features/account/screens/account_screen.dart b/lib/features/account/screens/account_screen.dart index 41b9dcd8..8cc1cfb8 100644 --- a/lib/features/account/screens/account_screen.dart +++ b/lib/features/account/screens/account_screen.dart @@ -44,11 +44,9 @@ class _AccountScreenState extends ConsumerState { if (!mounted) return; if (words.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.noIdentityFoundMessage), - ), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.noIdentityFoundMessage))); return; } final backupPending = ref.read(backupReminderProvider); @@ -79,8 +77,12 @@ class _AccountScreenState extends ConsumerState { Future _confirmBackup() async { final l10n = AppLocalizations.of(context); try { - await ref.read(backupReminderProvider.notifier).confirmBackupComplete(); + // Authoritative Rust write first; only dismiss the reminder locally once + // it succeeds. If markCompleted() throws, the catch below fires before the + // permanent local dismissal, keeping the reminder armed and consistent + // with backup_confirmed=false. (#141 review) await ref.read(backupCompletedProvider.notifier).markCompleted(); + await ref.read(backupReminderProvider.notifier).confirmBackupComplete(); if (mounted) setState(() => _showBackupCheckbox = false); } catch (e) { debugPrint('[account] _confirmBackup error: $e'); @@ -129,9 +131,7 @@ class _AccountScreenState extends ConsumerState { // ── Backup ritual banner — entry point for the 3-step backup // flow while the backup reminder is active. if (backupPending) ...[ - _BackupRitualBanner( - onTap: () => showBackupTriggerSheet(context), - ), + _BackupRitualBanner(onTap: () => showBackupTriggerSheet(context)), const SizedBox(height: AppSpacing.lg), ], @@ -212,7 +212,9 @@ class _AccountScreenState extends ConsumerState { color: green, ), label: Text( - _wordsVisible ? l10n.hideButtonLabel : l10n.showButtonLabel, + _wordsVisible + ? l10n.hideButtonLabel + : l10n.showButtonLabel, style: TextStyle(color: green), ), ), @@ -449,9 +451,7 @@ class _AccountScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - kDebugMode - ? 'Import failed: $e' - : l10n.invalidMnemonicMessage, + kDebugMode ? 'Import failed: $e' : l10n.invalidMnemonicMessage, ), ), ); @@ -552,10 +552,7 @@ class _CardHeader extends StatelessWidget { context, ).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600), ), - if (badge != null) ...[ - const SizedBox(width: AppSpacing.sm), - badge!, - ], + if (badge != null) ...[const SizedBox(width: AppSpacing.sm), badge!], const Spacer(), IconButton( onPressed: onInfo, @@ -617,8 +614,9 @@ class _BackupRitualBanner extends StatelessWidget { const SizedBox(height: 2), Text( l10n.backupBannerSubtitle, - style: - theme.textTheme.bodySmall!.copyWith(color: textSec), + style: theme.textTheme.bodySmall!.copyWith( + color: textSec, + ), ), ], ), diff --git a/lib/features/account/screens/backup_ritual_screen.dart b/lib/features/account/screens/backup_ritual_screen.dart index 5d983799..27accc13 100644 --- a/lib/features/account/screens/backup_ritual_screen.dart +++ b/lib/features/account/screens/backup_ritual_screen.dart @@ -107,9 +107,7 @@ class _BackupRitualScreenState extends ConsumerState { if (words.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - AppLocalizations.of(context).noIdentityFoundMessage, - ), + content: Text(AppLocalizations.of(context).noIdentityFoundMessage), ), ); Navigator.of(context).pop(); @@ -213,8 +211,12 @@ class _BackupRitualScreenState extends ConsumerState { if (!_allCorrect || _confirming) return; setState(() => _confirming = true); try { - await ref.read(backupReminderProvider.notifier).confirmBackupComplete(); + // Authoritative Rust write first; only dismiss the reminder locally once + // it succeeds. If markCompleted() throws, the catch below fires before the + // permanent local dismissal, keeping the reminder armed and consistent + // with backup_confirmed=false. (#141 review) await ref.read(backupCompletedProvider.notifier).markCompleted(); + await ref.read(backupReminderProvider.notifier).confirmBackupComplete(); if (mounted) setState(() => _step = 2); } catch (e) { debugPrint('[backup-ritual] confirm error: $e'); @@ -264,17 +266,18 @@ class _BackupRitualScreenState extends ConsumerState { appBar: AppBar( title: Text(title, style: const TextStyle(fontSize: 15)), automaticallyImplyLeading: false, - leading: _step == 2 - ? null - : BackButton( - onPressed: () { - if (_step == 1) { - _backToWords(); - } else { - Navigator.of(context).pop(); - } - }, - ), + leading: + _step == 2 + ? null + : BackButton( + onPressed: () { + if (_step == 1) { + _backToWords(); + } else { + Navigator.of(context).pop(); + } + }, + ), ), body: SafeArea( child: Padding( @@ -324,117 +327,125 @@ class _BackupRitualScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Amber warning card - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.md, - ), - decoration: BoxDecoration( - color: amber.withValues(alpha: 0.12), - border: Border.all(color: amber.withValues(alpha: 0.27)), - borderRadius: BorderRadius.circular(AppRadius.card), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber_rounded, color: amber, size: 20), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text.rich( - TextSpan( - text: l10n.backupRitualWarningTitle, - style: const TextStyle(fontWeight: FontWeight.w700), - children: [ - TextSpan( - text: l10n.backupRitualWarningBody, - style: const TextStyle(fontWeight: FontWeight.w400), - ), - ], + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: amber.withValues(alpha: 0.12), + border: Border.all(color: amber.withValues(alpha: 0.27)), + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, color: amber, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text.rich( + TextSpan( + text: l10n.backupRitualWarningTitle, + style: const TextStyle(fontWeight: FontWeight.w700), + children: [ + TextSpan( + text: l10n.backupRitualWarningBody, + style: const TextStyle(fontWeight: FontWeight.w400), + ), + ], + ), + style: theme.textTheme.bodySmall!.copyWith( + color: amber, + height: 1.5, + ), ), - style: theme.textTheme.bodySmall! - .copyWith(color: amber, height: 1.5), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: AppSpacing.md), + const SizedBox(height: AppSpacing.md), - // Words grid card - Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: cardBg, - borderRadius: BorderRadius.circular(AppRadius.card), - ), - child: Column( - children: [ - for (var row = 0; row < (words.length + 1) ~/ 2; row++) ...[ - if (row > 0) const SizedBox(height: AppSpacing.sm), - Row( - children: [ - for (var col = 0; col < 2; col++) ...[ - if (col > 0) const SizedBox(width: AppSpacing.sm), - Expanded( - child: row * 2 + col < words.length - ? _WordCell( - index: row * 2 + col, - word: words[row * 2 + col], - background: elevated, - indexColor: textSubtle, - ) - : const SizedBox.shrink(), + // Words grid card + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + children: [ + for (var row = 0; row < (words.length + 1) ~/ 2; row++) ...[ + if (row > 0) const SizedBox(height: AppSpacing.sm), + Row( + children: [ + for (var col = 0; col < 2; col++) ...[ + if (col > 0) const SizedBox(width: AppSpacing.sm), + Expanded( + child: + row * 2 + col < words.length + ? _WordCell( + index: row * 2 + col, + word: words[row * 2 + col], + background: elevated, + indexColor: textSubtle, + ) + : const SizedBox.shrink(), + ), + ], + ], + ), + ], + const SizedBox(height: AppSpacing.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.sm + 2), + decoration: BoxDecoration( + color: elevated, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.visibility_off_outlined, + size: 14, + color: textSec, + ), + const SizedBox(width: AppSpacing.xs), + Flexible( + child: Text( + l10n.wordsHiddenOnLeaveNote, + style: theme.textTheme.bodySmall!.copyWith( + color: textSec, + fontSize: 12, + ), + ), ), ], - ], + ), ), ], - const SizedBox(height: AppSpacing.md), - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.sm + 2), - decoration: BoxDecoration( - color: elevated, - borderRadius: BorderRadius.circular(10), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.visibility_off_outlined, - size: 14, color: textSec), - const SizedBox(width: AppSpacing.xs), - Flexible( - child: Text( - l10n.wordsHiddenOnLeaveNote, - style: theme.textTheme.bodySmall! - .copyWith(color: textSec, fontSize: 12), - ), - ), - ], - ), - ), - ], + ), ), - ), - const Spacer(), + const Spacer(), - FilledButton.icon( - onPressed: _startVerification, - icon: Text( - l10n.wroteThemDownVerifyButton, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), - ), - label: const Icon(Icons.arrow_forward, size: 18), - style: FilledButton.styleFrom( - backgroundColor: green, - foregroundColor: Colors.black, - minimumSize: const Size.fromHeight(54), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + FilledButton.icon( + onPressed: _startVerification, + icon: Text( + l10n.wroteThemDownVerifyButton, + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), + ), + label: const Icon(Icons.arrow_forward, size: 18), + style: FilledButton.styleFrom( + backgroundColor: green, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(54), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), ), ), - ), ], ), ); @@ -461,14 +472,17 @@ class _BackupRitualScreenState extends ConsumerState { const SizedBox(height: AppSpacing.sm), Text( l10n.tapCorrectWordsTitle, - style: theme.textTheme.titleLarge! - .copyWith(fontWeight: FontWeight.w700), + style: theme.textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: AppSpacing.xs), Text( l10n.verifyInstructionsBody, - style: - theme.textTheme.bodySmall!.copyWith(color: textSec, height: 1.5), + style: theme.textTheme.bodySmall!.copyWith( + color: textSec, + height: 1.5, + ), ), const SizedBox(height: AppSpacing.md), @@ -516,17 +530,20 @@ class _BackupRitualScreenState extends ConsumerState { for (var col = 0; col < 2; col++) ...[ if (col > 0) const SizedBox(width: AppSpacing.sm), Expanded( - child: row * 2 + col < _options.length - ? _OptionButton( - word: _options[row * 2 + col], - isWrong: _options[row * 2 + col] == _wrongPick, - cardBg: cardBg, - red: red, - borderColor: textDisabled.withValues(alpha: 0.4), - onTap: () => - _onOptionTap(_options[row * 2 + col]), - ) - : const SizedBox.shrink(), + child: + row * 2 + col < _options.length + ? _OptionButton( + word: _options[row * 2 + col], + isWrong: _options[row * 2 + col] == _wrongPick, + cardBg: cardBg, + red: red, + borderColor: textDisabled.withValues( + alpha: 0.4, + ), + onTap: + () => _onOptionTap(_options[row * 2 + col]), + ) + : const SizedBox.shrink(), ), ], ], @@ -536,8 +553,10 @@ class _BackupRitualScreenState extends ConsumerState { const SizedBox(height: AppSpacing.sm), Text( l10n.wrongPickMessage, - style: theme.textTheme.bodySmall! - .copyWith(color: red, fontSize: 12), + style: theme.textTheme.bodySmall!.copyWith( + color: red, + fontSize: 12, + ), ), ], ] else ...[ @@ -571,7 +590,10 @@ class _BackupRitualScreenState extends ConsumerState { ), child: Text( l10n.showWordsAgainButton, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), ), @@ -589,19 +611,20 @@ class _BackupRitualScreenState extends ConsumerState { borderRadius: BorderRadius.circular(AppRadius.card), ), ), - child: _confirming - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text( - l10n.confirmButtonLabel, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, + child: + _confirming + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + l10n.confirmButtonLabel, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), ), - ), ), ), ], @@ -622,47 +645,52 @@ class _BackupRitualScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Spacer(), - Center( - child: Container( - width: 112, - height: 112, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: green.withValues(alpha: 0.15), + const Spacer(), + Center( + child: Container( + width: 112, + height: 112, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: green.withValues(alpha: 0.15), + ), + child: Icon(Icons.check_rounded, size: 64, color: green), ), - child: Icon(Icons.check_rounded, size: 64, color: green), ), - ), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.accountBackedUpTitle, - textAlign: TextAlign.center, - style: - theme.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: AppSpacing.sm), - Text( - l10n.accountBackedUpBody, - textAlign: TextAlign.center, - style: - theme.textTheme.bodyMedium!.copyWith(color: textSec, height: 1.5), - ), - const Spacer(), - FilledButton( - onPressed: () => Navigator.of(context).pop(), - style: FilledButton.styleFrom( - backgroundColor: green, - foregroundColor: Colors.black, - minimumSize: const Size.fromHeight(54), - textStyle: - const TextStyle(fontSize: 15, fontWeight: FontWeight.w700), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.accountBackedUpTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w700, ), ), - child: Text(l10n.done), - ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.accountBackedUpBody, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium!.copyWith( + color: textSec, + height: 1.5, + ), + ), + const Spacer(), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: FilledButton.styleFrom( + backgroundColor: green, + foregroundColor: Colors.black, + minimumSize: const Size.fromHeight(54), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Text(l10n.done), + ), ], ), ); @@ -802,9 +830,10 @@ class _SlotRow extends StatelessWidget { decoration: BoxDecoration( color: filled ? green.withValues(alpha: 0.12) : elevated, border: Border.all( - color: filled - ? green.withValues(alpha: 0.4) - : isActive + color: + filled + ? green.withValues(alpha: 0.4) + : isActive ? green.withValues(alpha: 0.5) : borderColor.withValues(alpha: 0.4), ), diff --git a/rust/src/api/identity.rs b/rust/src/api/identity.rs index b7e4f600..39bd33db 100644 --- a/rust/src/api/identity.rs +++ b/rust/src/api/identity.rs @@ -141,6 +141,9 @@ pub async fn create_identity() -> Result { privacy_mode: false, trade_key_index: 0, created_at: now, + // A freshly generated mnemonic has not been backed up yet — this is + // what re-arms the backup reminder for a new identity (issue #141). + backup_confirmed: false, }; *guard = Some(IdentityState { @@ -203,12 +206,15 @@ pub async fn load_identity_from_mnemonic( Some(ts) if ts > 0 => ts, _ => unix_now(), }; + // Restore the backup-confirmed flag from the persisted identity record. + let backup_confirmed = restore_backup_confirmed(stored.as_ref(), &public_key); let identity_info = IdentityInfo { public_key: public_key.clone(), display_name: None, privacy_mode, trade_key_index, created_at, + backup_confirmed, }; let mut guard = identity_lock().write().await; @@ -264,6 +270,8 @@ pub async fn import_from_nsec(nsec: String) -> Result { privacy_mode: false, trade_key_index: 0, created_at: now, + // nsec imports have no BIP-39 mnemonic to back up; leave unconfirmed. + backup_confirmed: false, }; let mut guard = identity_lock().write().await; @@ -282,6 +290,70 @@ pub async fn get_identity() -> Result> { Ok(guard.as_ref().map(|s| s.identity_info.clone())) } +/// Whether the current identity's secret words have been confirmed backed up. +/// +/// Returns `false` when no identity is loaded — nothing has been backed up +/// yet, which correctly leaves the reminder armed (issue #141). +pub async fn get_backup_confirmed() -> Result { + let guard = identity_lock().read().await; + Ok(guard + .as_ref() + .map(|s| s.identity_info.backup_confirmed) + .unwrap_or(false)) +} + +/// Set the backup-confirmed flag and persist it to the identity record. +/// +/// Mirrors the `trade_key_index` persist path: mutate under the identity lock, +/// then `save_identity`, so the flag survives a restart. Unlike a trade-key +/// index, persistence is best-effort rather than required — the flag only +/// drives a reminder, so if the store is unavailable (e.g. the web IndexedDB +/// backend, which does not implement `save_identity`) the worst case is the +/// reminder re-appears next launch, which fails safe. A redundant write is +/// skipped so confirming twice does not touch storage. +pub async fn set_backup_confirmed(confirmed: bool) -> Result<()> { + set_backup_confirmed_with(crate::db::app_db::db(), confirmed).await +} + +/// [`set_backup_confirmed`] against an explicit store, so the persist-then- +/// commit sequence is testable with an injected failing store without touching +/// the global singleton (mirrors [`derive_trade_key_with`]). +async fn set_backup_confirmed_with( + db: Option<&S>, + confirmed: bool, +) -> Result<()> { + let mut guard = identity_lock().write().await; + let state = guard.as_mut().ok_or_else(|| anyhow!("NoIdentity"))?; + if state.identity_info.backup_confirmed == confirmed { + return Ok(()); + } + // Persist before committing in memory: build the updated record, save it, + // and only then assign to state. Mutating first would leave the session + // reporting a confirmed backup that never reached disk if the save failed — + // and the no-op short-circuit above would stop a retry from re-saving, so + // the flag would silently vanish on the next restart. Same persist-then- + // commit discipline as the trade_key_index path (#217). + let mut updated = state.identity_info.clone(); + updated.backup_confirmed = confirmed; + if let Some(db) = db { + db.save_identity(&updated).await.map_err(|e| { + anyhow!("StorageError: failed to persist backup_confirmed={confirmed}: {e}") + })?; + } + state.identity_info = updated; + Ok(()) +} + +/// Re-arm the backup reminder by marking the current identity as not-yet +/// backed up. Called when a new identity is generated so the security-relevant +/// reminder re-appears (issue #141). A no-op when no identity is loaded. +pub async fn reset_backup_confirmation() -> Result<()> { + if get_identity().await?.is_none() { + return Ok(()); + } + set_backup_confirmed(false).await +} + /// Delete the in-memory identity state. Flutter must also clear /// `flutter_secure_storage` after calling this. pub async fn delete_identity() -> Result<()> { @@ -502,6 +574,19 @@ fn reconcile_trade_key_index( } } +/// Restore the backup-confirmed flag from the persisted identity record on +/// load. Only trusts a stored value that belongs to the same identity (guards +/// against a leftover blob from a previous mnemonic), and defaults to +/// `false` — importing a mnemonic is not itself the in-app backup ritual, so +/// an identity with no persisted flag stays unconfirmed and keeps the reminder +/// armed (issue #141). +fn restore_backup_confirmed(stored: Option<&IdentityInfo>, public_key: &str) -> bool { + match stored { + Some(info) if info.public_key == public_key => info.backup_confirmed, + _ => false, + } +} + /// [`reconcile_trade_key_index`], publishing the result when the database knew /// a higher counter than the value Flutter passed in. That is exactly the case /// where secure storage is behind — an installation from before it was kept in @@ -587,6 +672,7 @@ mod tests { privacy_mode: false, trade_key_index, created_at: 1, + backup_confirmed: false, } } @@ -598,6 +684,145 @@ mod tests { (tx, TradeKeyIndexStream { rx }) } + /// A `Storage` whose `save_identity` always fails, for exercising the + /// persist-then-commit path with an injected failure. The seams under test + /// only call `save_identity`, so every other method is `unimplemented!()` — + /// reaching one would be a test bug, not silent success. + struct FailingStore; + + impl Storage for FailingStore { + async fn save_identity(&self, _identity: &IdentityInfo) -> Result<()> { + anyhow::bail!("injected save failure") + } + async fn save_order(&self, _order: &crate::api::types::OrderInfo) -> Result<()> { + unimplemented!() + } + async fn get_order(&self, _id: &str) -> Result> { + unimplemented!() + } + async fn delete_order(&self, _id: &str) -> Result<()> { + unimplemented!() + } + async fn list_orders(&self) -> Result> { + unimplemented!() + } + async fn save_trade(&self, _trade: &crate::api::types::TradeInfo) -> Result<()> { + unimplemented!() + } + async fn get_trade(&self, _id: &str) -> Result> { + unimplemented!() + } + async fn list_trades(&self) -> Result> { + unimplemented!() + } + async fn save_message(&self, _msg: &crate::api::types::ChatMessage) -> Result<()> { + unimplemented!() + } + async fn list_messages( + &self, + _trade_id: &str, + ) -> Result> { + unimplemented!() + } + async fn mark_messages_read(&self, _trade_id: &str) -> Result<()> { + unimplemented!() + } + async fn message_exists(&self, _id: &str) -> Result { + unimplemented!() + } + async fn save_relay(&self, _relay: &crate::api::types::RelayInfo) -> Result<()> { + unimplemented!() + } + async fn delete_relay(&self, _url: &str) -> Result<()> { + unimplemented!() + } + async fn list_relays(&self) -> Result> { + unimplemented!() + } + async fn get_identity(&self) -> Result> { + unimplemented!() + } + async fn delete_identity(&self) -> Result<()> { + unimplemented!() + } + async fn save_queued_message( + &self, + _msg: &crate::queue::outbox::QueuedMessage, + ) -> Result<()> { + unimplemented!() + } + async fn list_queued_messages( + &self, + ) -> Result> { + unimplemented!() + } + async fn update_queued_message_status( + &self, + _id: &str, + _status: crate::api::types::QueuedMessageStatus, + ) -> Result<()> { + unimplemented!() + } + async fn delete_queued_message(&self, _id: &str) -> Result<()> { + unimplemented!() + } + async fn save_trade_key(&self, _order_id: &str, _key_index: u32) -> Result<()> { + unimplemented!() + } + async fn get_trade_key(&self, _order_id: &str) -> Result> { + unimplemented!() + } + async fn get_order_id_by_trade_index(&self, _key_index: u32) -> Result> { + unimplemented!() + } + async fn delete_trade_key(&self, _order_id: &str) -> Result<()> { + unimplemented!() + } + async fn clear_trade_keys(&self) -> Result<()> { + unimplemented!() + } + async fn get_setting(&self, _key: &str) -> Result> { + unimplemented!() + } + async fn set_setting(&self, _key: &str, _value: &str) -> Result<()> { + unimplemented!() + } + async fn delete_setting(&self, _key: &str) -> Result<()> { + unimplemented!() + } + async fn save_active_mostro_pubkey(&self, _pubkey: &str) -> Result<()> { + unimplemented!() + } + async fn get_active_mostro_pubkey(&self) -> Result> { + unimplemented!() + } + async fn get_trade_by_order_id( + &self, + _order_id: &str, + ) -> Result> { + unimplemented!() + } + async fn delete_trade_by_order_id(&self, _order_id: &str) -> Result<()> { + unimplemented!() + } + async fn update_trade_order_id( + &self, + _old_order_id: &str, + _new_order_id: &str, + ) -> Result<()> { + unimplemented!() + } + async fn update_trade_fields( + &self, + _order_id: &str, + _status: Option, + _hold_invoice: Option, + _amount_sats: Option, + ) -> Result<()> { + unimplemented!() + } + } + #[test] fn deriving_without_durable_storage_is_refused() { // Asserted as a pure decision, not through `derive_trade_key`: that @@ -666,6 +891,40 @@ mod tests { assert_eq!(reconcile_trade_key_index(3, Some(&stored), "abc"), 3); } + // ── backup_confirmed restore (#141) ─────────────────────────────────────── + #[test] + fn restore_reads_the_persisted_backup_flag_for_the_same_identity() { + let mut stored = stored_identity("abc", 4); + stored.backup_confirmed = true; + assert!(restore_backup_confirmed(Some(&stored), "abc")); + } + + #[test] + fn restore_defaults_to_unconfirmed_when_nothing_is_persisted() { + // No stored record: a fresh import has not completed the backup ritual, + // so the reminder must stay armed. + assert!(!restore_backup_confirmed(None, "abc")); + } + + #[test] + fn restore_ignores_a_backup_flag_from_another_identity() { + // A leftover blob from a previous mnemonic must not mark the new + // identity as backed up. + let mut stored = stored_identity("other-pubkey", 0); + stored.backup_confirmed = true; + assert!(!restore_backup_confirmed(Some(&stored), "abc")); + } + + #[test] + fn an_identity_persisted_before_the_field_deserializes_as_unconfirmed() { + // Serde default: an identity JSON blob written before backup_confirmed + // existed has no such key, and must load as `false` (reminder armed), + // not error. + let legacy = r#"{"public_key":"abc","display_name":null,"privacy_mode":false,"trade_key_index":3,"created_at":1}"#; + let info: IdentityInfo = serde_json::from_str(legacy).unwrap(); + assert!(!info.backup_confirmed); + } + #[test] fn reconcile_without_stored_identity_keeps_passed_index() { assert_eq!(reconcile_trade_key_index(7, None, "abc"), 7); @@ -707,6 +966,46 @@ mod tests { let current = get_identity().await.unwrap().unwrap(); assert_eq!(current.trade_key_index, 22); + // ── #141 set_backup_confirmed: persist-then-commit under a failing store ── + // Kept in this one identity_lock test on purpose: a separate #[tokio::test] + // touching the singleton would race this one. The identity loaded above + // starts unconfirmed. + assert!(!current.backup_confirmed); + // (1) A failing store errors with the StorageError marker and leaves the + // in-memory flag unchanged — the persist happens before the commit, so a + // save failure never flips it (pins commit 6cb67f7). + let backup_err = set_backup_confirmed_with(Some(&FailingStore), true) + .await + .unwrap_err() + .to_string(); + assert!( + backup_err.contains("StorageError:"), + "unexpected error: {backup_err}" + ); + assert!( + !get_identity().await.unwrap().unwrap().backup_confirmed, + "a failed persist must not flip the in-memory backup flag", + ); + // (2) Retry against the working store writes: the `== confirmed` + // short-circuit was not poisoned by a half-applied mutation. + set_backup_confirmed_with(Some(&db), true).await.unwrap(); + assert!( + get_identity().await.unwrap().unwrap().backup_confirmed, + "retry against a working store must persist the backup flag", + ); + assert!( + db.get_identity().await.unwrap().unwrap().backup_confirmed, + "the working store must hold the confirmed flag durably", + ); + // reset_backup_confirmation() re-arms the reminder by flipping the flag + // back (it delegates to set_backup_confirmed(false)); with an identity + // loaded it is not the early-return no-op path. + reset_backup_confirmation().await.unwrap(); + assert!( + !get_identity().await.unwrap().unwrap().backup_confirmed, + "reset_backup_confirmation must clear the in-memory flag", + ); + crate::api::logging::forward_log(log::Level::Info, "identity_probe", "before delete"); delete_identity().await.unwrap(); diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 771389e7..57e79a16 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -337,6 +337,13 @@ pub struct IdentityInfo { pub privacy_mode: bool, pub trade_key_index: u32, pub created_at: i64, + /// Whether the user has confirmed a backup of the current identity's + /// secret words (issue #141 — migrated out of Dart SharedPreferences into + /// the Rust identity record per Principle I). `#[serde(default)]` so + /// identities persisted before this field deserialize as `false` — an + /// unconfirmed backup, which correctly keeps the reminder armed. + #[serde(default)] + pub backup_confirmed: bool, } /// Deterministic pseudonymous identity derived from a public key. diff --git a/rust/src/db/sqlite.rs b/rust/src/db/sqlite.rs index 42eb8f55..56b4a832 100644 --- a/rust/src/db/sqlite.rs +++ b/rust/src/db/sqlite.rs @@ -1017,6 +1017,7 @@ mod tests { privacy_mode: false, trade_key_index: 21, created_at: 1_700_000_000, + backup_confirmed: false, }; storage.save_identity(&identity).await.unwrap(); let loaded = storage.get_identity().await.unwrap().unwrap(); @@ -1025,9 +1026,13 @@ mod tests { // INSERT OR REPLACE keeps a single row with the latest counter. identity.trade_key_index = 22; + // The backup-confirmed flag rides in the same JSON blob (#141) and must + // survive the round-trip alongside the counter. + identity.backup_confirmed = true; storage.save_identity(&identity).await.unwrap(); let loaded = storage.get_identity().await.unwrap().unwrap(); assert_eq!(loaded.trade_key_index, 22); + assert!(loaded.backup_confirmed, "backup_confirmed must persist"); drop(storage); let _ = std::fs::remove_file(&path); @@ -1045,6 +1050,7 @@ mod tests { privacy_mode: false, trade_key_index: 7, created_at: 1_700_000_000, + backup_confirmed: false, }; storage.save_identity(&identity).await.unwrap(); storage.save_trade_key("order-1", 5).await.unwrap(); diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 29c59dbb..4bcb87ea 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -48,7 +48,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 659438006; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1535564465; // Section: executor @@ -1941,6 +1941,41 @@ fn wire__crate__api__messages__get_attachment_status_impl( }, ) } +fn wire__crate__api__identity__get_backup_confirmed_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_backup_confirmed", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::identity::get_backup_confirmed().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__nwc__get_balance_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3861,6 +3896,41 @@ fn wire__crate__api__nostr__remove_relay_impl( }, ) } +fn wire__crate__api__identity__reset_backup_confirmation_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "reset_backup_confirmation", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::api::identity::reset_backup_confirmation().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__orders__restart_orders_subscription_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4097,6 +4167,43 @@ fn wire__crate__api__settings__set_active_mostro_node_impl( }, ) } +fn wire__crate__api__identity__set_backup_confirmed_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "set_backup_confirmed", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_confirmed = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::api::identity::set_backup_confirmed(api_confirmed).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__escrow__set_cashu_mint_url_override_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5238,12 +5345,14 @@ impl SseDecode for crate::api::types::IdentityInfo { let mut var_privacyMode = ::sse_decode(deserializer); let mut var_tradeKeyIndex = ::sse_decode(deserializer); let mut var_createdAt = ::sse_decode(deserializer); + let mut var_backupConfirmed = ::sse_decode(deserializer); return crate::api::types::IdentityInfo { public_key: var_publicKey, display_name: var_displayName, privacy_mode: var_privacyMode, trade_key_index: var_tradeKeyIndex, created_at: var_createdAt, + backup_confirmed: var_backupConfirmed, }; } } @@ -6250,187 +6359,199 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 40 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 50 => { + 40 => { + wire__crate__api__identity__get_backup_confirmed_impl(port, ptr, rust_vec_len, data_len) + } + 41 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), + 45 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), + 47 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), + 51 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 51 => wire__crate__api__reputation__get_rating_for_trade_impl( + 52 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 52 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 55 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 56 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 58 => wire__crate__api__disputes__handle_admin_canceled_impl( + 53 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), + 57 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), + 58 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), + 59 => wire__crate__api__disputes__handle_admin_canceled_impl( port, ptr, rust_vec_len, data_len, ), - 59 => { + 60 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 60 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 61 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 61 => wire__crate__api__reputation__handle_rating_received_impl( + 62 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 62 => { + 63 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 63 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 64 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), + 67 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__identity__load_identity_from_mnemonic_impl( port, ptr, rust_vec_len, data_len, ), - 69 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 71 => wire__crate__api__messages__on_attachment_progress_impl( + 70 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), + 71 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), + 72 => wire__crate__api__messages__on_attachment_progress_impl( port, ptr, rust_vec_len, data_len, ), - 72 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), - 73 => wire__crate__api__nostr__on_connection_state_changed_impl( + 73 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), + 74 => wire__crate__api__nostr__on_connection_state_changed_impl( port, ptr, rust_vec_len, data_len, ), - 74 => { + 75 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 75 => { + 76 => { wire__crate__api__escrow__on_escrow_mode_changed_impl(port, ptr, rust_vec_len, data_len) } - 76 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 77 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 78 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 79 => { + 77 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), + 78 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), + 79 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), + 80 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 80 => { + 81 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 81 => { + 82 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 82 => wire__crate__api__identity__on_trade_key_index_changed_impl( + 83 => wire__crate__api__identity__on_trade_key_index_changed_impl( port, ptr, rust_vec_len, data_len, ), - 83 => wire__crate__api__orders__on_trade_updated_impl(port, ptr, rust_vec_len, data_len), - 84 => wire__crate__api__messages__on_unread_count_changed_impl( + 84 => wire__crate__api__orders__on_trade_updated_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 85 => { + 86 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 86 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 87 => { + 87 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 88 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 88 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 89 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), - 90 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + 89 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 90 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), + 91 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 91 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( + 92 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( port, ptr, rust_vec_len, data_len, ), - 92 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 93 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 94 => wire__crate__api__orders__restart_orders_subscription_impl( + 93 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 94 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 95 => wire__crate__api__identity__reset_backup_confirmation_impl( port, ptr, rust_vec_len, data_len, ), - 95 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 96 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 98 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 99 => wire__crate__api__settings__set_active_mostro_node_impl( + 96 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 100 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( + 97 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), + 98 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), + 99 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), + 100 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), + 101 => wire__crate__api__settings__set_active_mostro_node_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 102 => { + wire__crate__api__identity__set_backup_confirmed_impl(port, ptr, rust_vec_len, data_len) + } + 103 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( port, ptr, rust_vec_len, data_len, ), - 101 => wire__crate__api__settings__set_default_fiat_code_impl( + 104 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 102 => wire__crate__api__settings__set_default_lightning_address_impl( + 105 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 103 => wire__crate__api__escrow__set_escrow_mode_override_impl( + 106 => wire__crate__api__escrow__set_escrow_mode_override_impl( port, ptr, rust_vec_len, data_len, ), - 104 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 105 => { + 107 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 108 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 106 => { + 109 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 107 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 108 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 109 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 110 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 111 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 110 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 111 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 112 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 113 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 114 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -7069,6 +7190,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::types::IdentityInfo { self.privacy_mode.into_into_dart().into_dart(), self.trade_key_index.into_into_dart().into_dart(), self.created_at.into_into_dart().into_dart(), + self.backup_confirmed.into_into_dart().into_dart(), ] .into_dart() } @@ -8258,6 +8380,7 @@ impl SseEncode for crate::api::types::IdentityInfo { ::sse_encode(self.privacy_mode, serializer); ::sse_encode(self.trade_key_index, serializer); ::sse_encode(self.created_at, serializer); + ::sse_encode(self.backup_confirmed, serializer); } } diff --git a/test/features/account/backup_reminder_provider_test.dart b/test/features/account/backup_reminder_provider_test.dart index ffdaeced..99e96c7a 100644 --- a/test/features/account/backup_reminder_provider_test.dart +++ b/test/features/account/backup_reminder_provider_test.dart @@ -61,59 +61,65 @@ void main() { expect(notifier.state, isTrue); }); - test('showBackupReminder(): arms the badge and clears prior state', - () async { - SharedPreferences.setMockInitialValues({ - kBackupReminderDismissedKey: true, - kBackupCompletedKey: true, - kBackupSnoozedUntilKey: _inFuture(const Duration(days: 1)), - }); - - final notifier = BackupReminderNotifier(); - await notifier.showBackupReminder(); - - expect(notifier.state, isTrue); - final prefs = await _prefs(); - expect(prefs.getBool(kBackupReminderActiveKey), isTrue); - expect(prefs.getBool(kBackupReminderDismissedKey), isFalse); - expect(prefs.getBool(kBackupCompletedKey), isFalse); - expect(prefs.getInt(kBackupSnoozedUntilKey), isNull); - }); - - test('snoozeUntilTomorrow(): hides badge and persists a future snooze', - () async { - SharedPreferences.setMockInitialValues({ - kBackupReminderActiveKey: true, - kBackupReminderDismissedKey: false, - }); - - final notifier = BackupReminderNotifier(); - await notifier.snoozeUntilTomorrow(); - - expect(notifier.state, isFalse); - final prefs = await _prefs(); - final until = prefs.getInt(kBackupSnoozedUntilKey); - expect(until, isNotNull); - expect(until, greaterThan(DateTime.now().millisecondsSinceEpoch)); - }); - - test('confirmBackupComplete(): permanently dismisses the reminder', - () async { - SharedPreferences.setMockInitialValues({ - kBackupReminderActiveKey: true, - kBackupReminderDismissedKey: false, - kBackupSnoozedUntilKey: _inFuture(const Duration(days: 1)), - }); - - final notifier = BackupReminderNotifier(); - await notifier.confirmBackupComplete(); - - expect(notifier.state, isFalse); - final prefs = await _prefs(); - expect(prefs.getBool(kBackupReminderDismissedKey), isTrue); - expect(prefs.getBool(kBackupCompletedKey), isTrue); - expect(prefs.getInt(kBackupSnoozedUntilKey), isNull); - }); + test( + 'showBackupReminder(): arms the badge and clears prior state', + () async { + SharedPreferences.setMockInitialValues({ + kBackupReminderDismissedKey: true, + kBackupCompletedKey: true, + kBackupSnoozedUntilKey: _inFuture(const Duration(days: 1)), + }); + + final notifier = BackupReminderNotifier(); + await notifier.showBackupReminder(); + + expect(notifier.state, isTrue); + final prefs = await _prefs(); + expect(prefs.getBool(kBackupReminderActiveKey), isTrue); + expect(prefs.getBool(kBackupReminderDismissedKey), isFalse); + expect(prefs.getBool(kBackupCompletedKey), isFalse); + expect(prefs.getInt(kBackupSnoozedUntilKey), isNull); + }, + ); + + test( + 'snoozeUntilTomorrow(): hides badge and persists a future snooze', + () async { + SharedPreferences.setMockInitialValues({ + kBackupReminderActiveKey: true, + kBackupReminderDismissedKey: false, + }); + + final notifier = BackupReminderNotifier(); + await notifier.snoozeUntilTomorrow(); + + expect(notifier.state, isFalse); + final prefs = await _prefs(); + final until = prefs.getInt(kBackupSnoozedUntilKey); + expect(until, isNotNull); + expect(until, greaterThan(DateTime.now().millisecondsSinceEpoch)); + }, + ); + + test( + 'confirmBackupComplete(): permanently dismisses the reminder', + () async { + SharedPreferences.setMockInitialValues({ + kBackupReminderActiveKey: true, + kBackupReminderDismissedKey: false, + kBackupSnoozedUntilKey: _inFuture(const Duration(days: 1)), + }); + + final notifier = BackupReminderNotifier(); + await notifier.confirmBackupComplete(); + + expect(notifier.state, isFalse); + final prefs = await _prefs(); + expect(prefs.getBool(kBackupReminderDismissedKey), isTrue); + expect(prefs.getBool(kBackupCompletedKey), isTrue); + expect(prefs.getInt(kBackupSnoozedUntilKey), isNull); + }, + ); test('initialValue with a live snooze is reconciled to off', () async { SharedPreferences.setMockInitialValues({ @@ -128,47 +134,185 @@ void main() { }); }); - group('BackupCompletedNotifier', () { - test('load(): reads the explicit completed flag', () async { - SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); + group('BackupCompletedNotifier (backed by the Rust bridge, #141)', () { + // A fake identity-bridge backing store so the notifier is exercised without + // a live Rust runtime. + BackupCompletedNotifier makeNotifier({required bool initial}) { + var confirmed = initial; + return BackupCompletedNotifier( + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = false, + isWebOverride: false, + ); + } + + test('load(): reads the confirmed flag from the bridge', () async { + SharedPreferences.setMockInitialValues({ + 'backupCompletedMigratedToRust': true, + }); - final notifier = BackupCompletedNotifier(); + final notifier = makeNotifier(initial: true); await notifier.load(); expect(notifier.state, isTrue); }); - test('load(): legacy installs fall back to the dismissed flag', () async { + test( + 'load(): migrates a legacy SharedPreferences flag into the bridge once', + () async { + // Legacy install: completed flag set, no migration marker. load() copies + // it into the bridge, marks the migration done, then reads the bridge. + SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); + + var confirmed = false; + final notifier = BackupCompletedNotifier( + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = false, + isWebOverride: false, + ); + await notifier.load(); + + expect(confirmed, isTrue, reason: 'legacy flag copied into the bridge'); + expect(notifier.state, isTrue); + final prefs = await _prefs(); + expect(prefs.getBool('backupCompletedMigratedToRust'), isTrue); + }, + ); + + test( + 'load(): concurrent calls run the migration write exactly once', + () async { + // The constructor fires load() un-awaited; a caller may await load() + // before it finishes. Both must share one in-flight future so the + // one-time migration calls the bridge exactly once. (#141 review) + SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); + var setCount = 0; + final notifier = BackupCompletedNotifier( + getConfirmed: () async => setCount > 0, + setConfirmed: (v) async => setCount++, + resetConfirmed: () async {}, + isWebOverride: false, + ); + // Two overlapping loads (plus the un-awaited one from the constructor). + await Future.wait([notifier.load(), notifier.load()]); + expect( + setCount, + 1, + reason: 'migration must write through the bridge exactly once', + ); + expect(notifier.state, isTrue); + }, + ); + + test('markCompleted() writes true through the bridge', () async { SharedPreferences.setMockInitialValues({ - kBackupReminderDismissedKey: true, + 'backupCompletedMigratedToRust': true, }); - final notifier = BackupCompletedNotifier(); - await notifier.load(); + // Track the fake bridge's backing value so we assert the write reached it, + // not only that notifier.state flipped. + var confirmed = false; + final notifier = BackupCompletedNotifier( + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = false, + isWebOverride: false, + ); + await notifier.markCompleted(); + expect( + confirmed, + isTrue, + reason: 'markCompleted must write to the bridge', + ); expect(notifier.state, isTrue); }); - test('markCompleted() persists and flips state on', () async { - SharedPreferences.setMockInitialValues({}); + test('reset() clears the flag through the bridge', () async { + SharedPreferences.setMockInitialValues({ + 'backupCompletedMigratedToRust': true, + }); - final notifier = BackupCompletedNotifier(); - await notifier.markCompleted(); + var confirmed = true; + final notifier = BackupCompletedNotifier( + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = false, + isWebOverride: false, + ); + await notifier.reset(); - expect(notifier.state, isTrue); - final prefs = await _prefs(); - expect(prefs.getBool(kBackupCompletedKey), isTrue); + expect(confirmed, isFalse, reason: 'reset must clear the bridge value'); + expect(notifier.state, isFalse); }); + }); - test('reset() clears the backed-up flag', () async { + group('BackupCompletedNotifier — web (SharedPreferences authoritative, #233)', () { + // On web the Rust identity store is a stub (#233), so SharedPreferences + // (localStorage) is the durable source for the backup-confirmed flag. + // These force the web path via isWebOverride and assert the flag round-trips + // through SharedPreferences across a simulated reload. (#141 review) + test( + 'web: markCompleted persists to SharedPreferences and survives reload', + () async { + SharedPreferences.setMockInitialValues({}); + final n1 = BackupCompletedNotifier(isWebOverride: true); + await n1.load(); + expect( + n1.state, + isFalse, + reason: 'fresh web install starts unconfirmed', + ); + await n1.markCompleted(); + expect(n1.state, isTrue); + final prefs = await _prefs(); + expect( + prefs.getBool(kBackupCompletedKey), + isTrue, + reason: 'web write must reach SharedPreferences', + ); + // Simulate a page reload: a brand-new notifier reads the durable value. + final n2 = BackupCompletedNotifier(isWebOverride: true); + await n2.load(); + expect( + n2.state, + isTrue, + reason: 'a confirmed backup must survive a web reload', + ); + }, + ); + + test('web: reset clears the SharedPreferences flag', () async { SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); + final n = BackupCompletedNotifier(isWebOverride: true); + await n.load(); + expect(n.state, isTrue); + await n.reset(); + expect(n.state, isFalse); + final prefs = await _prefs(); + expect( + prefs.getBool(kBackupCompletedKey), + isFalse, + reason: 'web reset must clear the durable flag', + ); + }); - final notifier = BackupCompletedNotifier(); - await notifier.reset(); - - expect(notifier.state, isFalse); + test('web: load does not run the native migration', () async { + // A legacy completed flag with no migration marker: on web we must NOT + // consume it via migration; SharedPreferences stays authoritative and the + // migration marker is never written. + SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); + final n = BackupCompletedNotifier(isWebOverride: true); + await n.load(); + expect(n.state, isTrue, reason: 'web reads the flag directly'); final prefs = await _prefs(); - expect(prefs.getBool(kBackupCompletedKey), isFalse); + expect( + prefs.getBool('backupCompletedMigratedToRust'), + isNull, + reason: 'web must not set the migration marker', + ); }); }); } diff --git a/test/features/account/backup_ritual_screen_test.dart b/test/features/account/backup_ritual_screen_test.dart index 4d93b80f..712b1d97 100644 --- a/test/features/account/backup_ritual_screen_test.dart +++ b/test/features/account/backup_ritual_screen_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mostro/features/account/providers/backup_reminder_provider.dart'; import 'package:mostro/features/account/screens/backup_ritual_screen.dart'; import 'package:mostro/l10n/app_localizations.dart'; @@ -18,8 +19,22 @@ Future _pumpRitual(WidgetTester tester) async { addTearDown(tester.view.resetDevicePixelRatio); await tester.pumpWidget( - const ProviderScope( - child: MaterialApp( + ProviderScope( + overrides: [ + // The backup-completed flag is persisted through the Rust identity + // bridge (#141), which is unavailable under flutter_test — back it with + // an in-memory fake so tapping "confirm" doesn't hit the real bridge. + backupCompletedProvider.overrideWith((ref) { + var confirmed = false; + return BackupCompletedNotifier( + initialValue: false, + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = false, + ); + }), + ], + child: const MaterialApp( locale: Locale('en'), localizationsDelegates: [ AppLocalizations.delegate,