diff --git a/lib/features/account/providers/backup_reminder_provider.dart b/lib/features/account/providers/backup_reminder_provider.dart index 59631982..86f33550 100644 --- a/lib/features/account/providers/backup_reminder_provider.dart +++ b/lib/features/account/providers/backup_reminder_provider.dart @@ -1,6 +1,8 @@ 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'; @@ -112,7 +114,21 @@ 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, + }) : _getConfirmed = getConfirmed ?? identity_api.getBackupConfirmed, + _setConfirmed = setConfirmed ?? + ((confirmed) => + identity_api.setBackupConfirmed(confirmed: confirmed)), + _resetConfirmed = + resetConfirmed ?? identity_api.resetBackupConfirmation, + super(initialValue ?? false) { if (initialValue == null) { load(); } else { @@ -120,33 +136,61 @@ class BackupCompletedNotifier extends StateNotifier { } } + final Future Function() _getConfirmed; + final Future Function(bool confirmed) _setConfirmed; + final Future Function() _resetConfirmed; + bool _loaded = false; + /// 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'; + 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; + // 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 { + 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 migration flag is only set once the copy sticks. + await _setConfirmed(true); + } + await prefs.setBool(_kMigratedKey, true); + } + state = await _getConfirmed(); + } catch (_) { + // Rust unavailable (e.g. no identity yet, or tests without the bridge): + // fall back to unconfirmed so the reminder stays armed. + state = false; + } _loaded = true; } - /// 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 _setConfirmed(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 _resetConfirmed(); state = false; } } diff --git a/rust/src/api/identity.rs b/rust/src/api/identity.rs index b7e4f600..782127d5 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,60 @@ 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<()> { + 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) = crate::db::app_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 +564,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 +662,7 @@ mod tests { privacy_mode: false, trade_key_index, created_at: 1, + backup_confirmed: false, } } @@ -666,6 +742,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); diff --git a/rust/src/api/types.rs b/rust/src/api/types.rs index 12af0c16..599321d2 100644 --- a/rust/src/api/types.rs +++ b/rust/src/api/types.rs @@ -324,6 +324,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 7ae59ad1..9d83709a 100644 --- a/rust/src/db/sqlite.rs +++ b/rust/src/db/sqlite.rs @@ -940,6 +940,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(); @@ -948,9 +949,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); @@ -968,6 +973,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 17b6edb0..d6f62684 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 = -1417387575; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1124474174; // Section: executor @@ -1883,6 +1883,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, @@ -3768,6 +3803,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, @@ -4004,6 +4074,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, @@ -5122,12 +5229,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, }; } } @@ -6105,186 +6214,198 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 39 => wire__crate__api__nwc__get_balance_impl(port, ptr, rust_vec_len, data_len), - 40 => wire__crate__api__nostr__get_connection_state_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__disputes__get_dispute_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__escrow__get_escrow_mode_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__identity__get_identity_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__messages__get_messages_impl(port, ptr, rust_vec_len, data_len), - 45 => wire__crate__api__settings__get_mostro_pubkey_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__identity__get_nym_identity_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__orders__get_order_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__orders__get_orders_impl(port, ptr, rust_vec_len, data_len), - 49 => { + 39 => { + wire__crate__api__identity__get_backup_confirmed_impl(port, ptr, 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 => { wire__crate__api__reputation__get_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 50 => wire__crate__api__reputation__get_rating_for_trade_impl( + 51 => wire__crate__api__reputation__get_rating_for_trade_impl( port, ptr, rust_vec_len, data_len, ), - 51 => wire__crate__api__nostr__get_relays_impl(port, ptr, rust_vec_len, data_len), - 52 => wire__crate__api__settings__get_settings_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__identity__get_trade_key_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__orders__get_trade_role_impl(port, ptr, rust_vec_len, data_len), - 55 => wire__crate__api__messages__get_unread_count_impl(port, ptr, rust_vec_len, data_len), - 56 => wire__crate__api__nwc__get_wallet_impl(port, ptr, rust_vec_len, data_len), - 57 => wire__crate__api__disputes__handle_admin_canceled_impl( + 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( port, ptr, rust_vec_len, data_len, ), - 58 => { + 59 => { wire__crate__api__disputes__handle_admin_settled_impl(port, ptr, rust_vec_len, data_len) } - 59 => wire__crate__api__disputes__handle_admin_took_dispute_impl( + 60 => wire__crate__api__disputes__handle_admin_took_dispute_impl( port, ptr, rust_vec_len, data_len, ), - 60 => wire__crate__api__reputation__handle_rating_received_impl( + 61 => wire__crate__api__reputation__handle_rating_received_impl( port, ptr, rust_vec_len, data_len, ), - 61 => { + 62 => { wire__crate__api__identity__import_from_mnemonic_impl(port, ptr, rust_vec_len, data_len) } - 62 => wire__crate__api__identity__import_from_nsec_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__init_db_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__nostr__initialize_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__logging__install_log_bridge_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__orders__list_trades_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__identity__load_identity_from_mnemonic_impl( + 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( port, ptr, rust_vec_len, data_len, ), - 68 => wire__crate__api__nwc__make_invoice_impl(port, ptr, rust_vec_len, data_len), - 69 => wire__crate__api__messages__mark_as_read_impl(port, ptr, rust_vec_len, data_len), - 70 => wire__crate__api__messages__on_attachment_progress_impl( + 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( port, ptr, rust_vec_len, data_len, ), - 71 => wire__crate__api__bond__on_bond_slashed_impl(port, ptr, rust_vec_len, data_len), - 72 => wire__crate__api__nostr__on_connection_state_changed_impl( + 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( port, ptr, rust_vec_len, data_len, ), - 73 => { + 74 => { wire__crate__api__disputes__on_dispute_updated_impl(port, ptr, rust_vec_len, data_len) } - 74 => { + 75 => { wire__crate__api__escrow__on_escrow_mode_changed_impl(port, ptr, rust_vec_len, data_len) } - 75 => wire__crate__api__logging__on_log_entry_impl(port, ptr, rust_vec_len, data_len), - 76 => wire__crate__api__messages__on_new_message_impl(port, ptr, rust_vec_len, data_len), - 77 => wire__crate__api__orders__on_orders_updated_impl(port, ptr, rust_vec_len, data_len), - 78 => { + 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 => { wire__crate__api__reputation__on_rating_received_impl(port, ptr, rust_vec_len, data_len) } - 79 => { + 80 => { wire__crate__api__nostr__on_relay_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 80 => { + 81 => { wire__crate__api__settings__on_settings_changed_impl(port, ptr, rust_vec_len, data_len) } - 81 => wire__crate__api__identity__on_trade_key_index_changed_impl( + 82 => wire__crate__api__identity__on_trade_key_index_changed_impl( port, ptr, rust_vec_len, data_len, ), - 82 => wire__crate__api__messages__on_unread_count_changed_impl( + 83 => wire__crate__api__messages__on_unread_count_changed_impl( port, ptr, rust_vec_len, data_len, ), - 83 => { + 84 => { wire__crate__api__nwc__on_wallet_status_changed_impl(port, ptr, rust_vec_len, data_len) } - 84 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), - 85 => { + 85 => wire__crate__api__disputes__open_dispute_impl(port, ptr, rust_vec_len, data_len), + 86 => { wire__crate__api__orders__order_filters_default_impl(port, ptr, rust_vec_len, data_len) } - 86 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), - 87 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), - 88 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( + 87 => wire__crate__api__nwc__pay_invoice_impl(port, ptr, rust_vec_len, data_len), + 88 => wire__crate__api__logging__recent_logs_impl(port, ptr, rust_vec_len, data_len), + 89 => wire__crate__api__settings__rehydrate_active_mostro_node_impl( port, ptr, rust_vec_len, data_len, ), - 89 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( + 90 => wire__crate__api__escrow__rehydrate_escrow_overrides_impl( port, ptr, rust_vec_len, data_len, ), - 90 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), - 91 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), - 92 => wire__crate__api__orders__restart_orders_subscription_impl( + 91 => wire__crate__api__orders__release_order_impl(port, ptr, rust_vec_len, data_len), + 92 => wire__crate__api__nostr__remove_relay_impl(port, ptr, rust_vec_len, data_len), + 93 => wire__crate__api__identity__reset_backup_confirmation_impl( port, ptr, rust_vec_len, data_len, ), - 93 => wire__crate__api__orders__send_fiat_sent_impl(port, ptr, rust_vec_len, data_len), - 94 => wire__crate__api__messages__send_file_impl(port, ptr, rust_vec_len, data_len), - 95 => wire__crate__api__orders__send_invoice_impl(port, ptr, rust_vec_len, data_len), - 96 => wire__crate__api__messages__send_message_impl(port, ptr, rust_vec_len, data_len), - 97 => wire__crate__api__settings__set_active_mostro_node_impl( + 94 => wire__crate__api__orders__restart_orders_subscription_impl( port, ptr, rust_vec_len, data_len, ), - 98 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( + 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( + port, + ptr, + rust_vec_len, + data_len, + ), + 100 => { + wire__crate__api__identity__set_backup_confirmed_impl(port, ptr, rust_vec_len, data_len) + } + 101 => wire__crate__api__escrow__set_cashu_mint_url_override_impl( port, ptr, rust_vec_len, data_len, ), - 99 => wire__crate__api__settings__set_default_fiat_code_impl( + 102 => wire__crate__api__settings__set_default_fiat_code_impl( port, ptr, rust_vec_len, data_len, ), - 100 => wire__crate__api__settings__set_default_lightning_address_impl( + 103 => wire__crate__api__settings__set_default_lightning_address_impl( port, ptr, rust_vec_len, data_len, ), - 101 => wire__crate__api__escrow__set_escrow_mode_override_impl( + 104 => wire__crate__api__escrow__set_escrow_mode_override_impl( port, ptr, rust_vec_len, data_len, ), - 102 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), - 103 => { + 105 => wire__crate__api__settings__set_language_impl(port, ptr, rust_vec_len, data_len), + 106 => { wire__crate__api__settings__set_logging_enabled_impl(port, ptr, rust_vec_len, data_len) } - 104 => { + 107 => { wire__crate__api__reputation__set_privacy_mode_impl(port, ptr, rust_vec_len, data_len) } - 105 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), - 106 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), - 107 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), - 108 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), - 109 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), + 108 => wire__crate__api__settings__set_theme_impl(port, ptr, rust_vec_len, data_len), + 109 => wire__crate__api__disputes__submit_evidence_impl(port, ptr, rust_vec_len, data_len), + 110 => wire__crate__api__reputation__submit_rating_impl(port, ptr, rust_vec_len, data_len), + 111 => wire__crate__api__orders__subscribe_orders_impl(port, ptr, rust_vec_len, data_len), + 112 => wire__crate__api__orders__take_order_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -6905,6 +7026,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() } @@ -8055,6 +8177,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..64119c05 100644 --- a/test/features/account/backup_reminder_provider_test.dart +++ b/test/features/account/backup_reminder_provider_test.dart @@ -128,47 +128,80 @@ void main() { }); }); - group('BackupCompletedNotifier', () { - test('load(): reads the explicit completed flag', () async { - SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); - - final notifier = BackupCompletedNotifier(); + 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, + ); + } + + test('load(): reads the confirmed flag from the bridge', () async { + SharedPreferences.setMockInitialValues( + {'backupCompletedMigratedToRust': true}); + + final notifier = makeNotifier(initial: true); await notifier.load(); expect(notifier.state, isTrue); }); - test('load(): legacy installs fall back to the dismissed flag', () async { - SharedPreferences.setMockInitialValues({ - kBackupReminderDismissedKey: true, - }); + 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}); - final notifier = BackupCompletedNotifier(); + var confirmed = false; + final notifier = BackupCompletedNotifier( + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = 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('markCompleted() persists and flips state on', () async { - SharedPreferences.setMockInitialValues({}); - - final notifier = BackupCompletedNotifier(); + test('markCompleted() writes true through the bridge', () async { + SharedPreferences.setMockInitialValues( + {'backupCompletedMigratedToRust': true}); + + // 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, + ); await notifier.markCompleted(); + expect(confirmed, isTrue, reason: 'markCompleted must write to the bridge'); expect(notifier.state, isTrue); - final prefs = await _prefs(); - expect(prefs.getBool(kBackupCompletedKey), isTrue); }); - test('reset() clears the backed-up flag', () async { - SharedPreferences.setMockInitialValues({kBackupCompletedKey: true}); + test('reset() clears the flag through the bridge', () async { + SharedPreferences.setMockInitialValues( + {'backupCompletedMigratedToRust': true}); - final notifier = BackupCompletedNotifier(); + var confirmed = true; + final notifier = BackupCompletedNotifier( + getConfirmed: () async => confirmed, + setConfirmed: (v) async => confirmed = v, + resetConfirmed: () async => confirmed = false, + ); await notifier.reset(); + expect(confirmed, isFalse, reason: 'reset must clear the bridge value'); expect(notifier.state, isFalse); - final prefs = await _prefs(); - expect(prefs.getBool(kBackupCompletedKey), isFalse); }); }); } 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,