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:
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");
+}