commit 2a0bd61727ffade9a28a3927553ceb8c1faf38af
parent f97b76407d4b3ee1a5085b4d0855754952823e75
Author: triesap <tyson@radroots.org>
Date: Mon, 10 Aug 2026 18:27:17 +0000
feat: expose mobile product operations
Diffstat:
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");