field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 3e4d9a9fb2d85921105f7c59314f45458cdd487d
parent 09edef0d73fef537d91875be09ac74c6e79e0a14
Author: triesap <tyson@radroots.org>
Date:   Thu,  6 Aug 2026 08:10:06 +0000

mobile: isolate native ffi ownership

- remove UniFFI annotations and dependencies from the ordinary Rust mobile core
- own remote DTO converters, errors, exports, logging, and delegation in mobileffi
- require full canonical lib and consumer revisions in runtime provenance
- qualify isolated features, generated bindings, lifecycle ordering, and coverage

Diffstat:
Mcore/crates/tera_ffi/Cargo.toml | 2++
Acore/crates/tera_ffi/src/error.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/src/lib.rs | 10++++++++--
Mcore/crates/tera_ffi/src/logging.rs | 15+++++++--------
Acore/crates/tera_ffi/src/remote.rs | 664+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/runtime.rs | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/logging_error.rs | 2+-
Acore/crates/tera_ffi/tests/runtime_delegation.rs | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/runtime_lifecycle.rs | 40++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/tests/uniffi_contract.rs | 33+++++++++++++++++++++++++++++++++
10 files changed, 1279 insertions(+), 11 deletions(-)

diff --git a/core/crates/tera_ffi/Cargo.toml b/core/crates/tera_ffi/Cargo.toml @@ -23,6 +23,7 @@ radroots_mobile_core = { workspace = true, features = ["mobile-social"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +thiserror = { workspace = true } uniffi = { workspace = true } [target.'cfg(unix)'.dependencies] @@ -30,3 +31,4 @@ rustix = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "time"] } diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs @@ -0,0 +1,70 @@ +use thiserror::Error; + +pub use radroots_mobile_core::SdkErrorRecord; + +/// Versioned, secret-safe failure exposed across the native language boundary. +#[derive(Debug, Error, uniffi::Error)] +pub enum RadrootsAppError { + #[error("initialization: {0}")] + Initialization(String), + #[error("sdk: {report:?}")] + Sdk { report: SdkErrorRecord }, + #[error("runtime: {0}")] + Runtime(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("internal: {0}")] + Internal(String), +} + +impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError { + fn from(error: radroots_mobile_core::RadrootsAppError) -> Self { + match error { + radroots_mobile_core::RadrootsAppError::Initialization(message) => { + Self::Initialization(message) + } + radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Sdk { report }, + radroots_mobile_core::RadrootsAppError::Runtime(message) => Self::Runtime(message), + radroots_mobile_core::RadrootsAppError::Unsupported(message) => { + Self::Unsupported(message) + } + radroots_mobile_core::RadrootsAppError::Internal(message) => Self::Internal(message), + } + } +} + +impl RadrootsAppError { + pub(crate) fn initialization(message: impl Into<String>) -> Self { + Self::Initialization(message.into()) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::RadrootsAppError; + + #[test] + fn every_core_error_variant_maps_without_string_erasure() { + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::initialization( + "initialization" + )), + RadrootsAppError::Initialization(message) if message == "initialization" + )); + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::runtime("runtime")), + RadrootsAppError::Runtime(message) if message == "runtime" + )); + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::unsupported( + "unsupported" + )), + RadrootsAppError::Unsupported(message) if message == "unsupported" + )); + assert!(matches!( + RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::internal("internal")), + RadrootsAppError::Internal(message) if message == "internal" + )); + } +} diff --git a/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_ffi/src/lib.rs @@ -3,10 +3,16 @@ #![allow(clippy::result_large_err)] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] -uniffi::setup_scaffolding!("radroots_mobile_ffi"); -radroots_mobile_core::uniffi_reexport_scaffolding!(); +uniffi::setup_scaffolding!("radroots_mobile_core"); pub mod logging; +mod remote; +mod runtime; + +pub use error::{RadrootsAppError, SdkErrorRecord}; +pub use runtime::RadrootsRuntime; + +mod error; #[allow( clippy::if_same_then_else, diff --git a/core/crates/tera_ffi/src/logging.rs b/core/crates/tera_ffi/src/logging.rs @@ -40,9 +40,9 @@ pub fn init_logging( dir: Option<String>, file_name: Option<String>, is_stdout: Option<bool>, -) -> Result<(), radroots_mobile_core::RadrootsAppError> { +) -> Result<(), crate::RadrootsAppError> { let opts = logging_options(dir, file_name, is_stdout); - initialize(opts).map_err(radroots_mobile_core::RadrootsAppError::initialization) + initialize(opts).map_err(crate::RadrootsAppError::initialization) } fn logging_options( @@ -142,25 +142,24 @@ fn build_file_writer( } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn init_logging_stdout() -> Result<(), radroots_mobile_core::RadrootsAppError> { - initialize(LoggingOptions::default()) - .map_err(radroots_mobile_core::RadrootsAppError::initialization) +pub fn init_logging_stdout() -> Result<(), crate::RadrootsAppError> { + initialize(LoggingOptions::default()).map_err(crate::RadrootsAppError::initialization) } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_info(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { +pub fn log_info(msg: String) -> Result<(), crate::RadrootsAppError> { tracing::info!("{msg}"); Ok(()) } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_error(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { +pub fn log_error(msg: String) -> Result<(), crate::RadrootsAppError> { tracing::error!("{msg}"); Ok(()) } #[cfg_attr(not(coverage_nightly), uniffi::export)] -pub fn log_debug(msg: String) -> Result<(), radroots_mobile_core::RadrootsAppError> { +pub fn log_debug(msg: String) -> Result<(), crate::RadrootsAppError> { tracing::debug!("{msg}"); Ok(()) } diff --git a/core/crates/tera_ffi/src/remote.rs b/core/crates/tera_ffi/src/remote.rs @@ -0,0 +1,664 @@ +//! UniFFI converter ownership for ordinary Rust DTOs defined by mobile core. + +use radroots_mobile_core::SdkErrorRecord; +use radroots_mobile_core::runtime::app_info::*; +use radroots_mobile_core::runtime::info::*; +use radroots_mobile_core::runtime::key_management::*; +use radroots_mobile_core::runtime::nostr::*; +use radroots_mobile_core::runtime::product_surface::*; +use radroots_mobile_core::runtime::sdk::*; + +#[uniffi::remote(Record)] +pub struct SdkErrorRecord { + pub schema_version: u16, + pub code: String, + pub class: String, + pub retryable: bool, + pub recovery_actions: Vec<String>, + pub operation_id: Option<String>, + pub capability_id: Option<String>, + pub message: String, +} + +#[uniffi::remote(Record)] +pub struct AppInfoPlatform { + pub platform: Option<String>, + pub bundle_id: Option<String>, + pub version: Option<String>, + pub build_number: Option<String>, + pub build_sha: Option<String>, +} +#[uniffi::remote(Record)] +pub struct RuntimeBuildInfo { + pub crate_name: String, + pub crate_version: String, + pub rustc: Option<String>, + pub profile: Option<String>, + pub lib_revision: Option<String>, + pub consumer_revision: Option<String>, + pub build_time_unix: Option<u64>, +} + +#[uniffi::remote(Record)] +pub struct AppInfo { + pub build: RuntimeBuildInfo, + pub started_unix_ms: i64, + pub uptime_millis: i64, + pub shutting_down: bool, + pub platform: Option<AppInfoPlatform>, +} + +#[uniffi::remote(Record)] +pub struct RuntimeInfo { + pub app: AppInfo, + pub sdk: RuntimeBuildInfo, + pub sdk_closed: bool, +} +#[uniffi::remote(Record)] +pub struct SdkCapabilityRecord { + pub id: String, + pub compiled: bool, + pub configured: bool, + pub availability: String, + pub maturity: String, +} + +#[uniffi::remote(Record)] +pub struct SdkStorageStatusRecord { + pub backend: String, + pub open_mode: String, + pub shutdown: String, + pub integrity: String, +} + +#[uniffi::remote(Record)] +pub struct SdkShutdownRecord { + pub state: String, + pub already_closed: bool, +} +#[uniffi::remote(Record)] +pub struct NostrIdentityRecord { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, + pub label: Option<String>, + pub is_selected: bool, +} + +#[uniffi::remote(Record)] +pub struct NostrIdentitySnapshot { + pub has_selected_signing_identity: bool, + pub selected_identity_id: Option<String>, + pub selected_npub: Option<String>, + pub identities: Vec<NostrIdentityRecord>, +} + +#[uniffi::remote(Record)] +pub struct NostrHostCustodyIdentity { + pub id: String, + pub public_key_hex: String, + pub public_key_npub: String, +} +#[uniffi::remote(Enum)] +pub enum NostrLight { + Red, + Yellow, + Green, +} + +#[uniffi::remote(Record)] +pub struct NostrConnectionStatus { + pub light: NostrLight, + pub configured: bool, + pub source_available: bool, + pub sink_available: bool, + pub last_error: Option<String>, +} + +#[uniffi::remote(Record)] +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>, +} + +#[uniffi::remote(Record)] +pub struct NostrProfileEventMetadata { + pub id: String, + pub author: String, + pub published_at: u64, + pub profile: NostrProfile, +} + +#[uniffi::remote(Record)] +pub struct NostrPost { + pub content: String, +} + +#[uniffi::remote(Record)] +pub struct NostrPostEventMetadata { + pub id: String, + pub author: String, + pub published_at: u64, + pub post: NostrPost, +} +#[uniffi::remote(Enum)] +pub enum ContextType { + Regional, + Network, + Farm, + Buyer, + Route, + RoutePartner, + PickupPoint, + TraceRecords, + Hub, + NetworkSteward, +} + +#[uniffi::remote(Enum)] +pub enum WorkflowActor { + NetworkMember, + ProducerAdmin, + FarmTeamMember, + HubOperator, + TraceLead, + BuyerSourcingLead, + BuyerReceiver, + PickupPointCoordinator, + RouteCoordinator, + RoutePartner, + NetworkSteward, +} + +#[uniffi::remote(Enum)] +pub enum VisibilityClass { + LocalDraft, + FarmPrivate, + WorkspacePrivate, + NetworkVisible, + RouteScoped, + BuyerScoped, + PublicCommunity, + PublicProvenance, + SecretNeverShared, +} + +#[uniffi::remote(Enum)] +pub enum AuthorityDomain { + #[serde(rename = "Relay/group access")] + RelayGroupAccess, + #[serde(rename = "Farm/workspace operations authority")] + FarmWorkspaceOperations, + #[serde(rename = "Buyer workspace authority")] + BuyerWorkspace, + #[serde(rename = "Route coordination authority")] + RouteCoordination, + #[serde(rename = "Route execution authority")] + RouteExecution, + #[serde(rename = "Receipt authority")] + Receipt, + #[serde(rename = "Trace/proof authority")] + TraceProof, + #[serde(rename = "Public publishing authority")] + PublicPublishing, + #[serde(rename = "Network stewardship authority")] + NetworkStewardship, +} + +#[uniffi::remote(Enum)] +pub enum AuthorityAction { + Submit, + Publish, + Share, + Assign, + Approve, + Correct, + Close, + Retry, + Search, + NavigateRelatedObject, +} + +#[uniffi::remote(Enum)] +pub enum ObjectKind { + Region, + Network, + Farm, + BuyerWorkspace, + Route, + RoutePartner, + PickupPoint, + Hub, + Food, + Ask, + Event, + Place, + Task, + Proof, + Exception, + Provenance, + Update, + AccessMembership, + BuyerPacket, + RouteStop, + Draft, + OutboxItem, + MemberInvite, + Correction, +} + +#[uniffi::remote(Enum)] +pub enum ObjectPageFamily { + Network, + NetworkRoute, + FarmWorkspace, + FarmPublicProfile, + BuyerWorkspace, + PickupPointPlace, + Food, + Event, + RouteStop, + Proof, + BuyerPacket, + PublicProvenance, + Exception, +} + +#[uniffi::remote(Enum)] +pub enum TodayCardType { + Route, + Food, + Ask, + Event, + Place, + Task, + Proof, + Exception, + Provenance, + Update, + AccessMembership, + SyncOutbox, +} + +#[uniffi::remote(Enum)] +pub enum AddActionType { + Photo, + Note, + Ask, + Scan, + Food, + Harvest, + BuyerRequest, + BuyerCommitment, + RouteNeed, + RouteStop, + PickupEvent, + Place, + Proof, + Exception, + PublicUpdate, + Provenance, + MemberInvite, + Correction, +} + +#[uniffi::remote(Enum)] +pub enum AddFlowState { + NotStarted, + Draft, + Editing, + ValidationFailed, + ReadyToSubmit, + Submitted, + Queued, + Syncing, + NeedsApproval, + Approved, + Published, + Shared, + Confirmed, + Failed, + Conflict, + Discarded, +} + +#[uniffi::remote(Enum)] +pub enum OutboxBehavior { + LocalOnly, + QueueWhenOffline, + RequireOnline, + PublishWhenAuthorized, + ShareWhenAuthorized, +} + +#[uniffi::remote(Enum)] +pub enum PrototypePathKind { + ProducerFoodToRoute, + BuyerCommitmentToRoute, + RouteCoordinatorAssignment, +} + +#[uniffi::remote(Enum)] +pub enum RouteExecutionFlowKind { + RoutePartnerAssignedStops, + BuyerReceiptConfirmation, + ExceptionRecovery, +} + +#[uniffi::remote(Enum)] +pub enum RouteExecutionStepKind { + AssignedRoute, + PickupConfirmation, + DropoffConfirmation, + ReceiptConfirmation, + ExceptionReport, + RecoveryAction, +} + +#[uniffi::remote(Enum)] +pub enum ProofProvenanceArtifactKind { + ProofCompleteness, + BuyerPacketDraft, + BuyerPacketShared, + PublicProvenancePreview, +} + +#[uniffi::remote(Enum)] +pub enum ProofProvenanceReviewState { + MissingProof, + Complete, + Draft, + ReadyToShare, + Shared, + RedactionRequired, + ReadyToPublish, + Published, +} + +#[uniffi::remote(Enum)] +pub enum StewardshipAccessItemKind { + AccessRequestReview, + RoleApproval, + RoutePartnerInvite, + RoutePoolMetadata, + PublicModeration, + InviteAcceptance, + RequestAccess, + AccessDenied, + GroupManagementDeferred, +} + +#[uniffi::remote(Enum)] +pub enum OutboxState { + NotQueued, + Draft, + Queued, + Syncing, + AwaitingAuthority, + Published, + Shared, + Failed, + Conflict, + Discarded, +} + +#[uniffi::remote(Enum)] +pub enum SyncState { + Unknown, + Online, + Offline, + Syncing, + Synced, + Stale, + Failed, +} + +#[uniffi::remote(Record)] +pub struct ObjectRef { + pub object_type: ObjectKind, + pub object_id: String, + pub display_label: String, +} + +#[uniffi::remote(Record)] +pub struct EventRef { + pub event_id: String, + pub relay_url: Option<String>, + pub kind: Option<u32>, +} + +#[uniffi::remote(Record)] +pub struct ActiveContext { + pub context_type: ContextType, + pub context_ref: ObjectRef, + pub actor: WorkflowActor, + pub display_label: String, + pub visibility_scope: VisibilityClass, +} + +#[uniffi::remote(Record)] +pub struct AuthorityGate { + pub domain: AuthorityDomain, + pub action: AuthorityAction, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub is_required: bool, + pub is_allowed: bool, + pub reason: Option<String>, +} + +#[uniffi::remote(Record)] +pub struct ObjectPageSummary { + pub object_ref: ObjectRef, + pub family: ObjectPageFamily, + pub primary_context: ActiveContext, + pub title: String, + pub subtitle: Option<String>, + pub visibility: VisibilityClass, + pub visibility_label: String, + pub required_authority: AuthorityGate, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct TodayCardAction { + pub id: String, + pub label: String, + pub action_type: Option<AddActionType>, + pub target_object: Option<ObjectRef>, +} + +#[uniffi::remote(Record)] +pub struct TodayCard { + pub id: String, + pub card_type: TodayCardType, + pub source_object_refs: Vec<ObjectRef>, + pub source_event_refs: Vec<EventRef>, + pub primary_context: ActiveContext, + pub actor: WorkflowActor, + pub visibility: VisibilityClass, + pub visibility_label: String, + pub title: String, + pub status_line: String, + pub detail_lines: Vec<String>, + pub pills: Vec<String>, + pub primary_action: TodayCardAction, + pub secondary_action: Option<TodayCardAction>, + pub ranking_reason: String, + pub ranking_features: Vec<String>, + pub sync_state: SyncState, + pub outbox_state: OutboxState, + pub is_stale: bool, + pub is_offline: bool, +} + +#[uniffi::remote(Record)] +pub struct RelatedObjectRequirement { + pub object_type: ObjectKind, + pub relationship_label: String, + pub is_required: bool, +} + +#[uniffi::remote(Record)] +pub struct ValidationRequirement { + pub id: String, + pub label: String, + pub is_blocking: bool, +} + +#[uniffi::remote(Record)] +pub struct AddAction { + pub action_type: AddActionType, + pub display_label: String, + pub allowed_context_types: Vec<ContextType>, + pub required_authority: AuthorityGate, + pub default_visibility: VisibilityClass, + pub allowed_visibility_options: Vec<VisibilityClass>, + pub created_or_updated_object_type: ObjectKind, + pub related_object_requirements: Vec<RelatedObjectRequirement>, + pub validation_requirements: Vec<ValidationRequirement>, + pub supports_offline: bool, + pub supports_draft: bool, + pub outbox_behavior: OutboxBehavior, + pub primary_submit_label: String, + pub completion_state: AddFlowState, +} + +#[uniffi::remote(Record)] +pub struct OutboxItem { + pub id: String, + pub action_type: AddActionType, + pub context: ActiveContext, + pub object_refs: Vec<ObjectRef>, + pub event_refs: Vec<EventRef>, + pub visibility: VisibilityClass, + pub authority_gate: AuthorityGate, + pub flow_state: AddFlowState, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub queued_at_unix: Option<u64>, + pub last_attempt_at_unix: Option<u64>, + pub retry_count: u32, + pub last_error: Option<String>, +} + +#[uniffi::remote(Record)] +pub struct OutboxRetryDecision { + pub item_id: String, + pub is_retryable: bool, + pub authority_gate: AuthorityGate, + pub reason: Option<String>, +} + +#[uniffi::remote(Record)] +pub struct SearchResultSummary { + pub id: String, + pub object_ref: ObjectRef, + pub primary_context: ActiveContext, + pub title: String, + pub subtitle: Option<String>, + pub visibility: VisibilityClass, + pub visibility_label: String, + pub required_authority: AuthorityGate, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct PrototypePathStep { + pub id: String, + pub label: String, + pub context: ActiveContext, + pub action_type: Option<AddActionType>, + pub object_ref: Option<ObjectRef>, + pub authority_gate: AuthorityGate, + pub visibility: VisibilityClass, + pub outbox_state: OutboxState, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct PrototypePath { + pub id: String, + pub kind: PrototypePathKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub steps: Vec<PrototypePathStep>, +} + +#[uniffi::remote(Record)] +pub struct RouteExecutionStep { + pub id: String, + pub kind: RouteExecutionStepKind, + pub label: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub route_ref: ObjectRef, + pub object_ref: Option<ObjectRef>, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub supports_offline: bool, + pub supports_partial_receipt: bool, + pub uses_receipt_token: bool, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub detail_lines: Vec<String>, +} + +#[uniffi::remote(Record)] +pub struct RouteExecutionFlow { + pub id: String, + pub kind: RouteExecutionFlowKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub route_ref: ObjectRef, + pub steps: Vec<RouteExecutionStep>, +} + +#[uniffi::remote(Record)] +pub struct ProofProvenanceArtifact { + pub id: String, + pub kind: ProofProvenanceArtifactKind, + pub review_state: ProofProvenanceReviewState, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub object_ref: ObjectRef, + pub source_object_refs: Vec<ObjectRef>, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub is_public_preview: bool, + pub requires_redaction_review: bool, + pub can_publish: bool, + pub public_summary_lines: Vec<String>, + pub redacted_field_labels: Vec<String>, + pub outbox_state: OutboxState, + pub sync_state: SyncState, +} + +#[uniffi::remote(Record)] +pub struct StewardshipAccessItem { + pub id: String, + pub kind: StewardshipAccessItemKind, + pub title: String, + pub actor: WorkflowActor, + pub context: ActiveContext, + pub target_ref: ObjectRef, + pub required_authority: AuthorityGate, + pub visibility: VisibilityClass, + pub is_admin_lite: bool, + pub is_phase_2_deferred: bool, + pub grants_private_access: bool, + pub outbox_state: OutboxState, + pub sync_state: SyncState, + pub detail_lines: Vec<String>, +} diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -0,0 +1,277 @@ +use radroots_mobile_core::runtime::{ + info::RuntimeInfo, + key_management::{NostrHostCustodyIdentity, NostrIdentityRecord, NostrIdentitySnapshot}, + nostr::{NostrConnectionStatus, NostrPostEventMetadata, NostrProfileEventMetadata}, + product_surface::{ + ActiveContext, AddAction, AuthorityAction, AuthorityDomain, AuthorityGate, + ObjectPageSummary, OutboxItem, OutboxRetryDecision, ProofProvenanceArtifact, PrototypePath, + RouteExecutionFlow, SearchResultSummary, StewardshipAccessItem, TodayCard, WorkflowActor, + }, + sdk::{SdkCapabilityRecord, SdkShutdownRecord, SdkStorageStatusRecord}, +}; + +use crate::RadrootsAppError; + +/// Native boundary object delegating all behavior to the ordinary Rust core. +#[derive(uniffi::Object)] +pub struct RadrootsRuntime { + inner: radroots_mobile_core::RadrootsRuntime, +} + +#[cfg_attr(not(coverage_nightly), uniffi::export)] +impl RadrootsRuntime { + #[cfg_attr(not(coverage_nightly), uniffi::constructor)] + pub fn new() -> Result<Self, RadrootsAppError> { + radroots_mobile_core::RadrootsRuntime::new() + .map(|inner| Self { inner }) + .map_err(Into::into) + } + + pub async fn shutdown(&self) -> Result<SdkShutdownRecord, RadrootsAppError> { + self.inner.shutdown().await.map_err(Into::into) + } + + pub fn uptime_millis(&self) -> i64 { + self.inner.uptime_millis() + } + + pub fn info(&self) -> RuntimeInfo { + self.inner.info() + } + + pub fn info_json(&self) -> String { + self.inner.info_json() + } + + pub fn set_app_info_platform( + &self, + platform: Option<String>, + bundle_id: Option<String>, + version: Option<String>, + build_number: Option<String>, + build_sha: Option<String>, + ) { + self.inner + .set_app_info_platform(platform, bundle_id, version, build_number, build_sha); + } + + pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> { + self.inner.sdk_capabilities() + } + + pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> { + self.inner.sdk_storage_status().await.map_err(Into::into) + } + + pub fn nostr_identity_has_selected_signing_identity(&self) -> bool { + self.inner.nostr_identity_has_selected_signing_identity() + } + + pub fn nostr_identity_selected_npub(&self) -> Option<String> { + self.inner.nostr_identity_selected_npub() + } + + pub fn nostr_identity_list(&self) -> Result<Vec<NostrIdentityRecord>, RadrootsAppError> { + self.inner.nostr_identity_list().map_err(Into::into) + } + + pub fn nostr_identity_list_ids(&self) -> Result<Vec<String>, RadrootsAppError> { + self.inner.nostr_identity_list_ids().map_err(Into::into) + } + + pub fn nostr_identity_snapshot(&self) -> Result<NostrIdentitySnapshot, RadrootsAppError> { + self.inner.nostr_identity_snapshot().map_err(Into::into) + } + + pub fn nostr_identity_validate_host_custody_secret( + &self, + secret_key: String, + ) -> Result<NostrHostCustodyIdentity, RadrootsAppError> { + self.inner + .nostr_identity_validate_host_custody_secret(secret_key) + .map_err(Into::into) + } + + pub fn nostr_identity_restore_host_custody_secret( + &self, + secret_key: String, + label: Option<String>, + make_selected: bool, + ) -> Result<NostrIdentityRecord, RadrootsAppError> { + self.inner + .nostr_identity_restore_host_custody_secret(secret_key, label, make_selected) + .map_err(Into::into) + } + + pub fn nostr_identity_select(&self, identity_id: String) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_select(identity_id) + .map_err(Into::into) + } + + pub fn nostr_identity_remove(&self, identity_id: String) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_remove(identity_id) + .map_err(Into::into) + } + + pub fn nostr_identity_lock_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_lock_host_custody_runtime() + .map_err(Into::into) + } + + pub fn nostr_identity_reset_host_custody_runtime(&self) -> Result<(), RadrootsAppError> { + self.inner + .nostr_identity_reset_host_custody_runtime() + .map_err(Into::into) + } + + pub fn nostr_set_default_relays(&self, relays: Vec<String>) -> Result<(), RadrootsAppError> { + self.inner + .nostr_set_default_relays(relays) + .map_err(Into::into) + } + + pub fn nostr_connect_if_key_present(&self) -> Result<(), RadrootsAppError> { + self.inner + .nostr_connect_if_key_present() + .map_err(Into::into) + } + + pub async fn nostr_connection_status(&self) -> Result<NostrConnectionStatus, RadrootsAppError> { + self.inner + .nostr_connection_status() + .await + .map_err(Into::into) + } + + pub async fn nostr_profile_for_self( + &self, + ) -> Result<Option<NostrProfileEventMetadata>, RadrootsAppError> { + self.inner + .nostr_profile_for_self() + .await + .map_err(Into::into) + } + + pub async fn nostr_post_profile( + &self, + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + ) -> Result<String, RadrootsAppError> { + self.inner + .nostr_post_profile(name, display_name, nip05, about) + .await + .map_err(Into::into) + } + + pub async fn nostr_post_text_note(&self, content: String) -> Result<String, RadrootsAppError> { + self.inner + .nostr_post_text_note(content) + .await + .map_err(Into::into) + } + + pub async fn nostr_fetch_text_notes( + &self, + limit: u16, + since_unix: Option<u64>, + ) -> Result<Vec<NostrPostEventMetadata>, RadrootsAppError> { + self.inner + .nostr_fetch_text_notes(limit, since_unix) + .await + .map_err(Into::into) + } + + pub async 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> { + self.inner + .nostr_post_reply( + parent_event_id_hex, + parent_author_hex, + content, + root_event_id_hex, + ) + .await + .map_err(Into::into) + } + + pub fn phase1_active_contexts(&self) -> Vec<ActiveContext> { + self.inner.phase1_active_contexts() + } + + pub fn phase1_today_cards(&self, context_id: Option<String>) -> Vec<TodayCard> { + self.inner.phase1_today_cards(context_id) + } + + pub fn phase1_add_actions(&self, context_id: Option<String>) -> Vec<AddAction> { + self.inner.phase1_add_actions(context_id) + } + + pub fn phase1_object_page_summaries( + &self, + context_id: Option<String>, + ) -> Vec<ObjectPageSummary> { + self.inner.phase1_object_page_summaries(context_id) + } + + pub fn phase1_outbox_snapshot(&self) -> Vec<OutboxItem> { + self.inner.phase1_outbox_snapshot() + } + + pub fn phase1_search_results( + &self, + query: Option<String>, + context_id: Option<String>, + ) -> Vec<SearchResultSummary> { + self.inner.phase1_search_results(query, context_id) + } + + pub fn phase1_prototype_paths(&self) -> Vec<PrototypePath> { + self.inner.phase1_prototype_paths() + } + + pub fn phase1_route_execution_flows( + &self, + context_id: Option<String>, + ) -> Vec<RouteExecutionFlow> { + self.inner.phase1_route_execution_flows(context_id) + } + + pub fn phase1_proof_provenance_artifacts( + &self, + context_id: Option<String>, + ) -> Vec<ProofProvenanceArtifact> { + self.inner.phase1_proof_provenance_artifacts(context_id) + } + + pub fn phase1_stewardship_access_items( + &self, + context_id: Option<String>, + ) -> Vec<StewardshipAccessItem> { + self.inner.phase1_stewardship_access_items(context_id) + } + + pub fn phase1_outbox_retry_decision(&self, item: OutboxItem) -> OutboxRetryDecision { + self.inner.phase1_outbox_retry_decision(item) + } + + pub fn phase1_check_authority( + &self, + actor: WorkflowActor, + context: ActiveContext, + domain: AuthorityDomain, + action: AuthorityAction, + ) -> AuthorityGate { + self.inner + .phase1_check_authority(actor, context, domain, action) + } +} diff --git a/core/crates/tera_ffi/tests/logging_error.rs b/core/crates/tera_ffi/tests/logging_error.rs @@ -1,4 +1,4 @@ -use radroots_mobile_core::RadrootsAppError; +use radroots_mobile_ffi::RadrootsAppError; use radroots_mobile_ffi::logging; #[test] diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -0,0 +1,177 @@ +use radroots_mobile_core::runtime::product_surface::{ + AuthorityAction, AuthorityDomain, OutboxState, +}; +use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; + +const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + +#[tokio::test] +async fn native_boundary_delegates_the_complete_core_surface() { + let runtime = RadrootsRuntime::new().expect("runtime"); + assert!(runtime.uptime_millis() >= 0); + assert!(runtime.info_json().contains("sdk")); + runtime.set_app_info_platform( + Some("ios".to_owned()), + Some("org.radroots.app".to_owned()), + Some("0.1.0-alpha".to_owned()), + Some("1".to_owned()), + Some("0123456789abcdef0123456789abcdef01234567".to_owned()), + ); + assert_eq!( + runtime.info().app.platform.expect("platform").platform, + Some("ios".to_owned()) + ); + assert!(!runtime.sdk_capabilities().is_empty()); + assert_eq!( + runtime.sdk_storage_status().await.expect("storage").backend, + "memory" + ); + + assert!(!runtime.nostr_identity_has_selected_signing_identity()); + assert!(runtime.nostr_identity_selected_npub().is_none()); + assert!( + runtime + .nostr_identity_list() + .expect("empty list") + .is_empty() + ); + assert!( + runtime + .nostr_identity_list_ids() + .expect("empty identifiers") + .is_empty() + ); + assert!( + runtime + .nostr_identity_snapshot() + .expect("empty snapshot") + .identities + .is_empty() + ); + let validated = runtime + .nostr_identity_validate_host_custody_secret(SECRET.to_owned()) + .expect("valid secret"); + let staged = runtime + .nostr_identity_restore_host_custody_secret( + SECRET.to_owned(), + Some("staged".to_owned()), + false, + ) + .expect("staged identity"); + assert_eq!(validated.id, staged.id); + let selected = runtime + .nostr_identity_restore_host_custody_secret( + SECRET.to_owned(), + Some("selected".to_owned()), + true, + ) + .expect("selected identity"); + assert_eq!( + runtime.nostr_identity_selected_npub(), + Some(selected.public_key_npub.clone()) + ); + runtime + .nostr_identity_select(selected.id.clone()) + .expect("select installed"); + assert!(runtime.nostr_identity_select("missing".to_owned()).is_err()); + runtime + .nostr_identity_remove("missing".to_owned()) + .expect("removing missing identity is idempotent"); + runtime + .nostr_identity_lock_host_custody_runtime() + .expect("lock identity"); + runtime + .nostr_identity_reset_host_custody_runtime() + .expect("reset identity"); + + assert!(runtime.nostr_set_default_relays(Vec::new()).is_err()); + assert!(runtime.nostr_connect_if_key_present().is_err()); + assert!( + runtime + .nostr_connection_status() + .await + .expect("unconfigured status") + .last_error + .is_none() + ); + assert!(runtime.nostr_profile_for_self().await.is_err()); + assert!( + runtime + .nostr_post_profile(None, None, None, None) + .await + .is_err() + ); + assert!( + runtime + .nostr_post_text_note("post".to_owned()) + .await + .is_err() + ); + assert!(runtime.nostr_fetch_text_notes(1, None).await.is_err()); + assert!(matches!( + runtime + .nostr_post_reply( + "parent".to_owned(), + "author".to_owned(), + "reply".to_owned(), + Some("different-root".to_owned()), + ) + .await, + Err(RadrootsAppError::Unsupported(_)) + )); + + let contexts = runtime.phase1_active_contexts(); + let context = contexts.first().expect("context").clone(); + let context_id = Some(context.context_ref.object_id.clone()); + assert!(!runtime.phase1_today_cards(context_id.clone()).is_empty()); + assert!(!runtime.phase1_add_actions(context_id.clone()).is_empty()); + assert!( + !runtime + .phase1_object_page_summaries(context_id.clone()) + .is_empty() + ); + let outbox = runtime.phase1_outbox_snapshot(); + let failed = outbox + .into_iter() + .find(|item| item.outbox_state == OutboxState::Failed) + .expect("failed outbox item"); + assert!(runtime.phase1_outbox_retry_decision(failed).is_retryable); + assert!( + !runtime + .phase1_search_results(Some("farm".to_owned()), context_id.clone()) + .is_empty() + ); + assert!(!runtime.phase1_prototype_paths().is_empty()); + assert!( + !runtime + .phase1_route_execution_flows(context_id.clone()) + .is_empty() + ); + assert!( + !runtime + .phase1_proof_provenance_artifacts(context_id.clone()) + .is_empty() + ); + assert!( + !runtime + .phase1_stewardship_access_items(context_id) + .is_empty() + ); + assert_eq!( + runtime + .phase1_check_authority( + context.actor, + context.clone(), + AuthorityDomain::RelayGroupAccess, + AuthorityAction::Search, + ) + .context, + context + ); + + runtime.shutdown().await.expect("shutdown"); + assert!(matches!( + runtime.sdk_storage_status().await, + Err(RadrootsAppError::Sdk { .. }) + )); +} diff --git a/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_ffi/tests/runtime_lifecycle.rs @@ -0,0 +1,40 @@ +use std::{sync::Arc, time::Duration}; + +use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime}; + +#[tokio::test] +async fn host_release_ordering_retains_close_and_finishes_within_deadline() { + let host = Arc::new(RadrootsRuntime::new().expect("runtime")); + let closing_owner = Arc::clone(&host); + let close = tokio::spawn(async move { closing_owner.shutdown().await }); + drop(host); + + let result = tokio::time::timeout(Duration::from_secs(1), close) + .await + .expect("mobile shutdown exceeded its host deadline") + .expect("shutdown task panicked") + .expect("shutdown failed"); + assert_eq!(result.state, "closed"); + assert!(!result.already_closed); +} + +#[tokio::test] +async fn concurrent_host_references_converge_and_repeated_close_is_idempotent() { + let runtime = Arc::new(RadrootsRuntime::new().expect("runtime")); + let first = Arc::clone(&runtime); + let second = Arc::clone(&runtime); + let (first, second) = tokio::join!(first.shutdown(), second.shutdown()); + + for outcome in [&first, &second] { + assert!( + outcome.is_ok() + || matches!( + outcome, + Err(RadrootsAppError::Sdk { report }) + if report.code == "close_in_progress" + ) + ); + } + let repeated = runtime.shutdown().await.expect("repeated close"); + assert!(repeated.already_closed); +} diff --git a/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_ffi/tests/uniffi_contract.rs @@ -0,0 +1,33 @@ +use radroots_mobile_ffi::{RadrootsAppError, RadrootsRuntime, SdkErrorRecord}; + +#[tokio::test] +async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() { + let runtime = RadrootsRuntime::new().expect("runtime"); + let storage = runtime.sdk_storage_status().await.expect("storage status"); + assert_eq!(storage.backend, "memory"); + + runtime.shutdown().await.expect("shutdown"); + let error = runtime + .sdk_storage_status() + .await + .expect_err("closed client must reject operations"); + let RadrootsAppError::Sdk { + report: + SdkErrorRecord { + schema_version, + code, + class, + retryable, + message, + .. + }, + } = error + else { + panic!("expected versioned SDK error record"); + }; + assert_eq!(schema_version, 1); + assert_eq!(code, "client_closed"); + assert_eq!(class, "runtime"); + assert!(!retryable); + assert_eq!(message, "SDK client is closed"); +}