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 2a0bd61727ffade9a28a3927553ceb8c1faf38af
parent f97b76407d4b3ee1a5085b4d0855754952823e75
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 18:27:17 +0000

feat: expose mobile product operations

Diffstat:
Mcore/crates/tera_ffi/src/dto.rs | 9+++++++++
Mcore/crates/tera_ffi/src/error.rs | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/tera_ffi/src/lib.rs | 2++
Acore/crates/tera_ffi/src/operations.rs | 740+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/src/runtime.rs | 286++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/tera_ffi/src/subscription.rs | 2++
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/tera_ffi/tests/uniffi_contract.rs | 19+++++++++++++++----
8 files changed, 1224 insertions(+), 11 deletions(-)

diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs @@ -276,6 +276,7 @@ impl From<&Phase1InboundMediaState> for FfiMediaVerificationState { #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct FfiMediaReferenceRecord { pub schema_version: u16, + pub reference_fingerprint: String, pub url: String, pub sha256: Option<String>, pub media_type: Option<String>, @@ -292,6 +293,7 @@ impl From<MediaReference> for FfiMediaReferenceRecord { let structural = value.structural(); Self { schema_version: MOBILE_FFI_SCHEMA_VERSION, + reference_fingerprint: hex::encode(structural.fingerprint()), url: structural.source_url().to_owned(), sha256: structural.expected_sha256().map(str::to_owned), media_type: structural.expected_media_type().map(str::to_owned), @@ -1165,6 +1167,13 @@ fn read_media_file_descriptor( } impl PreparedMedia { + pub(crate) fn into_authored_image( + self, + blossom: &radroots_sdk::transport::BlossomSlot, + ) -> Result<AuthoredImage, RadrootsAppError> { + self.bind(blossom)?.authored_image() + } + pub(crate) fn into_upload_intent( self, draft_id: [u8; 16], diff --git a/core/crates/tera_ffi/src/error.rs b/core/crates/tera_ffi/src/error.rs @@ -1,4 +1,6 @@ -use radroots_mobile_core::runtime::product_surface::{Phase1DraftError, TodayError}; +use radroots_mobile_core::runtime::product_surface::{ + Phase1DraftError, ProfileMetadataError, SettingsError, TodayError, +}; use thiserror::Error; use crate::MOBILE_FFI_SCHEMA_VERSION; @@ -77,6 +79,13 @@ impl RadrootsAppError { Self::Failure { report } => report, } } + + pub(crate) fn with_operation_id(mut self, operation_id: String) -> Self { + match &mut self { + Self::Failure { report } => report.operation_id = Some(operation_id), + } + self + } } impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError { @@ -222,6 +231,43 @@ impl From<Phase1DraftError> for RadrootsAppError { } } +impl From<SettingsError> for RadrootsAppError { + fn from(error: SettingsError) -> Self { + let retryable = matches!( + error, + SettingsError::RevisionConflict + | SettingsError::RevisionExhausted + | SettingsError::Storage + ); + let actions = if matches!(error, SettingsError::RevisionConflict) { + &["refresh"] as &[&str] + } else if retryable { + &["retry"] as &[&str] + } else { + &["correct_input"] as &[&str] + }; + Self::failure( + error.code(), + "settings", + retryable, + actions, + "The settings operation could not be completed.", + ) + } +} + +impl From<ProfileMetadataError> for RadrootsAppError { + fn from(error: ProfileMetadataError) -> Self { + Self::failure( + error.code(), + "profile", + false, + &["correct_input"], + "The profile metadata is invalid.", + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_ffi/src/lib.rs @@ -7,12 +7,14 @@ uniffi::setup_scaffolding!("radroots_mobile_core"); mod dto; pub mod logging; +mod operations; mod runtime; mod signer; mod subscription; pub use dto::*; pub use error::{RadrootsAppError, RadrootsErrorRecord}; +pub use operations::*; pub use runtime::{ProtectedDataAvailability, RadrootsRuntime}; pub use signer::{ HostSigningOutcome, HostSigningPurpose, HostSigningRequest, HostSigningResult, diff --git a/core/crates/tera_ffi/src/operations.rs b/core/crates/tera_ffi/src/operations.rs @@ -0,0 +1,740 @@ +//! Focused secret-safe operation records for settings, profile, revision, and inbound media. + +use radroots_mobile_core::runtime::product_surface::{ + AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand, + IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy, + MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus, + Phase1ProfileStatus, Phase1RevisionPhase, Phase1RevisionPolicy, Phase1RevisionStatus, + Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference, RelayEndpointPreference, + RelayPreferences, SettingsTransition, phase1_new_operation_id, +}; + +use crate::dto::PreparedMedia; +use crate::{ + FfiAddDraftInput, FfiDraftStatusRecord, FfiOperationSettlementRecord, FfiOutboxState, + FfiPreparedMediaInput, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, +}; + +impl From<crate::FfiAddCommandType> for AddCommandType { + fn from(value: crate::FfiAddCommandType) -> Self { + match value { + crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate, + crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, + crate::FfiAddCommandType::CreateAsk => Self::CreateAsk, + crate::FfiAddCommandType::CreateEvent => Self::CreateEvent, + crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiIdentityLockState { + Locked, + Unlocked, +} + +impl From<IdentityLockState> for FfiIdentityLockState { + fn from(value: IdentityLockState) -> Self { + match value { + IdentityLockState::Locked => Self::Locked, + IdentityLockState::Unlocked => Self::Unlocked, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSettingsIdentityRecord { + pub schema_version: u16, + pub id: String, + pub public_key: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiIdentityStateRecord { + pub schema_version: u16, + pub identities: Vec<FfiSettingsIdentityRecord>, + pub active_identity_id: Option<String>, + pub lock_state: FfiIdentityLockState, + pub pending_import_operation_id: Option<String>, +} + +impl From<&IdentityState> for FfiIdentityStateRecord { + fn from(value: &IdentityState) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + identities: value + .identities() + .iter() + .map(|identity| FfiSettingsIdentityRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + id: identity.id().to_owned(), + public_key: identity.public_key_hex().to_owned(), + }) + .collect(), + active_identity_id: value.active_identity_id().map(str::to_owned), + lock_state: value.lock_state().into(), + pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiIdentityCommandKind { + BeginImport, + CompleteImport, + CancelImport, + Select, + Lock, + Unlock, + Recover, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiIdentityCommandRecord { + pub schema_version: u16, + pub kind: FfiIdentityCommandKind, + pub operation_id: Option<String>, + pub identity_id: Option<String>, + pub public_key: Option<String>, +} + +impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand { + type Error = RadrootsAppError; + + fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> { + require_schema(value.schema_version)?; + let no_operation = value.operation_id.is_none(); + let no_identity = value.identity_id.is_none() && value.public_key.is_none(); + match value.kind { + FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport { + operation_id: required(value.operation_id, "identity_operation_id_required")?, + }), + FfiIdentityCommandKind::CompleteImport => { + let operation_id = required(value.operation_id, "identity_operation_id_required")?; + let identity_id = required(value.identity_id, "identity_id_required")?; + let public_key = required(value.public_key, "identity_public_key_required")?; + Ok(Self::CompleteImport { + operation_id, + identity: IdentityRecord::new(identity_id, &public_key) + .map_err(|error| RadrootsAppError::invalid_argument(error.code()))?, + }) + } + FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport { + operation_id: required(value.operation_id, "identity_operation_id_required")?, + }), + FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => { + Ok(Self::Select { + identity_id: required(value.identity_id, "identity_id_required")?, + }) + } + FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock), + FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock), + FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover), + _ => Err(RadrootsAppError::invalid_argument( + "invalid_identity_command", + )), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiMobileNetworkEnvironment { + Public, + Simulator, + PhysicalDevice, +} + +impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment { + fn from(value: FfiMobileNetworkEnvironment) -> Self { + match value { + FfiMobileNetworkEnvironment::Public => Self::Public, + FfiMobileNetworkEnvironment::Simulator => Self::Simulator, + FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment { + fn from(value: MobileNetworkEnvironment) -> Self { + match value { + MobileNetworkEnvironment::Public => Self::Public, + MobileNetworkEnvironment::Simulator => Self::Simulator, + MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRelayAccessPreference { + ReadOnly, + ReadWrite, +} + +impl From<FfiRelayAccessPreference> for RelayAccessPreference { + fn from(value: FfiRelayAccessPreference) -> Self { + match value { + FfiRelayAccessPreference::ReadOnly => Self::ReadOnly, + FfiRelayAccessPreference::ReadWrite => Self::ReadWrite, + } + } +} + +impl From<RelayAccessPreference> for FfiRelayAccessPreference { + fn from(value: RelayAccessPreference) -> Self { + match value { + RelayAccessPreference::ReadOnly => Self::ReadOnly, + RelayAccessPreference::ReadWrite => Self::ReadWrite, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRelayPreferenceRecord { + pub schema_version: u16, + pub url: String, + pub access: FfiRelayAccessPreference, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRelayPreferencesRecord { + pub schema_version: u16, + pub environment: FfiMobileNetworkEnvironment, + pub endpoints: Vec<FfiRelayPreferenceRecord>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiBlossomAuthorityPreference { + PublicWebPki, + LoopbackDevelopment, + PrivateNetworkDevelopment, +} + +impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference { + fn from(value: FfiBlossomAuthorityPreference) -> Self { + match value { + FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki, + FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, + FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference { + fn from(value: BlossomEndpointAuthorityPreference) -> Self { + match value { + BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki, + BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment, + BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => { + Self::PrivateNetworkDevelopment + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomPreferencesRecord { + pub schema_version: u16, + pub environment: FfiMobileNetworkEnvironment, + pub authority: FfiBlossomAuthorityPreference, + pub primary_origin: String, + pub fallback_origins: Vec<String>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMediaNetworkPolicyRecord { + pub schema_version: u16, + pub allow_cellular_downloads: bool, + pub allow_cellular_uploads: bool, + pub allow_background_transfers: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiLocalStoragePolicyRecord { + pub schema_version: u16, + pub media_cache_bytes: u64, + pub media_cache_artifacts: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMobileSettingsRecord { + pub schema_version: u16, + pub revision: u64, + pub identity: FfiIdentityStateRecord, + pub relays: FfiRelayPreferencesRecord, + pub blossom: FfiBlossomPreferencesRecord, + pub media_network: FfiMediaNetworkPolicyRecord, + pub local_storage: FfiLocalStoragePolicyRecord, +} + +impl From<&MobileSettings> for FfiMobileSettingsRecord { + fn from(value: &MobileSettings) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + revision: value.revision(), + identity: value.identity().into(), + relays: FfiRelayPreferencesRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + environment: value.relays().environment().into(), + endpoints: value + .relays() + .endpoints() + .iter() + .map(|endpoint| FfiRelayPreferenceRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + url: endpoint.url().to_owned(), + access: endpoint.access().into(), + }) + .collect(), + }, + blossom: FfiBlossomPreferencesRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + environment: value.blossom().environment().into(), + authority: value.blossom().authority().into(), + primary_origin: value.blossom().primary_origin().to_owned(), + fallback_origins: value.blossom().fallback_origins().to_vec(), + }, + media_network: FfiMediaNetworkPolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + allow_cellular_downloads: value.media_network().allow_cellular_downloads(), + allow_cellular_uploads: value.media_network().allow_cellular_uploads(), + allow_background_transfers: value.media_network().allow_background_transfers(), + }, + local_storage: FfiLocalStoragePolicyRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + media_cache_bytes: value.local_storage().media_cache_bytes(), + media_cache_artifacts: value.local_storage().media_cache_artifacts(), + }, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiReplaceSettingsRecord { + pub schema_version: u16, + pub expected_revision: u64, + pub relays: FfiRelayPreferencesRecord, + pub blossom: FfiBlossomPreferencesRecord, + pub media_network: FfiMediaNetworkPolicyRecord, + pub local_storage: FfiLocalStoragePolicyRecord, +} + +impl FfiReplaceSettingsRecord { + pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, RadrootsAppError> { + require_schema(self.schema_version)?; + if current.revision() != self.expected_revision { + return Err(RadrootsAppError::invalid_argument( + "settings_revision_conflict", + )); + } + require_schema(self.relays.schema_version)?; + let relay_environment: MobileNetworkEnvironment = self.relays.environment.into(); + let relay_endpoints = self + .relays + .endpoints + .into_iter() + .map(|endpoint| { + require_schema(endpoint.schema_version)?; + RelayEndpointPreference::new( + relay_environment, + endpoint.url, + endpoint.access.into(), + ) + .map_err(Into::into) + }) + .collect::<Result<Vec<_>, RadrootsAppError>>()?; + let relays = RelayPreferences::new(relay_environment, relay_endpoints)?; + + require_schema(self.blossom.schema_version)?; + let blossom = BlossomPreferences::new( + self.blossom.environment.into(), + self.blossom.authority.into(), + self.blossom.primary_origin, + self.blossom.fallback_origins, + )?; + require_schema(self.media_network.schema_version)?; + let media_network = MediaNetworkPolicy::new( + self.media_network.allow_cellular_downloads, + self.media_network.allow_cellular_uploads, + self.media_network.allow_background_transfers, + ); + require_schema(self.local_storage.schema_version)?; + let local_storage = LocalStoragePolicy::new( + self.local_storage.media_cache_bytes, + self.local_storage.media_cache_artifacts, + )?; + Ok(current + .with_relays(relays) + .with_blossom(blossom) + .with_media_network(media_network) + .with_local_storage(local_storage)) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiSettingsTransitionRecord { + pub schema_version: u16, + pub settings: FfiMobileSettingsRecord, + pub runtime_restart_required: bool, + pub outbox_requeue_required: bool, + pub media_cache_invalidation_required: bool, +} + +impl From<SettingsTransition> for FfiSettingsTransitionRecord { + fn from(value: SettingsTransition) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + settings: (&value.settings).into(), + runtime_restart_required: value.runtime_restart_required, + outbox_requeue_required: value.outbox_requeue_required, + media_cache_invalidation_required: value.media_cache_invalidation_required, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct FfiProfileMetadataInputRecord { + pub schema_version: u16, + pub name: String, + pub display_name: Option<String>, + pub about: Option<String>, + pub picture: Option<FfiPreparedMediaInput>, + pub banner: Option<FfiPreparedMediaInput>, + pub nip05: Option<String>, + pub bot: Option<bool>, +} + +impl FfiProfileMetadataInputRecord { + pub(crate) fn command( + self, + blossom: Option<&radroots_sdk::transport::BlossomSlot>, + ) -> Result<ProfileMetadataCommand, RadrootsAppError> { + require_schema(self.schema_version)?; + let picture = self + .picture + .map(PreparedMedia::try_from) + .transpose()? + .map(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::failure( + "blossom_unconfigured", + "profile", + true, + &["configure_blossom"], + "Profile media configuration is unavailable.", + ) + })?; + media.into_authored_image(blossom) + }) + .transpose()?; + let banner = self + .banner + .map(PreparedMedia::try_from) + .transpose()? + .map(|media| { + let blossom = blossom.ok_or_else(|| { + RadrootsAppError::failure( + "blossom_unconfigured", + "profile", + true, + &["configure_blossom"], + "Profile media configuration is unavailable.", + ) + })?; + media.into_authored_image(blossom) + }) + .transpose()?; + ProfileMetadataCommand::new( + self.name, + self.display_name, + self.about, + picture, + banner, + self.nip05, + self.bot, + ) + .map_err(Into::into) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiProfileStatusRecord { + pub schema_version: u16, + pub operation_id: String, + pub revision: u64, + pub author_public_key: String, + pub state: FfiOutboxState, + pub delivery_id: Option<String>, + pub created_at_unix_ms: u64, + pub updated_at_unix_ms: u64, + pub settlement: Option<FfiOperationSettlementRecord>, +} + +impl From<Phase1ProfileStatus> for FfiProfileStatusRecord { + fn from(value: Phase1ProfileStatus) -> Self { + let draft = value.draft(); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id: hex::encode(draft.draft_id().as_bytes()), + revision: draft.revision().get(), + author_public_key: hex::encode(draft.author()), + state: value.state().into(), + delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), + created_at_unix_ms: draft.created_at_unix_ms(), + updated_at_unix_ms: draft.updated_at_unix_ms(), + settlement: value.push().map(|push| push.settlement().into()), + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct FfiRevisionInputRecord { + pub schema_version: u16, + pub command_type: crate::FfiAddCommandType, + pub card_id: String, + pub source_event_id: String, + pub source_kind: u32, + pub source_address: Option<String>, + pub author_public_key: String, + pub replacement: FfiAddDraftInput, +} + +impl FfiRevisionInputRecord { + pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, RadrootsAppError> { + require_schema(self.schema_version)?; + Phase1RevisionTarget::new( + self.command_type.into(), + radroots_mobile_core::runtime::product_surface::CardId::parse(&self.card_id) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?, + self.source_event_id.clone(), + self.source_kind, + self.source_address.clone(), + self.author_public_key.clone(), + ) + .map_err(Into::into) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRevisionPolicy { + ReplaceThenRetract, + AddressableReplacement, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRevisionPhase { + ReplacementPending, + ReplacementFailed, + RetractionPending, + Complete, + PartialEffect, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRevisionStatusRecord { + pub schema_version: u16, + pub operation_id: String, + pub replacement: FfiDraftStatusRecord, + pub retraction: Option<FfiDraftStatusRecord>, + pub policy: FfiRevisionPolicy, + pub phase: FfiRevisionPhase, +} + +impl From<Phase1RevisionStatus> for FfiRevisionStatusRecord { + fn from(value: Phase1RevisionStatus) -> Self { + let operation_id = hex::encode(value.replacement().draft().draft_id().as_bytes()); + let retraction = value.retraction().cloned().map(Into::into); + let replacement = value.replacement().clone().into(); + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id, + replacement, + retraction, + policy: match value.policy() { + Phase1RevisionPolicy::ReplaceThenRetract => FfiRevisionPolicy::ReplaceThenRetract, + Phase1RevisionPolicy::AddressableReplacement => { + FfiRevisionPolicy::AddressableReplacement + } + }, + phase: match value.phase() { + Phase1RevisionPhase::ReplacementPending => FfiRevisionPhase::ReplacementPending, + Phase1RevisionPhase::ReplacementFailed => FfiRevisionPhase::ReplacementFailed, + Phase1RevisionPhase::RetractionPending => FfiRevisionPhase::RetractionPending, + Phase1RevisionPhase::Complete => FfiRevisionPhase::Complete, + Phase1RevisionPhase::PartialEffect => FfiRevisionPhase::PartialEffect, + Phase1RevisionPhase::Cancelled => FfiRevisionPhase::Cancelled, + }, + } + } +} + +#[derive(uniffi::Object)] +pub struct FfiMediaOperation { + operation_id: [u8; 16], + cancellation: radroots_sdk::transport::BlossomCancellation, + claimed: std::sync::atomic::AtomicBool, +} + +#[uniffi::export] +impl FfiMediaOperation { + #[uniffi::constructor] + pub fn new() -> Result<Self, RadrootsAppError> { + Ok(Self { + operation_id: phase1_new_operation_id().map_err(RadrootsAppError::from)?, + cancellation: radroots_sdk::transport::BlossomCancellation::default(), + claimed: std::sync::atomic::AtomicBool::new(false), + }) + } + + pub fn operation_id(&self) -> String { + hex::encode(self.operation_id) + } + + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation.is_cancelled() + } +} + +impl FfiMediaOperation { + pub(crate) fn claim(&self) -> Result<(), RadrootsAppError> { + self.claimed + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + ) + .map(|_| ()) + .map_err(|_| RadrootsAppError::invalid_argument("media_operation_already_used")) + } + + pub(crate) const fn id(&self) -> [u8; 16] { + self.operation_id + } + + pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation { + self.cancellation.clone() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiVerifiedMediaArtifactRecord { + pub schema_version: u16, + pub operation_id: Option<String>, + pub artifact_id: String, + pub byte_size: u64, + pub media_type: String, + pub width: u32, + pub height: u32, +} + +impl FfiVerifiedMediaArtifactRecord { + pub(crate) fn from_artifact( + value: Phase1LocalMediaArtifact, + operation_id: Option<String>, + ) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + operation_id, + artifact_id: value.artifact_id().to_hex(), + byte_size: value.byte_size(), + media_type: value.media_type().to_owned(), + width: value.width(), + height: value.height(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiMediaCacheStatusRecord { + pub schema_version: u16, + pub artifact_count: u32, + pub total_bytes: u64, + pub configuration_fingerprint: Option<String>, +} + +impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord { + fn from(value: Phase1MediaCacheStatus) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + artifact_count: value.artifacts, + total_bytes: value.bytes, + configuration_fingerprint: value.configuration.map(|value| value.to_hex()), + } + } +} + +pub(crate) fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> { + if schema_version == MOBILE_FFI_SCHEMA_VERSION { + Ok(()) + } else { + Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )) + } +} + +fn required(value: Option<String>, code: &'static str) -> Result<String, RadrootsAppError> { + value.ok_or_else(|| RadrootsAppError::invalid_argument(code)) +} + +pub(crate) fn decode_artifact_id( + value: &str, +) -> Result<radroots_mobile_core::runtime::product_surface::Phase1MediaArtifactId, RadrootsAppError> +{ + radroots_mobile_core::runtime::product_surface::Phase1MediaArtifactId::parse(value) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_artifact_id")) +} + +pub(crate) fn decode_configuration( + value: &str, +) -> Result< + radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint, + RadrootsAppError, +> { + radroots_mobile_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse( + value, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_configuration")) +} + +pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], RadrootsAppError> { + let bytes = hex::decode(value) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint"))?; + bytes + .try_into() + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference_fingerprint")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn media_operation_can_be_claimed_exactly_once() { + let operation = FfiMediaOperation::new().unwrap(); + operation.claim().unwrap(); + let error = operation.claim().unwrap_err(); + assert_eq!(error.report().code, "media_operation_already_used"); + assert_eq!(operation.operation_id().len(), 32); + } + + #[test] + fn identity_commands_reject_fields_outside_the_selected_variant() { + let error = IdentityCommand::try_from(FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Lock, + operation_id: Some("unexpected".to_owned()), + identity_id: None, + public_key: None, + }) + .unwrap_err(); + assert_eq!(error.report().code, "invalid_identity_command"); + } +} diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -1,11 +1,19 @@ use std::sync::Arc; use radroots_mobile_core::runtime::product_surface::{ - LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1QueueIntent, - TodayPageRequest, phase1_new_addressable_identifier, phase1_operation_now_unix_ms, + LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1MediaCachePolicy, + Phase1QueueIntent, Phase1ReviseIntent, ReplaceMobileSettings, TodayPageRequest, + phase1_new_addressable_identifier, phase1_operation_now_unix_ms, }; use crate::dto::PreparedMedia; +use crate::operations::{ + FfiIdentityCommandRecord, FfiMediaCacheStatusRecord, FfiMediaOperation, + FfiMobileSettingsRecord, FfiProfileMetadataInputRecord, FfiProfileStatusRecord, + FfiReplaceSettingsRecord, FfiRevisionInputRecord, FfiRevisionStatusRecord, + FfiSettingsTransitionRecord, FfiVerifiedMediaArtifactRecord, decode_artifact_id, + decode_configuration, decode_reference_fingerprint, +}; use crate::signer::HostSignerAdapter; use crate::subscription::SubscriptionHub; use crate::{ @@ -757,6 +765,280 @@ impl RadrootsRuntime { .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); Ok(status.into()) } + + pub async fn phase1_settings(&self) -> Result<FfiMobileSettingsRecord, RadrootsAppError> { + self.inner + .phase1_settings() + .await + .map(|settings| (&settings).into()) + .map_err(Into::into) + } + + pub async fn phase1_replace_settings( + &self, + input: FfiReplaceSettingsRecord, + ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> { + let current = self.inner.phase1_settings().await?; + let expected_revision = input.expected_revision; + let next = input.apply(current)?; + let transition = self + .inner + .phase1_replace_settings(ReplaceMobileSettings::new(expected_revision, next)?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Settings, None); + Ok(transition.into()) + } + + pub async fn phase1_apply_identity_command( + &self, + expected_revision: u64, + command: FfiIdentityCommandRecord, + ) -> Result<FfiSettingsTransitionRecord, RadrootsAppError> { + let transition = self + .inner + .phase1_apply_identity_command(expected_revision, command.try_into()?) + .await?; + let identity_id = transition + .settings + .identity() + .active_identity_id() + .map(str::to_owned); + self.subscriptions + .notify(FfiRuntimeChangeKind::Identity, identity_id); + Ok(transition.into()) + } + + pub async fn phase1_save_profile_metadata( + &self, + input: FfiProfileMetadataInputRecord, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let status = self + .inner + .phase1_save_profile_metadata(input.command(blossom.as_ref())?) + .await?; + let operation_id = hex::encode(status.draft().draft_id().as_bytes()); + self.subscriptions + .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_profile_status( + &self, + operation_id: String, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + self.inner + .phase1_profile_status(decode_id(&operation_id, "invalid_operation_id")?) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_advance_profile( + &self, + operation_id: String, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_advance_profile(decode_id(&operation_id, "invalid_operation_id")?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_cancel_profile( + &self, + operation_id: String, + expected_revision: u64, + ) -> Result<FfiProfileStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_cancel_profile( + decode_id(&operation_id, "invalid_operation_id")?, + expected_revision, + ) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Profile, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_save_revision_intent( + &self, + mut input: FfiRevisionInputRecord, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + let target = input.target()?; + if input.replacement.identifier.is_none() + && matches!( + input.replacement.command_type, + crate::FfiAddCommandType::CreateEvent + | crate::FfiAddCommandType::CreateFoodAvailability + ) + { + input.replacement.identifier = Some(phase1_new_addressable_identifier()); + } + let authored_at_unix_s = phase1_operation_now_unix_ms()? / 1_000; + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = input + .replacement + .command_media_and_form(authored_at_unix_s, blossom.as_ref())?; + let status = self + .inner + .phase1_save_revision_intent(Phase1ReviseIntent::new(target, command, media, form)?) + .await?; + let operation_id = hex::encode(status.replacement().draft().draft_id().as_bytes()); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_revision_status( + &self, + operation_id: String, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + self.inner + .phase1_revision_status(decode_id(&operation_id, "invalid_operation_id")?) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_advance_revision( + &self, + operation_id: String, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_advance_revision(decode_id(&operation_id, "invalid_operation_id")?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_cancel_revision( + &self, + operation_id: String, + ) -> Result<FfiRevisionStatusRecord, RadrootsAppError> { + let status = self + .inner + .phase1_cancel_revision(decode_id(&operation_id, "invalid_operation_id")?) + .await?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(operation_id)); + Ok(status.into()) + } + + pub async fn phase1_retrieve_media( + &self, + context: FfiLocalNetworkRecord, + reference_fingerprint: String, + operation: Arc<FfiMediaOperation>, + ) -> Result<FfiVerifiedMediaArtifactRecord, RadrootsAppError> { + let context = self.local_network(context)?; + operation.claim()?; + let operation_id = operation.operation_id(); + let settings = self.inner.phase1_settings().await?; + let policy = Phase1MediaCachePolicy::new( + settings.local_storage().media_cache_bytes(), + settings.local_storage().media_cache_artifacts(), + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_cache_policy"))?; + let artifact = self + .inner + .phase1_retrieve_media( + &context, + decode_reference_fingerprint(&reference_fingerprint)?, + operation.id(), + policy, + operation.cancellation(), + ) + .await + .map_err(|error| { + RadrootsAppError::from(error).with_operation_id(operation_id.clone()) + })?; + self.subscriptions.notify( + FfiRuntimeChangeKind::Media, + Some(artifact.artifact_id().to_hex()), + ); + Ok(FfiVerifiedMediaArtifactRecord::from_artifact( + artifact, + Some(operation_id), + )) + } + + pub async fn phase1_verified_media_artifact( + &self, + context: FfiLocalNetworkRecord, + artifact_id: String, + ) -> Result<Option<FfiVerifiedMediaArtifactRecord>, RadrootsAppError> { + let context = self.local_network(context)?; + self.inner + .phase1_verified_media_artifact( + &context, + decode_artifact_id(&artifact_id)?, + phase1_operation_now_unix_ms()?, + ) + .await + .map(|value| { + value.map(|artifact| FfiVerifiedMediaArtifactRecord::from_artifact(artifact, None)) + }) + .map_err(Into::into) + } + + pub async fn phase1_media_cache_status( + &self, + context: FfiLocalNetworkRecord, + ) -> Result<FfiMediaCacheStatusRecord, RadrootsAppError> { + let context = self.local_network(context)?; + self.inner + .phase1_media_cache_status(&context) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn phase1_invalidate_media_artifact( + &self, + context: FfiLocalNetworkRecord, + artifact_id: String, + ) -> Result<bool, RadrootsAppError> { + let context = self.local_network(context)?; + let changed = self + .inner + .phase1_invalidate_media_artifact(&context, decode_artifact_id(&artifact_id)?) + .await?; + if changed { + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(artifact_id)); + } + Ok(changed) + } + + pub async fn phase1_invalidate_media_configuration( + &self, + context: FfiLocalNetworkRecord, + configuration_fingerprint: String, + ) -> Result<Vec<String>, RadrootsAppError> { + let context = self.local_network(context)?; + let removed = self + .inner + .phase1_invalidate_media_configuration( + &context, + decode_configuration(&configuration_fingerprint)?, + ) + .await?; + self.subscriptions.notify(FfiRuntimeChangeKind::Media, None); + Ok(removed.into_iter().map(|value| value.to_hex()).collect()) + } } impl RadrootsRuntime { diff --git a/core/crates/tera_ffi/src/subscription.rs b/core/crates/tera_ffi/src/subscription.rs @@ -15,6 +15,8 @@ const CHANGE_BUFFER_CAPACITY: usize = 16; pub enum FfiRuntimeChangeKind { Initial, Identity, + Settings, + Profile, Today, Drafts, Relay, diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -1,9 +1,10 @@ use radroots_mobile_ffi::{ FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, - FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord, - FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, - FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, - RadrootsAppError, + FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiIdentityCommandKind, + FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord, FfiOutboxState, + FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord, + FfiRelaySatisfaction, FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, + FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, }; mod support; @@ -425,6 +426,126 @@ async fn native_boundary_delegates_the_complete_core_surface() { .is_err() ); + let initial_settings = runtime.phase1_settings().await.expect("settings"); + let begun = runtime + .phase1_apply_identity_command( + initial_settings.revision, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::BeginImport, + operation_id: Some("import-ffi-1".to_owned()), + identity_id: None, + public_key: None, + }, + ) + .await + .expect("begin identity import"); + let completed = runtime + .phase1_apply_identity_command( + begun.settings.revision, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::CompleteImport, + operation_id: Some("import-ffi-1".to_owned()), + identity_id: Some("primary".to_owned()), + public_key: Some(support::PUBLIC_KEY.to_owned()), + }, + ) + .await + .expect("complete identity import"); + let unlocked = runtime + .phase1_apply_identity_command( + completed.settings.revision, + FfiIdentityCommandRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + kind: FfiIdentityCommandKind::Unlock, + operation_id: None, + identity_id: None, + public_key: None, + }, + ) + .await + .expect("record process-local unlock"); + assert_eq!( + unlocked.settings.identity.lock_state, + FfiIdentityLockState::Unlocked + ); + assert_eq!(unlocked.settings.revision, completed.settings.revision); + + let source_event_id = "ab".repeat(32); + let source = radroots_mobile_core::runtime::product_surface::CardSourceIdentity::Event( + radroots_event::EventId::parse(&source_event_id).expect("source event id"), + ); + let card_id = radroots_mobile_core::runtime::product_surface::CardId::derive( + radroots_mobile_core::runtime::product_surface::TodayCardType::Update, + &source, + ) + .to_hex(); + let revision = runtime + .phase1_save_revision_intent(FfiRevisionInputRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + card_id, + source_event_id, + source_kind: 1, + source_address: None, + author_public_key: support::PUBLIC_KEY.to_owned(), + replacement: FfiAddDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + content: "Corrected farm stand hours".to_owned(), + identifier: None, + title: None, + summary: None, + location: None, + event_timing: None, + event_start_date: None, + event_end_date: None, + event_start_unix_s: None, + event_end_unix_s: None, + event_timezone: None, + price_amount: None, + currency: None, + unit: None, + quantity: None, + food_published_at_unix_s: None, + food_status: None, + media: Vec::new(), + }, + }) + .await + .expect("save lossless revision intent"); + assert_eq!(revision.phase, FfiRevisionPhase::ReplacementPending); + assert_eq!(revision.operation_id, revision.replacement.draft_id); + let cancelled_revision = runtime + .phase1_cancel_revision(revision.operation_id.clone()) + .await + .expect("cancel revision intent"); + assert_eq!(cancelled_revision.operation_id, revision.operation_id); + assert_eq!(cancelled_revision.phase, FfiRevisionPhase::Cancelled); + + let profile = runtime + .phase1_save_profile_metadata(FfiProfileMetadataInputRecord { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + name: "grower".to_owned(), + display_name: Some("Local Grower".to_owned()), + about: Some("Seasonal produce".to_owned()), + picture: None, + banner: None, + nip05: Some("grower@farm.example".to_owned()), + bot: Some(false), + }) + .await + .expect("save profile intent"); + assert_eq!(profile.state, FfiOutboxState::Draft); + assert_eq!(profile.operation_id.len(), 32); + let cancelled_profile = runtime + .phase1_cancel_profile(profile.operation_id.clone(), profile.revision) + .await + .expect("cancel profile intent"); + assert_eq!(cancelled_profile.operation_id, profile.operation_id); + assert_eq!(cancelled_profile.state, FfiOutboxState::Cancelled); + runtime.shutdown().await.expect("shutdown"); assert!(matches!( runtime.sdk_storage_status().await, diff --git a/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_ffi/tests/uniffi_contract.rs @@ -1,8 +1,8 @@ use radroots_mobile_ffi::{ - FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiQueuePolicyRecord, - FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest, HostSigningResult, - MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner, - RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, + FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiMediaOperation, + FfiQueuePolicyRecord, FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest, + HostSigningResult, MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsAppError, + RadrootsHostSigner, RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord, }; use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; use std::sync::{Arc, Mutex}; @@ -76,6 +76,17 @@ fn swift_module_names_preserve_the_host_contract() { ); } +#[test] +fn media_cancellation_handle_owns_one_stable_opaque_operation_identity() { + let operation = FfiMediaOperation::new().expect("media operation"); + let operation_id = operation.operation_id(); + assert_eq!(operation_id.len(), 32); + assert!(!operation.is_cancelled()); + operation.cancel(); + assert!(operation.is_cancelled()); + assert_eq!(operation.operation_id(), operation_id); +} + #[tokio::test] async fn protected_data_failure_is_typed_and_opens_no_store() { let root = tempfile::tempdir().expect("tempdir");