lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 6538de41d7992251e06205f1450f6348b7a55cc1
parent edefb4d696ae3588f1f6503f9817e296a40d3d1f
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 15:53:20 +0000

app-rt: delegate runtime behavior to radroots sdk

- Replace the parallel net runtime with one shared SDK client
- Keep executor lifecycle and presentation mapping in the mobile host
- Remove duplicate account relay listing and lower-package engines
- Verify registry-resolved native FFI and WASM workspace targets

Diffstat:
Mcrates/mobile_core/Cargo.toml | 22+++-------------------
Mcrates/mobile_core/src/error.rs | 9+++++++++
Mcrates/mobile_core/src/lib.rs | 2--
Mcrates/mobile_core/src/runtime/builder.rs | 63+++++++++------------------------------------------------------
Mcrates/mobile_core/src/runtime/info.rs | 118++++++++++++++++++++++++++-----------------------------------------------------
Dcrates/mobile_core/src/runtime/key_management.rs | 365-------------------------------------------------------------------------------
Mcrates/mobile_core/src/runtime/mod.rs | 159++++++++++++++++++++++++++++---------------------------------------------------
Dcrates/mobile_core/src/runtime/nostr.rs | 375-------------------------------------------------------------------------------
Acrates/mobile_core/src/runtime/sdk.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/mobile_core/src/runtime/trade_listing.rs | 382-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/error_mapping.rs | 127-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/key_management.rs | 137-------------------------------------------------------------------------------
Dcrates/mobile_core/tests/no_nostr_runtime.rs | 150-------------------------------------------------------------------------------
Acrates/mobile_core/tests/sdk_runtime.rs | 38++++++++++++++++++++++++++++++++++++++
14 files changed, 259 insertions(+), 1793 deletions(-)

diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -17,28 +17,12 @@ crate-type = ["rlib"] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [features] -default = ["rt", "nostr-client"] -rt = ["radroots_net_core/rt"] -nostr-client = [ - "radroots_net_core/nostr-client", - "dep:radroots_core", - "dep:radroots_event_codec", - "dep:radroots_trade", - "dep:radroots_identity", - "dep:radroots_nostr", -] -directories = ["radroots_net_core/directories"] -fs-persistence = ["radroots_net_core/fs-persistence"] +default = ["rt"] +rt = [] [dependencies] radroots_log = { workspace = true } -radroots_event = { workspace = true } -radroots_net_core = { workspace = true, features = ["std"] } -radroots_core = { workspace = true, optional = true } -radroots_identity = { workspace = true, optional = true, default-features = false, features = ["std"] } -radroots_event_codec = { workspace = true, features = ["serde_json"], optional = true } -radroots_trade = { workspace = true, features = ["std", "serde", "serde_json"], optional = true } -radroots_nostr = { workspace = true, features = ["events"], optional = true } +radroots_sdk = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/mobile_core/src/error.rs b/crates/mobile_core/src/error.rs @@ -19,6 +19,15 @@ pub enum RadrootsAppError { } impl RadrootsAppError { + pub(crate) fn from_sdk(error: radroots_sdk::Error) -> Self { + let report = error.to_report(); + Self::Runtime(format!( + "{}: {}", + report.code().as_str(), + report.message().as_str() + )) + } + pub fn initialization(message: impl Into<String>) -> Self { Self::Initialization(message.into()) } diff --git a/crates/mobile_core/src/lib.rs b/crates/mobile_core/src/lib.rs @@ -5,6 +5,4 @@ pub mod logging; pub mod runtime; pub use error::RadrootsAppError; -pub use radroots_net_core::net::{BuildInfo, NetInfo}; -pub use radroots_net_core::{Net, NetHandle}; pub use runtime::RadrootsRuntime; diff --git a/crates/mobile_core/src/runtime/builder.rs b/crates/mobile_core/src/runtime/builder.rs @@ -1,61 +1,16 @@ -use radroots_net_core::NetHandle; -use radroots_net_core::builder::NetBuilder; -use radroots_net_core::config::NetConfig; +use crate::{RadrootsAppError, RadrootsRuntime}; -use crate::RadrootsAppError; - -pub struct RuntimeBuilder { - config: NetConfig, - manage_runtime: bool, -} - -impl Default for RuntimeBuilder { - fn default() -> Self { - Self::new() - } -} +/// Host-owned construction boundary for the shared SDK-backed runtime. +#[derive(Default)] +pub struct RuntimeBuilder; impl RuntimeBuilder { - pub fn new() -> Self { - Self { - config: NetConfig::default(), - manage_runtime: true, - } + #[must_use] + pub const fn new() -> Self { + Self } - pub fn with_config(mut self, config: NetConfig) -> Self { - self.config = config; - self - } - - pub fn manage_runtime(mut self, manage: bool) -> Self { - self.manage_runtime = manage; - self - } - - pub fn build(self) -> Result<NetHandle, RadrootsAppError> { - #[cfg(feature = "rt")] - { - match NetBuilder::new() - .config(self.config) - .manage_runtime(self.manage_runtime) - .build() - { - Ok(handle) => Ok(handle), - Err(err) => Err(RadrootsAppError::initialization(format!( - "net build failed: {err}" - ))), - } - } - - #[cfg(not(feature = "rt"))] - { - let handle = NetBuilder::new() - .config(self.config) - .manage_runtime(self.manage_runtime) - .build() - .expect("net build must succeed when rt feature is disabled"); - Ok(handle) - } + pub fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> { + RadrootsRuntime::new() } } diff --git a/crates/mobile_core/src/runtime/info.rs b/crates/mobile_core/src/runtime/info.rs @@ -1,10 +1,9 @@ use super::RadrootsRuntime; use chrono::Utc; -use radroots_net_core::net; use serde::Serialize; #[derive(Debug, Clone, Serialize, Default, uniffi::Record)] -pub struct NetBuildInfo { +pub struct RuntimeBuildInfo { pub crate_name: String, pub crate_version: String, pub rustc: Option<String>, @@ -13,22 +12,9 @@ pub struct NetBuildInfo { pub build_time_unix: Option<u64>, } -impl From<&net::BuildInfo> for NetBuildInfo { - fn from(b: &net::BuildInfo) -> Self { - Self { - crate_name: b.crate_name.to_string(), - crate_version: b.crate_version.to_string(), - rustc: b.rustc.map(|s| s.to_string()), - profile: b.profile.map(|s| s.to_string()), - git_sha: b.git_sha.map(|s| s.to_string()), - build_time_unix: b.build_time_unix, - } - } -} - #[derive(Debug, Clone, Serialize, uniffi::Record)] pub struct AppInfo { - pub build: NetBuildInfo, + pub build: RuntimeBuildInfo, pub started_unix_ms: i64, pub uptime_millis: i64, pub shutting_down: bool, @@ -38,82 +24,56 @@ pub struct AppInfo { #[derive(Debug, Clone, Serialize, uniffi::Record)] pub struct RuntimeInfo { pub app: AppInfo, - pub net: NetBuildInfo, + pub sdk: RuntimeBuildInfo, + pub sdk_closed: bool, } pub fn gather_runtime_info(runtime: &RadrootsRuntime) -> RuntimeInfo { let now_ms = Utc::now().timestamp_millis(); - let app_info = AppInfo { - build: app_build_info(), - started_unix_ms: runtime.started_unix_ms, - uptime_millis: now_ms - runtime.started_unix_ms, - shutting_down: runtime - .shutting_down - .load(std::sync::atomic::Ordering::SeqCst), - platform: runtime.platform_app.read().ok().and_then(|g| (*g).clone()), - }; - - let net_info = match runtime.net.lock() { - Ok(guard) => NetBuildInfo::from(&guard.info.build), - Err(_) => NetBuildInfo::default(), - }; - RuntimeInfo { - app: app_info, - net: net_info, + app: AppInfo { + build: app_build_info(), + started_unix_ms: runtime.started_unix_ms, + uptime_millis: now_ms - runtime.started_unix_ms, + shutting_down: runtime + .shutting_down + .load(std::sync::atomic::Ordering::SeqCst), + platform: runtime + .platform_app + .read() + .ok() + .and_then(|value| (*value).clone()), + }, + sdk: RuntimeBuildInfo { + crate_name: "radroots_sdk".to_owned(), + crate_version: "0.1.0-alpha".to_owned(), + ..RuntimeBuildInfo::default() + }, + sdk_closed: runtime.client.is_closed(), } } -pub fn app_build_info() -> NetBuildInfo { - NetBuildInfo { - crate_name: env!("CARGO_PKG_NAME").to_string(), - crate_version: env!("CARGO_PKG_VERSION").to_string(), - rustc: env_opt_to_owned(option_env!("RUSTC_VERSION")), - profile: env_opt_to_owned(option_env!("PROFILE")), - git_sha: env_opt_to_owned(option_env!("GIT_HASH")), - build_time_unix: env_opt_to_u64(option_env!("BUILD_TIME_UNIX")), +pub fn app_build_info() -> RuntimeBuildInfo { + RuntimeBuildInfo { + crate_name: env!("CARGO_PKG_NAME").to_owned(), + crate_version: env!("CARGO_PKG_VERSION").to_owned(), + rustc: option_env!("RUSTC_VERSION").map(str::to_owned), + profile: option_env!("PROFILE").map(str::to_owned), + git_sha: option_env!("GIT_HASH").map(str::to_owned), + build_time_unix: option_env!("BUILD_TIME_UNIX").and_then(|value| value.parse().ok()), } } -fn env_opt_to_owned(value: Option<&str>) -> Option<String> { - value.map(str::to_owned) -} - -fn env_opt_to_u64(value: Option<&str>) -> Option<u64> { - value.map(str::parse::<u64>).and_then(Result::ok) -} - #[cfg(test)] mod tests { - use super::NetBuildInfo; - use radroots_net_core::net; - - #[test] - fn net_build_info_from_copies_optional_fields() { - let source = net::BuildInfo { - crate_name: "radroots-net-core", - crate_version: "1.2.3", - rustc: Some("rustc 1.97.0"), - profile: Some("debug"), - git_sha: Some("abc123"), - build_time_unix: Some(1_700_000_000), - }; - - let out = NetBuildInfo::from(&source); - assert_eq!(out.crate_name, "radroots-net-core"); - assert_eq!(out.crate_version, "1.2.3"); - assert_eq!(out.rustc.as_deref(), Some("rustc 1.97.0")); - assert_eq!(out.profile.as_deref(), Some("debug")); - assert_eq!(out.git_sha.as_deref(), Some("abc123")); - assert_eq!(out.build_time_unix, Some(1_700_000_000)); - } - #[test] - fn env_opt_helpers_cover_some_none_and_parse_failure() { - assert_eq!(super::env_opt_to_owned(Some("abc")).as_deref(), Some("abc")); - assert_eq!(super::env_opt_to_owned(None), None); - assert_eq!(super::env_opt_to_u64(Some("123")), Some(123)); - assert_eq!(super::env_opt_to_u64(Some("abc")), None); - assert_eq!(super::env_opt_to_u64(None), None); + fn build_info_uses_sdk_identity_without_lower_runtime_metadata() { + let runtime = super::RadrootsRuntime::new().expect("runtime"); + let info = runtime.info(); + assert_eq!(info.sdk.crate_name, "radroots_sdk"); + assert_eq!(info.sdk.crate_version, "0.1.0-alpha"); + assert!(!info.sdk_closed); + runtime.stop(); + assert!(runtime.info().sdk_closed); } } diff --git a/crates/mobile_core/src/runtime/key_management.rs b/crates/mobile_core/src/runtime/key_management.rs @@ -1,365 +0,0 @@ -use super::RadrootsRuntime; -use crate::RadrootsAppError; -#[cfg(feature = "nostr-client")] -use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrIdentityRecord { - pub id: String, - pub public_key_hex: String, - pub public_key_npub: String, - pub label: Option<String>, - pub is_selected: bool, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrIdentitySnapshot { - pub has_selected_signing_identity: bool, - pub selected_identity_id: Option<String>, - pub selected_npub: Option<String>, - pub identities: Vec<NostrIdentityRecord>, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrHostCustodyIdentity { - pub id: String, - pub public_key_hex: String, - pub public_key_npub: String, -} - -#[cfg(feature = "nostr-client")] -fn account_record( - net: &radroots_net_core::Net, - account_id: &RadrootsIdentityId, -) -> Result<NostrIdentityRecord, RadrootsAppError> { - let selected_identity_id = net - .accounts - .default_account_id() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - let account = net - .accounts - .list_accounts() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))? - .into_iter() - .find(|account| &account.account_id == account_id) - .ok_or_else(|| RadrootsAppError::identity(format!("identity not found: {account_id}")))?; - let is_selected = selected_identity_id - .as_ref() - .map(|selected| selected == &account.account_id) - .unwrap_or(false); - - Ok(NostrIdentityRecord { - id: account.account_id.to_string(), - public_key_hex: account.public_identity.public_key_hex, - public_key_npub: account.public_identity.public_key_npub, - label: account.label, - is_selected, - }) -} - -#[cfg(feature = "nostr-client")] -fn invalidate_nostr_runtime(net: &mut radroots_net_core::Net) { - net.set_nostr_signer(None); - net.nostr = None; -} - -#[cfg(feature = "nostr-client")] -fn identity_from_secret(secret_key: &str) -> Result<RadrootsIdentity, RadrootsAppError> { - RadrootsIdentity::from_secret_key_str(secret_key) - .map_err(|e| RadrootsAppError::identity(format!("{e}"))) -} - -#[cfg(feature = "nostr-client")] -fn host_custody_identity_from_secret( - secret_key: &str, -) -> Result<(RadrootsIdentity, NostrHostCustodyIdentity), RadrootsAppError> { - let identity = identity_from_secret(secret_key)?; - let record = NostrHostCustodyIdentity { - id: identity.id().to_string(), - public_key_hex: identity.public_key_hex(), - public_key_npub: identity.public_key_npub(), - }; - Ok((identity, record)) -} - -#[cfg(feature = "nostr-client")] -fn restore_host_custody_identity( - net: &mut radroots_net_core::Net, - identity: &RadrootsIdentity, - label: Option<String>, - make_selected: bool, -) -> Result<NostrIdentityRecord, RadrootsAppError> { - let account_id = net - .accounts - .upsert_identity(identity, label, make_selected) - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - invalidate_nostr_runtime(net); - account_record(net, &account_id) -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl RadrootsRuntime { - pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { - #[cfg(feature = "nostr-client")] - { - if let Ok(guard) = self.net.lock() { - return guard - .accounts - .default_signing_identity() - .ok() - .flatten() - .is_some(); - } - } - - #[cfg(not(feature = "nostr-client"))] - { - false - } - - #[cfg(feature = "nostr-client")] - false - } - - pub fn nostr_identity_selected_npub(&self) -> Option<String> { - #[cfg(feature = "nostr-client")] - { - if let Ok(guard) = self.net.lock() { - return guard - .accounts - .default_public_identity() - .ok() - .flatten() - .map(|identity| identity.public_key_npub); - } - } - - #[cfg(not(feature = "nostr-client"))] - { - None - } - - #[cfg(feature = "nostr-client")] - None - } - - pub fn nostr_identity_list(&self) -> Result<Vec<NostrIdentityRecord>, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let selected_identity_id = guard - .accounts - .default_account_id() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - let accounts = guard - .accounts - .list_accounts() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - Ok(accounts - .into_iter() - .map(|account| { - let is_selected = selected_identity_id - .as_ref() - .map(|selected| selected == &account.account_id) - .unwrap_or(false); - NostrIdentityRecord { - id: account.account_id.to_string(), - public_key_hex: account.public_identity.public_key_hex, - public_key_npub: account.public_identity.public_key_npub, - label: account.label, - is_selected, - } - }) - .collect()) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_list_ids(&self) -> Result<Vec<String>, RadrootsAppError> { - Ok(self - .nostr_identity_list()? - .into_iter() - .map(|identity| identity.id) - .collect()) - } - - pub fn nostr_identity_snapshot(&self) -> Result<NostrIdentitySnapshot, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let selected_identity_id = guard - .accounts - .default_account_id() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - let selected_npub = guard - .accounts - .default_public_identity() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))? - .map(|identity| identity.public_key_npub); - let has_selected_signing_identity = guard - .accounts - .default_signing_identity() - .ok() - .flatten() - .is_some(); - let identities = guard - .accounts - .list_accounts() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))? - .into_iter() - .map(|account| { - let is_selected = selected_identity_id - .as_ref() - .map(|selected| selected == &account.account_id) - .unwrap_or(false); - NostrIdentityRecord { - id: account.account_id.to_string(), - public_key_hex: account.public_identity.public_key_hex, - public_key_npub: account.public_identity.public_key_npub, - label: account.label, - is_selected, - } - }) - .collect(); - Ok(NostrIdentitySnapshot { - has_selected_signing_identity, - selected_identity_id: selected_identity_id.map(|id| id.to_string()), - selected_npub, - identities, - }) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_validate_host_custody_secret( - &self, - secret_key: String, - ) -> Result<NostrHostCustodyIdentity, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - host_custody_identity_from_secret(secret_key.as_str()).map(|(_, identity)| identity) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = secret_key; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_restore_host_custody_secret( - &self, - secret_key: String, - label: Option<String>, - make_selected: bool, - ) -> Result<NostrIdentityRecord, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let (identity, _) = host_custody_identity_from_secret(secret_key.as_str())?; - restore_host_custody_identity(&mut guard, &identity, label, make_selected) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = (secret_key, label, make_selected); - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_select(&self, identity_id: String) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let account_id = RadrootsIdentityId::parse(identity_id.as_str()) - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - guard - .accounts - .set_default_account(&account_id) - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - invalidate_nostr_runtime(&mut guard); - Ok(()) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = identity_id; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_remove(&self, identity_id: String) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let account_id = RadrootsIdentityId::parse(identity_id.as_str()) - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - guard - .accounts - .remove_account(&account_id) - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - invalidate_nostr_runtime(&mut guard); - Ok(()) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = identity_id; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_lock_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let accounts = guard - .accounts - .list_accounts() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - for account in accounts { - guard - .accounts - .remove_account(&account.account_id) - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - } - guard - .accounts - .clear_default_account() - .map_err(|e| RadrootsAppError::identity(format!("{e}")))?; - invalidate_nostr_runtime(&mut guard); - if let Ok(mut rx_guard) = self.post_events_rx.lock() { - *rx_guard = None; - } - Ok(()) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_identity_reset_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { - self.nostr_identity_lock_host_custody_runtime() - } -} diff --git a/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_core/src/runtime/mod.rs @@ -1,22 +1,15 @@ pub mod app_info; pub mod builder; pub mod info; -pub mod key_management; -pub mod nostr; pub mod product_surface; -#[cfg(feature = "nostr-client")] -pub mod trade_listing; +pub mod sdk; use chrono::Utc; -use radroots_net_core::{NetHandle, builder::NetBuilder}; -#[cfg(feature = "nostr-client")] -use std::sync::Mutex; +use radroots_sdk::{Client, ClientBuilder}; use std::sync::{ - RwLock, + Mutex, RwLock, atomic::{AtomicBool, Ordering}, }; -#[cfg(feature = "nostr-client")] -use tokio::sync::broadcast::Receiver; use self::{ app_info::AppInfoPlatform, @@ -26,72 +19,60 @@ use crate::RadrootsAppError; #[derive(uniffi::Object)] pub struct RadrootsRuntime { - pub(crate) net: NetHandle, + pub(crate) client: Client, + #[cfg(feature = "rt")] + executor: Mutex<Option<tokio::runtime::Runtime>>, pub(crate) started_unix_ms: i64, pub(crate) shutting_down: AtomicBool, pub(crate) platform_app: RwLock<Option<AppInfoPlatform>>, - #[cfg(feature = "nostr-client")] - pub(crate) post_events_rx: Mutex< - Option< - Receiver< - radroots_event_codec::parsed::RadrootsParsedData< - radroots_event::post::RadrootsPost, - >, - >, - >, - >, } #[cfg_attr(not(coverage_nightly), uniffi::export)] impl RadrootsRuntime { #[cfg_attr(not(coverage_nightly), uniffi::constructor)] pub fn new() -> Result<Self, RadrootsAppError> { - let cfg = radroots_net_core::config::NetConfig::default(); + let client = ClientBuilder::memory_default() + .build() + .map_err(RadrootsAppError::from_sdk)?; #[cfg(feature = "rt")] - let handle = match NetBuilder::new().config(cfg).manage_runtime(true).build() { - Ok(handle) => handle, - Err(err) => { - return Err(RadrootsAppError::initialization(format!( - "net build failed: {err}" - ))); - } - }; - #[cfg(not(feature = "rt"))] - let handle = NetBuilder::new() - .config(cfg) - .manage_runtime(true) + let executor = tokio::runtime::Builder::new_multi_thread() + .thread_name("radroots-app-sdk") + .enable_all() .build() - .expect("net build must succeed when rt feature is disabled"); + .map_err(|error| RadrootsAppError::initialization(error.to_string()))?; Ok(Self { - net: handle, + client, + #[cfg(feature = "rt")] + executor: Mutex::new(Some(executor)), started_unix_ms: Utc::now().timestamp_millis(), shutting_down: AtomicBool::new(false), platform_app: RwLock::new(None), - #[cfg(feature = "nostr-client")] - post_events_rx: Mutex::new(None), }) } pub fn stop(&self) { if self.shutting_down.swap(true, Ordering::SeqCst) { let _ = crate::logging::log_info( - "Runtime stop already in progress or completed.".to_string(), + "Runtime stop already in progress or completed.".to_owned(), ); return; } #[cfg(feature = "rt")] - { - if let Ok(mut net) = self.net.lock() { - if let Some(_rt) = net.rt.take() { - let _ = crate::logging::log_info("Runtime stopped gracefully.".to_string()); - } else { - let _ = crate::logging::log_info("No runtime was active at stop.".to_string()); + match self.executor.lock() { + Ok(mut executor) => { + if let Some(executor) = executor.take() { + if let Err(error) = executor.block_on(self.client.close()) { + let _ = crate::logging::log_error(format!( + "SDK runtime close failed safely: {error}" + )); + } } - } else { - let _ = crate::logging::log_info( - "Failed to acquire runtime lock during stop.".to_string(), + } + Err(_) => { + let _ = crate::logging::log_error( + "SDK executor lock was unavailable during shutdown.".to_owned(), ); } } @@ -99,7 +80,7 @@ impl RadrootsRuntime { #[cfg(not(feature = "rt"))] { let _ = crate::logging::log_info( - "No managed runtime is available for this build.".to_string(), + "Host must complete asynchronous SDK close for this runtime build.".to_owned(), ); } } @@ -113,17 +94,8 @@ impl RadrootsRuntime { } pub fn info_json(&self) -> String { - #[cfg(feature = "rt")] - { - match serde_json::to_string_pretty(&self.info()) { - Ok(json) => json, - Err(err) => format!(r#"{{"error":"serialize RuntimeInfo: {err}"}}"#), - } - } - #[cfg(not(feature = "rt"))] - { - serde_json::to_string_pretty(&self.info()).unwrap_or_default() - } + serde_json::to_string_pretty(&self.info()) + .unwrap_or_else(|error| format!(r#"{{"error":"serialize RuntimeInfo: {error}"}}"#)) } pub fn set_app_info_platform( @@ -145,23 +117,9 @@ impl RadrootsRuntime { #[cfg(test)] mod tests { use super::RadrootsRuntime; + use radroots_sdk::capability::{Availability, CapabilityId}; use std::panic::{AssertUnwindSafe, catch_unwind}; - fn init_info_logging() { - let _ = tracing_subscriber::fmt() - .with_test_writer() - .with_max_level(tracing::Level::INFO) - .try_init(); - } - - fn poison_net_lock(runtime: &RadrootsRuntime) { - let handle = runtime.net.clone(); - let _ = catch_unwind(AssertUnwindSafe(|| { - let _guard = handle.lock().expect("lock net"); - panic!("poison net lock"); - })); - } - fn poison_platform_lock(runtime: &RadrootsRuntime) { let _ = catch_unwind(AssertUnwindSafe(|| { let _guard = runtime.platform_app.write().expect("lock platform"); @@ -170,47 +128,42 @@ mod tests { } #[test] - fn runtime_info_uses_default_net_info_when_lock_is_poisoned() { - init_info_logging(); + fn runtime_owns_one_sdk_client_and_closes_idempotently() { let runtime = RadrootsRuntime::new().expect("runtime"); + let storage = runtime + .client + .capabilities() + .get(CapabilityId::CANONICAL_STORAGE) + .expect("storage capability"); + assert_eq!(storage.availability(), Availability::Available); + assert!(!runtime.client.is_closed()); - let healthy = runtime.info(); - assert!(!healthy.net.crate_name.is_empty()); - poison_net_lock(&runtime); - - let _ = runtime.uptime_millis(); - let info = runtime.info(); - assert_eq!(info.net.crate_name, String::new()); - assert_eq!(info.net.crate_version, String::new()); - let json = runtime.info_json(); - assert!(json.contains("\"net\"")); runtime.stop(); + assert!(runtime.client.is_closed()); runtime.stop(); } #[test] fn set_platform_info_handles_poisoned_lock() { - init_info_logging(); let runtime = RadrootsRuntime::new().expect("runtime"); runtime.set_app_info_platform( - Some("ios".to_string()), - Some("org.radroots.app".to_string()), - Some("1.0.0".to_string()), - Some("100".to_string()), - Some("abc123".to_string()), + Some("ios".to_owned()), + Some("org.radroots.app".to_owned()), + Some("1.0.0".to_owned()), + Some("100".to_owned()), + Some("abc123".to_owned()), ); - let info = runtime.info(); assert_eq!( - info.app.platform.as_ref().and_then(|v| v.platform.clone()), - Some("ios".to_string()) + runtime + .info() + .app + .platform + .as_ref() + .and_then(|value| value.platform.clone()), + Some("ios".to_owned()) ); poison_platform_lock(&runtime); - runtime.set_app_info_platform( - Some("ios".to_string()), - Some("org.radroots.app".to_string()), - Some("1.0.0".to_string()), - Some("100".to_string()), - Some("abc123".to_string()), - ); + runtime.set_app_info_platform(None, None, None, None, None); + runtime.stop(); } } diff --git a/crates/mobile_core/src/runtime/nostr.rs b/crates/mobile_core/src/runtime/nostr.rs @@ -1,375 +0,0 @@ -use super::RadrootsRuntime; -use crate::RadrootsAppError; -#[cfg(feature = "nostr-client")] -use tokio::sync::broadcast::error::TryRecvError; - -#[cfg(feature = "nostr-client")] -fn nostr_manager( - runtime: &RadrootsRuntime, -) -> Result<radroots_net_core::nostr_client::NostrClientManager, RadrootsAppError> { - let guard = runtime - .net - .lock() - .map_err(|err| RadrootsAppError::runtime(format!("{err}")))?; - guard - .nostr - .clone() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized")) -} - -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum NostrLight { - Red, - Yellow, - Green, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrConnectionStatus { - pub light: NostrLight, - pub connected: u32, - pub connecting: u32, - pub last_error: Option<String>, -} - -#[derive(uniffi::Record, Debug, Clone, Default)] -pub struct NostrProfile { - pub name: Option<String>, - pub display_name: Option<String>, - pub nip05: Option<String>, - pub about: Option<String>, - pub website: Option<String>, - pub picture: Option<String>, - pub banner: Option<String>, - pub lud06: Option<String>, - pub lud16: Option<String>, - pub bot: Option<String>, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrProfileEventMetadata { - pub id: String, - pub author: String, - pub published_at: u64, - pub profile: NostrProfile, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrEvent { - pub id: String, - pub author: String, - pub created_at: u64, - pub kind: u32, - pub content: String, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrPost { - pub content: String, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct NostrPostEventMetadata { - pub id: String, - pub author: String, - pub published_at: u64, - pub post: NostrPost, -} - -#[cfg(feature = "nostr-client")] -fn map_post_event_metadata( - event: radroots_event_codec::parsed::RadrootsParsedData<radroots_event::post::RadrootsPost>, -) -> NostrPostEventMetadata { - NostrPostEventMetadata { - id: event.id, - author: event.author, - published_at: event.published_at, - post: NostrPost { - content: event.data.content, - }, - } -} - -#[cfg(feature = "nostr-client")] -fn map_profile_event_metadata( - event: radroots_event_codec::parsed::RadrootsParsedData< - radroots_event_codec::profile::RadrootsProfileData, - >, -) -> NostrProfileEventMetadata { - NostrProfileEventMetadata { - id: event.id, - author: event.author, - published_at: event.published_at, - profile: NostrProfile { - name: event.data.profile.name.into(), - display_name: event.data.profile.display_name, - nip05: event.data.profile.nip05, - about: event.data.profile.about, - website: event.data.profile.website, - picture: event.data.profile.picture, - banner: event.data.profile.banner, - lud06: event.data.profile.lud06, - lud16: event.data.profile.lud16, - bot: event.data.profile.bot, - }, - } -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl RadrootsRuntime { - pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - guard - .nostr_set_default_relays(&relays) - .map_err(|e| RadrootsAppError::relay(format!("{e}"))) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = relays; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_connect_if_key_present(&self) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut guard = match self.net.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - guard - .nostr_connect_if_key_present() - .map_err(|e| RadrootsAppError::relay(format!("{e}"))) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_connection_status(&self) -> NostrConnectionStatus { - #[cfg(feature = "nostr-client")] - { - let guard = self.net.lock(); - if let Ok(g) = guard - && let Some(s) = g.nostr_connection_snapshot() - { - let light = match s.light { - radroots_net_core::nostr_client::Light::Green => NostrLight::Green, - radroots_net_core::nostr_client::Light::Yellow => NostrLight::Yellow, - radroots_net_core::nostr_client::Light::Red => NostrLight::Red, - }; - return NostrConnectionStatus { - light, - connected: s.connected as u32, - connecting: s.connecting as u32, - last_error: s.last_error, - }; - } - NostrConnectionStatus { - light: NostrLight::Red, - connected: 0, - connecting: 0, - last_error: None, - } - } - - #[cfg(not(feature = "nostr-client"))] - { - NostrConnectionStatus { - light: NostrLight::Red, - connected: 0, - connecting: 0, - last_error: None, - } - } - } - - pub fn nostr_profile_for_self( - &self, - ) -> Result<Option<NostrProfileEventMetadata>, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let (pk, mgr) = { - let guard = self - .net - .lock() - .map_err(|err| RadrootsAppError::runtime(format!("{err}")))?; - let keys = guard.selected_nostr_keys().ok_or_else(|| { - RadrootsAppError::identity("selected signing identity is not configured") - })?; - let pk = keys.public_key(); - let mgr = guard - .nostr - .clone() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; - (pk, mgr) - }; - let out = mgr - .fetch_profile_event_blocking(pk) - .map_err(|error| RadrootsAppError::relay(error.to_string()))?; - Ok(out.map(map_profile_event_metadata)) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_post_profile( - &self, - name: Option<String>, - display_name: Option<String>, - nip05: Option<String>, - about: Option<String>, - ) -> Result<String, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = nostr_manager(self)?; - mgr.publish_profile_event_blocking(name, display_name, nip05, about) - .map_err(|e| RadrootsAppError::relay(e.to_string())) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = (name, display_name, nip05, about); - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = nostr_manager(self)?; - mgr.publish_post_event_blocking(content) - .map_err(|e| RadrootsAppError::relay(e.to_string())) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = content; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_fetch_text_notes( - &self, - limit: u16, - since_unix: Option<u64>, - ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = nostr_manager(self)?; - let items = mgr - .fetch_post_events_blocking(limit, since_unix) - .map_err(|e| RadrootsAppError::relay(e.to_string()))?; - Ok(items.into_iter().map(map_post_event_metadata).collect()) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = (limit, since_unix); - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_post_reply( - &self, - parent_event_id_hex: String, - parent_author_hex: String, - content: String, - root_event_id_hex: Option<String>, - ) -> Result<String, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = nostr_manager(self)?; - mgr.publish_post_reply_event_blocking( - parent_event_id_hex, - parent_author_hex, - content, - root_event_id_hex, - ) - .map_err(|e| RadrootsAppError::relay(e.to_string())) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = ( - parent_event_id_hex, - parent_author_hex, - content, - root_event_id_hex, - ); - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_start_post_event_stream( - &self, - since_unix: Option<u64>, - ) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = nostr_manager(self)?; - mgr.start_post_event_stream(since_unix); - if let Ok(mut rx_guard) = self.post_events_rx.lock() - && rx_guard.is_none() - { - *rx_guard = Some(mgr.subscribe_post_events()); - } - Ok(()) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = since_unix; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_next_post_event( - &self, - ) -> Result<Option<NostrPostEventMetadata>, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mut rx_guard = match self.post_events_rx.lock() { - Ok(guard) => guard, - Err(err) => return Err(RadrootsAppError::runtime(format!("{err}"))), - }; - let Some(rx) = rx_guard.as_mut() else { - return Ok(None); - }; - match rx.try_recv() { - Ok(event) => Ok(Some(map_post_event_metadata(event))), - Err(TryRecvError::Empty) => Ok(None), - Err(TryRecvError::Lagged(count)) => Err(RadrootsAppError::relay(format!( - "post event stream lagged by {count} events" - ))), - Err(TryRecvError::Closed) => { - *rx_guard = None; - Err(RadrootsAppError::relay("post event stream closed")) - } - } - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn nostr_stop_post_event_stream(&self) -> Result<(), RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = nostr_manager(self)?; - mgr.stop_post_event_stream(); - if let Ok(mut rx_guard) = self.post_events_rx.lock() { - *rx_guard = None; - } - Ok(()) - } - #[cfg(not(feature = "nostr-client"))] - { - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } -} diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs @@ -0,0 +1,105 @@ +use radroots_sdk::capability::{Availability, Maturity}; + +use super::RadrootsRuntime; +use crate::RadrootsAppError; + +#[derive(Clone, Debug, uniffi::Record)] +pub struct SdkCapabilityRecord { + pub id: String, + pub compiled: bool, + pub configured: bool, + pub availability: String, + pub maturity: String, +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct SdkStorageStatusRecord { + pub backend: String, + pub open_mode: String, + pub shutdown: String, + pub integrity: String, +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +impl RadrootsRuntime { + pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { + self.client + .capabilities() + .iter() + .map(|status| SdkCapabilityRecord { + id: status.id().as_str().to_owned(), + compiled: status.is_compiled(), + configured: status.is_configured(), + availability: availability_label(status.availability()).to_owned(), + maturity: maturity_label(status.maturity()).to_owned(), + }) + .collect() + } + + pub fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { + #[cfg(feature = "rt")] + { + let executor = self + .executor + .lock() + .map_err(|_| RadrootsAppError::runtime("SDK executor lock is unavailable"))?; + let executor = executor + .as_ref() + .ok_or_else(|| RadrootsAppError::runtime("SDK runtime is closed"))?; + let status = executor + .block_on(self.client.storage_status()) + .map_err(RadrootsAppError::from_sdk)?; + Ok(SdkStorageStatusRecord { + backend: format!("{:?}", status.backend()).to_ascii_lowercase(), + open_mode: format!("{:?}", status.open_mode()).to_ascii_lowercase(), + shutdown: format!("{:?}", status.shutdown()).to_ascii_lowercase(), + integrity: format!("{:?}", status.integrity().health()).to_ascii_lowercase(), + }) + } + #[cfg(not(feature = "rt"))] + { + Err(RadrootsAppError::unsupported( + "SDK storage status requires a host async executor", + )) + } + } +} + +const fn availability_label(value: Availability) -> &'static str { + match value { + Availability::Available => "available", + Availability::Degraded => "degraded", + Availability::Unavailable => "unavailable", + Availability::Unsupported => "unsupported", + } +} + +const fn maturity_label(value: Maturity) -> &'static str { + match value { + Maturity::Stable => "stable", + Maturity::Preview => "preview", + Maturity::Experimental => "experimental", + } +} + +#[cfg(test)] +mod tests { + use super::RadrootsRuntime; + + #[test] + fn sdk_records_are_stable_and_storage_is_memory_backed() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let capabilities = runtime.sdk_capabilities(); + assert!(capabilities.iter().any(|capability| { + capability.id == "storage.canonical" + && capability.configured + && capability.availability == "available" + && capability.maturity == "stable" + })); + let status = runtime.sdk_storage_status().expect("storage status"); + assert_eq!(status.backend, "memory"); + assert_eq!(status.integrity, "healthy"); + runtime.stop(); + assert!(runtime.sdk_storage_status().is_err()); + } +} diff --git a/crates/mobile_core/src/runtime/trade_listing.rs b/crates/mobile_core/src/runtime/trade_listing.rs @@ -1,382 +0,0 @@ -#![forbid(unsafe_code)] - -use core::str::FromStr; - -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, -}; -use radroots_event::{ - RadrootsEventEnvelope, - farm::RadrootsFarmRef, - ids::{RadrootsDTag, RadrootsInventoryBinId}, - kinds::KIND_LISTING, - listing::{ - RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, - RadrootsListingDeliveryMethod, RadrootsListingProduct, RadrootsListingPublicLocation, - RadrootsListingStatus, - }, -}; -use radroots_event_codec::listing::encode::to_wire_parts as listing_to_wire_parts; -use radroots_nostr::prelude::{ - RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrTimestamp, radroots_event_from_nostr, -}; -use radroots_trade::listing::validation::validate_listing_event; - -use super::RadrootsRuntime; -use crate::RadrootsAppError; - -#[derive(uniffi::Record, Debug, Clone)] -pub struct TradeListingDraft { - pub listing_id: Option<String>, - pub farm_pubkey: String, - pub farm_d_tag: String, - pub title: String, - pub description: String, - pub category: String, - pub bin_display_amount: String, - pub bin_display_unit: String, - pub unit_price: String, - pub currency: String, - pub bin_label: Option<String>, - pub bin_id: Option<String>, - pub inventory: String, - pub delivery_method: String, - pub location_primary: String, - pub location_city: Option<String>, - pub location_region: Option<String>, - pub location_country: Option<String>, - pub location_geohash: String, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct TradeListingSummary { - pub event_id: String, - pub seller_pubkey: String, - pub published_at: u64, - pub listing_id: String, - pub listing_addr: String, - pub title: String, - pub description: String, - pub product_type: String, - pub primary_bin_id: String, - pub unit_price_amount: String, - pub unit_price_currency: String, - pub unit_price_unit: String, - pub bin_display_amount: String, - pub bin_display_unit: String, - pub bin_display_label: Option<String>, - pub inventory_available: String, - pub availability: String, - pub location: String, - pub delivery_method: String, -} - -#[derive(uniffi::Record, Debug, Clone)] -pub struct TradeListingEventParts { - pub kind: u32, - pub content: String, - pub tags_json: String, -} - -#[cfg_attr(not(coverage_nightly), uniffi::export)] -impl RadrootsRuntime { - pub fn trade_listing_build_event_parts( - &self, - draft: TradeListingDraft, - ) -> Result<TradeListingEventParts, RadrootsAppError> { - let listing = listing_from_draft(&draft)?; - let parts = listing_to_wire_parts(&listing).map_err(|error| { - RadrootsAppError::runtime(format!("listing encode failed: {error}")) - })?; - let tags_json = serde_json::to_string(&parts.tags).map_err(|error| { - RadrootsAppError::runtime(format!("listing tags encode failed: {error}")) - })?; - Ok(TradeListingEventParts { - kind: parts.kind, - content: parts.content, - tags_json, - }) - } - - pub fn trade_listing_publish( - &self, - draft: TradeListingDraft, - ) -> Result<String, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let (current_pubkey, mgr) = { - let guard = self - .net - .lock() - .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; - let current_pubkey = guard - .accounts - .default_public_identity() - .map_err(|error| RadrootsAppError::identity(format!("{error}")))? - .ok_or_else(|| RadrootsAppError::identity("default account is not configured"))? - .public_key_hex; - let mgr = guard - .nostr - .clone() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))?; - (current_pubkey, mgr) - }; - let listing = listing_from_draft(&draft)?; - if listing.farm.pubkey != current_pubkey { - return Err(RadrootsAppError::runtime( - "farm_pubkey must match the default account public key", - )); - } - let parts = listing_to_wire_parts(&listing).map_err(|error| { - RadrootsAppError::runtime(format!("listing encode failed: {error}")) - })?; - mgr.send_custom_event_blocking(parts.kind, parts.content, parts.tags) - .map_err(|error| RadrootsAppError::relay(error.to_string())) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = draft; - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } - - pub fn trade_listings_fetch( - &self, - limit: u16, - since_unix: Option<u64>, - ) -> Result<Vec<TradeListingSummary>, RadrootsAppError> { - #[cfg(feature = "nostr-client")] - { - let mgr = { - let guard = self - .net - .lock() - .map_err(|error| RadrootsAppError::runtime(format!("{error}")))?; - guard - .nostr - .clone() - .ok_or_else(|| RadrootsAppError::relay("nostr not initialized"))? - }; - let mut filter = - RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom(KIND_LISTING as u16)); - filter = filter.limit(limit.into()); - if let Some(since) = since_unix { - filter = filter.since(RadrootsNostrTimestamp::from(since)); - } - - let events = mgr - .fetch_events_blocking(filter, core::time::Duration::from_secs(10)) - .map_err(|error| RadrootsAppError::relay(error.to_string()))?; - let mut out = Vec::new(); - for event in events { - let event = radroots_event_from_nostr(&event); - if let Ok(listing) = validate_listing_event(&event) { - out.push(listing_summary_from_trade(listing, &event)); - } - } - out.sort_by_key(|listing| std::cmp::Reverse(listing.published_at)); - Ok(out) - } - #[cfg(not(feature = "nostr-client"))] - { - let _ = (limit, since_unix); - Err(RadrootsAppError::unsupported("nostr disabled")) - } - } -} - -fn listing_from_draft(draft: &TradeListingDraft) -> Result<RadrootsListing, RadrootsAppError> { - let listing_id = non_empty( - draft - .listing_id - .clone() - .unwrap_or_else(|| format!("listing-{}", chrono::Utc::now().timestamp_millis())), - "listing_id", - )?; - let farm_pubkey = non_empty(draft.farm_pubkey.clone(), "farm_pubkey")?; - let farm_d_tag = non_empty(draft.farm_d_tag.clone(), "farm_d_tag")?; - let title = non_empty(draft.title.clone(), "title")?; - let description = non_empty(draft.description.clone(), "description")?; - let category = non_empty(draft.category.clone(), "category")?; - let bin_id = non_empty( - draft.bin_id.clone().unwrap_or_else(|| "bin-1".to_string()), - "bin_id", - )?; - let listing_id = RadrootsDTag::parse(listing_id) - .map_err(|error| RadrootsAppError::runtime(format!("invalid listing_id: {error}")))?; - let bin_id = RadrootsInventoryBinId::parse(bin_id) - .map_err(|error| RadrootsAppError::runtime(format!("invalid bin_id: {error}")))?; - let amount = parse_decimal(&draft.bin_display_amount, "bin_display_amount")?; - let unit = parse_unit(&draft.bin_display_unit)?; - let canonical_unit = unit.canonical_unit(); - let currency = parse_currency(&draft.currency)?; - let unit_price = parse_decimal(&draft.unit_price, "unit_price")?; - let inventory = parse_decimal(&draft.inventory, "inventory")?; - let location_primary = non_empty(draft.location_primary.clone(), "location_primary")?; - let location_geohash = non_empty(draft.location_geohash.clone(), "location_geohash")?; - - Ok(RadrootsListing { - d_tag: listing_id, - published_at: None, - farm: RadrootsFarmRef { - pubkey: farm_pubkey, - d_tag: farm_d_tag, - }, - product: RadrootsListingProduct { - key: category.clone(), - title, - category, - summary: Some(description), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: bin_id.clone(), - bins: vec![RadrootsListingBin { - bin_id, - quantity: RadrootsCoreQuantity::new(amount, canonical_unit), - price_per_canonical_unit: RadrootsCoreQuantityPrice::new( - RadrootsCoreMoney::new(unit_price, currency), - RadrootsCoreQuantity::new(RadrootsCoreDecimal::ONE, canonical_unit), - ), - display_amount: Some(amount), - display_unit: Some(unit), - display_label: draft.bin_label.clone(), - display_price: Some(RadrootsCoreMoney::new(unit_price, currency)), - display_price_unit: Some(unit), - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(inventory), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, - }), - delivery_method: Some(parse_delivery_method(&draft.delivery_method)), - location: Some(RadrootsListingPublicLocation { - primary: location_primary, - city: blank_to_none(draft.location_city.clone()), - region: blank_to_none(draft.location_region.clone()), - country: blank_to_none(draft.location_country.clone()), - geohash: location_geohash, - }), - images: None, - }) -} - -fn listing_summary_from_trade( - listing: radroots_trade::listing::validation::RadrootsTradeListing, - event: &RadrootsEventEnvelope, -) -> TradeListingSummary { - let primary_bin = listing - .listing - .bins - .iter() - .find(|bin| bin.bin_id == listing.primary_bin_id); - TradeListingSummary { - event_id: event.id_str().to_owned(), - seller_pubkey: listing.seller_pubkey, - published_at: event.created_at_u64(), - listing_id: listing.listing_id, - listing_addr: listing.listing_addr, - title: listing.title, - description: listing.description, - product_type: listing.product_type, - primary_bin_id: listing.primary_bin_id, - unit_price_amount: listing.unit_price.amount.to_string(), - unit_price_currency: listing.unit_price.currency.to_string(), - unit_price_unit: listing.unit.to_string(), - bin_display_amount: primary_bin - .and_then(|bin| bin.display_amount) - .unwrap_or(listing.bin_quantity.amount) - .to_string(), - bin_display_unit: primary_bin - .and_then(|bin| bin.display_unit) - .unwrap_or(listing.unit) - .to_string(), - bin_display_label: primary_bin.and_then(|bin| bin.display_label.clone()), - inventory_available: listing.inventory_available.to_string(), - availability: availability_label(&listing.availability), - location: location_label(&listing.location), - delivery_method: delivery_method_label(&listing.delivery_method), - } -} - -fn non_empty(value: String, field: &str) -> Result<String, RadrootsAppError> { - let value = value.trim().to_string(); - if value.is_empty() { - return Err(RadrootsAppError::runtime(format!("{field} is required"))); - } - Ok(value) -} - -fn blank_to_none(value: Option<String>) -> Option<String> { - value - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn parse_decimal(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RadrootsAppError> { - RadrootsCoreDecimal::from_str(value.trim()) - .map_err(|error| RadrootsAppError::runtime(format!("{field} is invalid: {error}"))) -} - -fn parse_currency(value: &str) -> Result<RadrootsCoreCurrency, RadrootsAppError> { - RadrootsCoreCurrency::from_str(value.trim()) - .map_err(|error| RadrootsAppError::runtime(format!("currency is invalid: {error}"))) -} - -fn parse_unit(value: &str) -> Result<RadrootsCoreUnit, RadrootsAppError> { - RadrootsCoreUnit::from_str(value.trim()) - .map_err(|error| RadrootsAppError::runtime(format!("unit is invalid: {error}"))) -} - -fn parse_delivery_method(value: &str) -> RadrootsListingDeliveryMethod { - match value.trim().to_ascii_lowercase().as_str() { - "pickup" => RadrootsListingDeliveryMethod::Pickup, - "local_delivery" | "local delivery" => RadrootsListingDeliveryMethod::LocalDelivery, - "shipping" => RadrootsListingDeliveryMethod::Shipping, - other => RadrootsListingDeliveryMethod::Other { - method: other.to_string(), - }, - } -} - -fn availability_label(value: &RadrootsListingAvailability) -> String { - match value { - RadrootsListingAvailability::Window { start, end } => { - format!("window:{start:?}:{end:?}") - } - RadrootsListingAvailability::Status { status } => match status { - RadrootsListingStatus::Active => "active".to_string(), - RadrootsListingStatus::Sold => "sold".to_string(), - RadrootsListingStatus::Other { value } => value.clone(), - }, - } -} - -fn delivery_method_label(value: &RadrootsListingDeliveryMethod) -> String { - match value { - RadrootsListingDeliveryMethod::Pickup => "pickup".to_string(), - RadrootsListingDeliveryMethod::LocalDelivery => "local_delivery".to_string(), - RadrootsListingDeliveryMethod::Shipping => "shipping".to_string(), - RadrootsListingDeliveryMethod::Other { method } => method.clone(), - } -} - -fn location_label(value: &RadrootsListingPublicLocation) -> String { - [ - Some(value.primary.as_str()), - value.city.as_deref(), - value.region.as_deref(), - value.country.as_deref(), - ] - .into_iter() - .flatten() - .filter(|part| !part.trim().is_empty()) - .collect::<Vec<_>>() - .join(", ") -} diff --git a/crates/mobile_core/tests/error_mapping.rs b/crates/mobile_core/tests/error_mapping.rs @@ -1,127 +0,0 @@ -#![cfg(feature = "nostr-client")] - -use std::{sync::mpsc, time::Duration}; - -use radroots_app_core::runtime::trade_listing::TradeListingDraft; -use radroots_app_core::{RadrootsAppError, RadrootsRuntime}; - -#[test] -fn invalid_host_custody_secret_maps_to_identity_error() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - let err = runtime - .nostr_identity_validate_host_custody_secret("not-a-secret".to_string()) - .expect_err("invalid secret should fail"); - - assert!(matches!(err, RadrootsAppError::Identity(_))); -} - -#[test] -fn uninitialized_nostr_publish_maps_to_relay_error() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - let err = runtime - .nostr_post_text_note("hello".to_string()) - .expect_err("uninitialized nostr should fail"); - - assert!(matches!( - err, - RadrootsAppError::Relay(message) if message == "nostr not initialized" - )); -} - -#[test] -fn profile_read_without_identity_maps_to_identity_error() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - let err = runtime - .nostr_profile_for_self() - .expect_err("missing identity should fail"); - - assert!(matches!(err, RadrootsAppError::Identity(_))); -} - -#[test] -fn profile_read_without_initialized_nostr_maps_to_relay_error() { - let runtime = RadrootsRuntime::new().expect("runtime"); - let identity = radroots_identity::RadrootsIdentity::generate(); - runtime - .nostr_identity_restore_host_custody_secret( - identity.secret_key_hex(), - Some("field".to_string()), - true, - ) - .expect("restore identity"); - - let err = runtime - .nostr_profile_for_self() - .expect_err("uninitialized nostr should fail"); - - assert!(matches!( - err, - RadrootsAppError::Relay(message) if message == "nostr not initialized" - )); -} - -#[test] -fn post_stream_read_without_started_stream_returns_no_data() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - let event = runtime - .nostr_next_post_event() - .expect("missing stream should be a no-data state"); - - assert!(event.is_none()); -} - -#[test] -fn trade_listing_publish_with_initialized_nostr_does_not_relock_runtime() { - let runtime = RadrootsRuntime::new().expect("runtime"); - let identity = radroots_identity::RadrootsIdentity::generate(); - let public_key_hex = identity.public_key_hex(); - runtime - .nostr_identity_restore_host_custody_secret( - identity.secret_key_hex(), - Some("field".to_string()), - true, - ) - .expect("restore identity"); - runtime - .nostr_set_default_relays(Vec::new()) - .expect("initialize nostr manager"); - let draft = TradeListingDraft { - listing_id: Some("AAAAAAAAAAAAAAAAAAAAAg".to_string()), - farm_pubkey: public_key_hex, - farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAQ".to_string(), - title: "Carrots".to_string(), - description: "Fresh carrots".to_string(), - category: "produce".to_string(), - bin_display_amount: "1".to_string(), - bin_display_unit: "lb".to_string(), - unit_price: "3.50".to_string(), - currency: "USD".to_string(), - bin_label: None, - bin_id: Some("bin-1".to_string()), - inventory: "10".to_string(), - delivery_method: "pickup".to_string(), - location_primary: "farm stand".to_string(), - location_city: Some("Asheville".to_string()), - location_region: None, - location_country: None, - location_geohash: "9q8yy".to_string(), - }; - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let result = runtime.trade_listing_publish(draft); - let _ = tx.send(result); - }); - - let result = rx - .recv_timeout(Duration::from_secs(3)) - .expect("publish must return instead of re-locking the runtime"); - - match result { - Ok(_) | Err(RadrootsAppError::Relay(_)) => {} - other => panic!("unexpected publish result: {other:?}"), - } -} diff --git a/crates/mobile_core/tests/key_management.rs b/crates/mobile_core/tests/key_management.rs @@ -1,137 +0,0 @@ -#![cfg(feature = "nostr-client")] - -use radroots_app_core::RadrootsRuntime; - -#[test] -fn identity_reset_all_removes_selected_and_unselected_identities() { - let runtime = RadrootsRuntime::new().expect("runtime"); - let selected_identity = radroots_identity::RadrootsIdentity::generate(); - let other_identity = radroots_identity::RadrootsIdentity::generate(); - - let selected = runtime - .nostr_identity_restore_host_custody_secret( - selected_identity.secret_key_hex(), - Some("selected".to_string()), - true, - ) - .expect("selected identity"); - let other = runtime - .nostr_identity_restore_host_custody_secret( - other_identity.secret_key_hex(), - Some("other".to_string()), - false, - ) - .expect("other identity"); - - let snapshot = runtime.nostr_identity_snapshot().expect("snapshot"); - assert!(snapshot.has_selected_signing_identity); - assert_eq!( - snapshot.selected_identity_id.as_deref(), - Some(selected.id.as_str()) - ); - assert!(selected.is_selected); - assert!(!other.is_selected); - assert_eq!(snapshot.identities.len(), 2); - - runtime - .nostr_identity_reset_host_custody_runtime() - .expect("reset all"); - - let snapshot = runtime.nostr_identity_snapshot().expect("reset snapshot"); - assert!(!snapshot.has_selected_signing_identity); - assert_eq!(snapshot.selected_identity_id, None); - assert_eq!(snapshot.selected_npub, None); - assert!(snapshot.identities.is_empty()); - assert!(runtime.nostr_identity_list().expect("list").is_empty()); - - assert!(runtime.nostr_identity_remove(other.id).is_err()); -} - -#[test] -fn host_custody_secret_validation_derives_public_identity_without_runtime_mutation() { - let runtime = RadrootsRuntime::new().expect("runtime"); - let host_identity = radroots_identity::RadrootsIdentity::generate(); - - let validated = runtime - .nostr_identity_validate_host_custody_secret(host_identity.secret_key_hex()) - .expect("validate host custody secret"); - - assert_eq!(validated.id, host_identity.id().to_string()); - assert_eq!(validated.public_key_hex, host_identity.public_key_hex()); - assert_eq!(validated.public_key_npub, host_identity.public_key_npub()); - assert!(!runtime.nostr_identity_has_selected_signing_identity()); - assert!( - runtime - .nostr_identity_snapshot() - .expect("snapshot") - .identities - .is_empty() - ); -} - -#[test] -fn host_custody_secret_restore_recreates_runtime_signing_identity_after_lock() { - let runtime = RadrootsRuntime::new().expect("runtime"); - let host_identity = radroots_identity::RadrootsIdentity::generate(); - let secret_key = host_identity.secret_key_hex(); - - let restored = runtime - .nostr_identity_restore_host_custody_secret( - secret_key.clone(), - Some("local custody".to_string()), - true, - ) - .expect("restore host secret"); - assert_eq!(restored.public_key_hex, host_identity.public_key_hex()); - assert!(restored.is_selected); - assert!(runtime.nostr_identity_has_selected_signing_identity()); - - runtime - .nostr_identity_lock_host_custody_runtime() - .expect("clear runtime state"); - let locked = runtime.nostr_identity_snapshot().expect("locked snapshot"); - assert!(!locked.has_selected_signing_identity); - assert!(locked.identities.is_empty()); - assert!(!runtime.nostr_identity_has_selected_signing_identity()); - - let restored_again = runtime - .nostr_identity_restore_host_custody_secret( - secret_key, - Some("local custody".to_string()), - true, - ) - .expect("restore host secret again"); - assert_eq!( - restored_again.public_key_hex, - host_identity.public_key_hex() - ); - assert!(runtime.nostr_identity_has_selected_signing_identity()); -} - -#[test] -fn invalid_host_custody_secret_is_rejected_before_runtime_mutation() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - assert!( - runtime - .nostr_identity_validate_host_custody_secret("not-a-secret".to_string()) - .is_err() - ); - assert!( - runtime - .nostr_identity_restore_host_custody_secret( - "not-a-secret".to_string(), - Some("bad".to_string()), - true, - ) - .is_err() - ); - assert!(!runtime.nostr_identity_has_selected_signing_identity()); - assert!( - runtime - .nostr_identity_snapshot() - .expect("snapshot") - .identities - .is_empty() - ); -} diff --git a/crates/mobile_core/tests/no_nostr_runtime.rs b/crates/mobile_core/tests/no_nostr_runtime.rs @@ -1,150 +0,0 @@ -#![cfg(not(feature = "nostr-client"))] - -use radroots_app_core::logging; -use radroots_app_core::runtime::builder::RuntimeBuilder; -use radroots_app_core::runtime::nostr::{ - NostrConnectionStatus, NostrEvent, NostrLight, NostrPost, NostrPostEventMetadata, NostrProfile, - NostrProfileEventMetadata, -}; -use radroots_app_core::{RadrootsAppError, RadrootsRuntime}; -use radroots_net_core::config::NetConfig; - -fn expect_disabled<T>(result: Result<T, RadrootsAppError>) { - match result { - Err(RadrootsAppError::Unsupported(message)) => assert_eq!(message, "nostr disabled"), - _ => panic!("expected nostr disabled error"), - } -} - -#[test] -fn runtime_info_and_platform_paths_are_exercised() { - let runtime = RadrootsRuntime::new().expect("runtime"); - assert!(runtime.uptime_millis() >= 0); - - runtime.stop(); - runtime.stop(); -} - -#[test] -fn key_management_disabled_paths_are_exercised() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - assert!(!runtime.nostr_identity_has_selected_signing_identity()); - assert_eq!(runtime.nostr_identity_selected_npub(), None); - expect_disabled(runtime.nostr_identity_list()); - expect_disabled(runtime.nostr_identity_list_ids()); - expect_disabled(runtime.nostr_identity_snapshot()); - expect_disabled(runtime.nostr_identity_validate_host_custody_secret("deadbeef".to_string())); - expect_disabled(runtime.nostr_identity_restore_host_custody_secret( - "deadbeef".to_string(), - Some("alpha".to_string()), - true, - )); - expect_disabled(runtime.nostr_identity_select("account-1".to_string())); - expect_disabled(runtime.nostr_identity_remove("account-1".to_string())); - expect_disabled(runtime.nostr_identity_lock_host_custody_runtime()); - expect_disabled(runtime.nostr_identity_reset_host_custody_runtime()); -} - -#[test] -fn nostr_disabled_paths_are_exercised() { - let runtime = RadrootsRuntime::new().expect("runtime"); - - let status = runtime.nostr_connection_status(); - assert_eq!(status.connected, 0); - assert_eq!(status.connecting, 0); - assert!(status.last_error.is_none()); - - expect_disabled(runtime.nostr_profile_for_self()); - expect_disabled(runtime.nostr_next_post_event()); - expect_disabled(runtime.nostr_set_default_relays(vec!["wss://relay.example.com".to_string()])); - expect_disabled(runtime.nostr_connect_if_key_present()); - expect_disabled(runtime.nostr_post_profile(None, None, None, None)); - expect_disabled(runtime.nostr_post_text_note("hello".to_string())); - expect_disabled(runtime.nostr_fetch_text_notes(25, Some(0))); - expect_disabled(runtime.nostr_post_reply( - "event-id".to_string(), - "author".to_string(), - "reply".to_string(), - None, - )); - expect_disabled(runtime.nostr_start_post_event_stream(None)); - expect_disabled(runtime.nostr_stop_post_event_stream()); -} - -#[test] -fn runtime_builder_and_logging_paths_are_exercised() { - let handle = RuntimeBuilder::new() - .with_config(NetConfig::default()) - .manage_runtime(false) - .build() - .expect("build net handle"); - drop(handle); - let default_handle = RuntimeBuilder::new() - .build() - .expect("build default net handle"); - drop(default_handle); - - let err = logging::init_logging(Some("/dev/null/file.log".to_string()), None, Some(false)); - assert!(matches!(err, Err(RadrootsAppError::Initialization(_)))); - let _ = logging::init_logging(None, None, None); - let _ = logging::init_logging(None, Some("app.log".to_string()), Some(false)); - let _ = logging::init_logging_stdout(); - - assert!(logging::log_info("info".to_string()).is_ok()); - assert!(logging::log_error("error".to_string()).is_ok()); - assert!(logging::log_debug("debug".to_string()).is_ok()); -} - -#[test] -fn nostr_records_and_enums_are_exercised() { - let status = NostrConnectionStatus { - light: NostrLight::Yellow, - connected: 1, - connecting: 2, - last_error: Some("err".to_string()), - }; - let _status_debug = format!("{status:?}"); - let _status_clone = status.clone(); - - let profile = NostrProfile::default(); - let _profile_debug = format!("{profile:?}"); - let _profile_clone = profile.clone(); - - let profile_event = NostrProfileEventMetadata { - id: "id".to_string(), - author: "author".to_string(), - published_at: 1, - profile, - }; - let _profile_event_debug = format!("{profile_event:?}"); - let _profile_event_clone = profile_event.clone(); - - let event = NostrEvent { - id: "event-id".to_string(), - author: "event-author".to_string(), - created_at: 2, - kind: 1, - content: "content".to_string(), - }; - let _event_debug = format!("{event:?}"); - let _event_clone = event.clone(); - - let post = NostrPost { - content: "post".to_string(), - }; - let _post_debug = format!("{post:?}"); - let _post_clone = post.clone(); - - let post_event = NostrPostEventMetadata { - id: "id".to_string(), - author: "author".to_string(), - published_at: 3, - post, - }; - let _post_event_debug = format!("{post_event:?}"); - let _post_event_clone = post_event.clone(); - - assert!(matches!(NostrLight::Red, NostrLight::Red)); - assert!(matches!(NostrLight::Green, NostrLight::Green)); -} diff --git a/crates/mobile_core/tests/sdk_runtime.rs b/crates/mobile_core/tests/sdk_runtime.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use radroots_app_core::{RadrootsAppError, RadrootsRuntime}; + +#[test] +fn runtime_is_send_sync_and_shares_one_sdk_lifecycle() { + fn require_send_sync<T: Send + Sync>() {} + require_send_sync::<RadrootsRuntime>(); + + let runtime = Arc::new(RadrootsRuntime::new().expect("runtime")); + let worker = { + let runtime = Arc::clone(&runtime); + std::thread::spawn(move || runtime.sdk_capabilities()) + }; + let capabilities = worker.join().expect("worker"); + assert!( + capabilities + .iter() + .any(|capability| capability.id == "storage.canonical") + ); + runtime.stop(); + runtime.stop(); + assert!(runtime.info().sdk_closed); +} + +#[test] +fn operations_fail_safely_after_explicit_close() { + let runtime = RadrootsRuntime::new().expect("runtime"); + assert_eq!( + runtime.sdk_storage_status().expect("status").backend, + "memory" + ); + runtime.stop(); + assert!(matches!( + runtime.sdk_storage_status(), + Err(RadrootsAppError::Runtime(_)) + )); +}