lib

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

commit 828c462255b52a3d9bbfe8ef90b7bc38395bedfa
parent 2dea7bb3a84417e1ed8f35bb285c7d2228104cbd
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 18:27:17 +0000

feat: expose mobile product operations

Diffstat:
Mcrates/mobile_core/src/runtime/mod.rs | 4++++
Mcrates/mobile_core/src/runtime/product_surface.rs | 8++++----
Mcrates/mobile_core/src/runtime/product_surface/outbox.rs | 476+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/mobile_core/src/runtime/product_surface/settings.rs | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/mobile_core/src/runtime/product_surface/today.rs | 1+
Mcrates/mobile_ffi/src/dto.rs | 9+++++++++
Mcrates/mobile_ffi/src/error.rs | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/mobile_ffi/src/lib.rs | 2++
Acrates/mobile_ffi/src/operations.rs | 740+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mobile_ffi/src/runtime.rs | 286++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/mobile_ffi/src/subscription.rs | 2++
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/mobile_ffi/tests/uniffi_contract.rs | 19+++++++++++++++----
13 files changed, 1814 insertions(+), 27 deletions(-)

diff --git a/crates/mobile_core/src/runtime/mod.rs b/crates/mobile_core/src/runtime/mod.rs @@ -30,6 +30,8 @@ pub struct RadrootsRuntime { #[cfg(feature = "mobile-social")] pub(crate) settings_lock: tokio::sync::Mutex<()>, #[cfg(feature = "mobile-social")] + pub(crate) identity_session: tokio::sync::RwLock<Option<(u64, product_surface::IdentityState)>>, + #[cfg(feature = "mobile-social")] pub(crate) inbound_media_directory: Option<PathBuf>, #[cfg(feature = "mobile-social")] pub(crate) inbound_media_lock: tokio::sync::Mutex<()>, @@ -85,6 +87,8 @@ impl RadrootsRuntime { #[cfg(feature = "mobile-social")] settings_lock: tokio::sync::Mutex::new(()), #[cfg(feature = "mobile-social")] + identity_session: tokio::sync::RwLock::new(None), + #[cfg(feature = "mobile-social")] inbound_media_directory, #[cfg(feature = "mobile-social")] inbound_media_lock: tokio::sync::Mutex::new(()), diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -47,10 +47,10 @@ pub use outbox::{ Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, - Phase1OutboxState, Phase1QueueIntent, Phase1QueuePolicy, Phase1RelaySatisfaction, - Phase1ReviseIntent, Phase1RevisionPhase, Phase1RevisionPolicy, Phase1RevisionStatus, - Phase1RevisionTarget, Phase1UploadIntent, Phase1UploadPlan, phase1_new_addressable_identifier, - phase1_operation_now_unix_ms, + Phase1OutboxState, Phase1ProfileStatus, Phase1QueueIntent, Phase1QueuePolicy, + Phase1RelaySatisfaction, Phase1ReviseIntent, Phase1RevisionPhase, Phase1RevisionPolicy, + Phase1RevisionStatus, Phase1RevisionTarget, Phase1UploadIntent, Phase1UploadPlan, + phase1_new_addressable_identifier, phase1_new_operation_id, phase1_operation_now_unix_ms, }; pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; diff --git a/crates/mobile_core/src/runtime/product_surface/outbox.rs b/crates/mobile_core/src/runtime/product_surface/outbox.rs @@ -13,7 +13,7 @@ use radroots_event::{ AuthoredNip09DeletionRequest, Nip09DeletionAddressTarget, Nip09DeletionEventTarget, }, }; -use radroots_event_codec::authoring::PlanWireV1; +use radroots_event_codec::authoring::{AuthoredEventPlan, PlanWireV1}; use radroots_identity::PublicKey; use radroots_signing::{ Actor, AuthoredArtifactId, SigningIntentId, SigningOperationId, @@ -41,11 +41,12 @@ use thiserror::Error; use super::{ AddCommandType, CardId, CardSourceIdentity, LocalAuthorOverlay, LocalNetwork, Phase1AddCommand, - TodayCardType, TodayError, phase1_retraction_plan, + ProfileMetadataCommand, TodayCardType, TodayError, phase1_retraction_plan, }; use crate::runtime::RadrootsRuntime; const DRAFT_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-draft.v1"; +const PROFILE_PAYLOAD_SCHEMA: &str = "radroots.mobile.phase1-profile.v1"; const DRAFT_SCHEMA_VERSION: u16 = 1; const DRAFT_MEDIA_MAX: usize = 20; const DRAFT_LOCAL_REFERENCE_MAX_BYTES: usize = 4_096; @@ -746,6 +747,29 @@ pub struct Phase1RevisionStatus { phase: Phase1RevisionPhase, } +/// Durable kind-0 profile publication state. The profile fields remain inside +/// the canonical authored plan and are never duplicated in outbox metadata. +#[derive(Clone, Debug)] +pub struct Phase1ProfileStatus { + draft: AuthoredDraft, + state: Phase1OutboxState, + push: Option<PushStatus>, +} + +impl Phase1ProfileStatus { + pub const fn draft(&self) -> &AuthoredDraft { + &self.draft + } + + pub const fn state(&self) -> Phase1OutboxState { + self.state + } + + pub const fn push(&self) -> Option<&PushStatus> { + self.push.as_ref() + } +} + impl Phase1RevisionStatus { pub const fn replacement(&self) -> &Phase1DraftStatus { &self.replacement @@ -928,6 +952,61 @@ struct Phase1DraftPayload { revision: Option<Phase1RevisionRecord>, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct Phase1ProfilePayload { + schema_version: u16, + plan_wire_json: Vec<u8>, + queue: Option<Phase1QueuePolicy>, +} + +impl Phase1ProfilePayload { + fn new(plan_wire_json: Vec<u8>) -> Result<Self, Phase1DraftError> { + let value = Self { + schema_version: DRAFT_SCHEMA_VERSION, + plan_wire_json, + queue: None, + }; + value.validate()?; + Ok(value) + } + + fn validate(&self) -> Result<(), Phase1DraftError> { + if self.schema_version != DRAFT_SCHEMA_VERSION { + return Err(Phase1DraftError::Corrupt); + } + let plan = PlanWireV1::from_json(self.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().body().kind() != 0 { + return Err(Phase1DraftError::Corrupt); + } + if let Some(queue) = &self.queue { + queue.materialize()?; + } + Ok(()) + } + + fn decode(draft: &AuthoredDraft) -> Result<Self, Phase1DraftError> { + if draft.payload_schema() != PROFILE_PAYLOAD_SCHEMA { + return Err(Phase1DraftError::Corrupt); + } + let value = serde_json::from_slice::<Self>(draft.payload()) + .map_err(|_| Phase1DraftError::Corrupt)?; + value.validate()?; + let plan = PlanWireV1::from_json(value.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)?; + if plan.plan().author().as_bytes() != draft.author() { + return Err(Phase1DraftError::Corrupt); + } + Ok(value) + } + + fn encode(&self) -> Result<Vec<u8>, Phase1DraftError> { + self.validate()?; + serde_json::to_vec(self).map_err(|_| Phase1DraftError::InvalidDraft) + } +} + impl Phase1DraftPayload { fn new( command: &Phase1AddCommand, @@ -1067,6 +1146,181 @@ impl Phase1DraftPayload { } impl RadrootsRuntime { + /// Persists a strict kind-0 profile intent before any signing or network + /// side effect. Identity and timestamps remain Rust-owned. + pub async fn phase1_save_profile_metadata( + &self, + command: ProfileMetadataCommand, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let author = self.draft_author()?; + let draft_id = AuthoredDraftId::new(phase1_random_id()?) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = AuthoredEventPlan::from_profile( + command.authored(), + now_unix_ms / 1_000, + hex::encode(author), + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let payload = Phase1ProfilePayload::new(wire)?; + let draft = AuthoredDraft::initial( + draft_id, + author, + PROFILE_PAYLOAD_SCHEMA, + payload.encode()?, + AuthoredDraftStage::Draft, + None, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let receipt = self + .storage()? + .append_authored_draft(draft, None) + .await + .map_err(map_draft_storage_error)?; + self.profile_status_from(receipt.draft().clone()).await + } + + pub async fn phase1_profile_status( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + self.profile_status_from(head).await + } + + /// Recovers and advances one profile operation through the same durable + /// sign/admit/deliver engine used by Add without exposing queue policy. + pub async fn phase1_advance_profile( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let mut status = self.phase1_profile_status(draft_id).await?; + if status.draft.stage() == AuthoredDraftStage::Draft { + status = self + .phase1_queue_profile(draft_id, status.draft.revision().get()) + .await?; + } else if status.draft.stage() == AuthoredDraftStage::ReadyToSign { + status = self + .finish_profile_queue(status.draft.clone(), phase1_operation_now_unix_ms()?) + .await?; + } + if status.draft.stage() != AuthoredDraftStage::Queued + || matches!( + status.state, + Phase1OutboxState::Complete + | Phase1OutboxState::Terminal + | Phase1OutboxState::Cancelled + ) + { + return Ok(status); + } + let request = profile_push_request(&status.draft)?; + let operation_id = request.operation_id(); + let sync = self.sync()?; + let mut push = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + if matches!( + push.artifact().signing_state(), + SigningState::Planned | SigningState::Retryable + ) { + sync.sign_prepared(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + push = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if push.artifact().signing_state() == SigningState::Signed + && matches!( + push.artifact().admission_state(), + AdmissionState::Pending | AdmissionState::Retryable + ) + { + sync.admit_signed(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + push = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if push.artifact().admission_state().is_admitted() + && matches!( + push.delivery_plan().state(), + AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable + ) + { + sync.deliver_push(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + self.phase1_profile_status(draft_id).await + } + + pub async fn phase1_cancel_profile( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + Phase1ProfilePayload::decode(&head)?; + if head.stage() == AuthoredDraftStage::Cancelled { + return self.profile_status_from(head).await; + } + if head.revision() != expected { + return Err(Phase1DraftError::RevisionConflict); + } + let push = self.profile_push_status_for(&head).await?; + if let Some(status) = push { + if status.settlement().is_successful() { + return Err(Phase1DraftError::Terminal); + } + self.sync()? + .cancel_push(sync_id_for(&head)?) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + let next = head + .successor( + head.payload().to_vec(), + AuthoredDraftStage::Cancelled, + head.operation_id(), + phase1_operation_now_unix_ms()?, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next, Some(expected)) + .await + .map_err(map_draft_storage_error)?; + self.profile_status_from(receipt.draft().clone()).await + } + /// Persists one complete Add intent with Rust-owned identity and time. pub async fn phase1_save_add_intent( &self, @@ -1097,6 +1351,17 @@ impl RadrootsRuntime { intent: Phase1QueueIntent, ) -> Result<Phase1DraftStatus, Phase1DraftError> { let now_unix_ms = phase1_operation_now_unix_ms()?; + let policy = self.active_queue_policy(now_unix_ms)?; + self.phase1_queue_draft( + intent.draft_id, + intent.expected_revision, + policy, + now_unix_ms, + ) + .await + } + + fn active_queue_policy(&self, now_unix_ms: u64) -> Result<Phase1QueuePolicy, Phase1DraftError> { let report = self .client .nostr_status() @@ -1114,19 +1379,12 @@ impl RadrootsRuntime { let deadline = now_unix_ms .checked_add(ADD_DELIVERY_TIMEOUT_MS) .ok_or(Phase1DraftError::DeadlineOverflow)?; - let policy = Phase1QueuePolicy::new( + Phase1QueuePolicy::new( relay_urls, Phase1RelaySatisfaction::AllAccepted, deadline, Phase1CancellationPolicy::LocalCooperative, - )?; - self.phase1_queue_draft( - intent.draft_id, - intent.expected_revision, - policy, - now_unix_ms, ) - .await } /// Resumes a durable queue checkpoint with a Rust-owned recovery time. @@ -2249,6 +2507,112 @@ impl RadrootsRuntime { Ok(status) } + async fn phase1_queue_profile( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1ProfilePayload::decode(&head)?; + let ready = match head.stage() { + AuthoredDraftStage::Draft => { + payload.queue = Some(self.active_queue_policy(now_unix_ms)?); + let bytes = payload.encode()?; + let operation_id = operation_id(draft_id, bytes.as_slice())?; + let operation_id = OperationInstanceId::new(*operation_id.as_bytes()) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let ready = head + .successor( + bytes, + AuthoredDraftStage::ReadyToSign, + Some(operation_id), + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + storage + .append_authored_draft(ready.clone(), Some(expected)) + .await + .map_err(map_draft_storage_error)?; + ready + } + AuthoredDraftStage::ReadyToSign => head, + AuthoredDraftStage::Queued => return self.profile_status_from(head).await, + AuthoredDraftStage::Cancelled => return Err(Phase1DraftError::Terminal), + AuthoredDraftStage::MediaPreparing | AuthoredDraftStage::MediaUploading => { + return Err(Phase1DraftError::Corrupt); + } + }; + self.finish_profile_queue(ready, now_unix_ms).await + } + + async fn finish_profile_queue( + &self, + ready: AuthoredDraft, + queued_at_unix_ms: u64, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + let request = profile_push_request(&ready)?; + self.sync()? + .prepare_push(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + let queued = ready + .successor( + ready.payload().to_vec(), + AuthoredDraftStage::Queued, + ready.operation_id(), + queued_at_unix_ms.max(ready.updated_at_unix_ms()), + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = self + .storage()? + .append_authored_draft(queued, Some(ready.revision())) + .await + .map_err(map_draft_storage_error)?; + self.profile_status_from(receipt.draft().clone()).await + } + + async fn profile_status_from( + &self, + draft: AuthoredDraft, + ) -> Result<Phase1ProfileStatus, Phase1DraftError> { + if draft.author() != &self.draft_author()? { + return Err(Phase1DraftError::Corrupt); + } + Phase1ProfilePayload::decode(&draft)?; + let push = self.profile_push_status_for(&draft).await?; + if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { + return Err(Phase1DraftError::Corrupt); + } + let state = aggregate_state(&draft, push.as_ref()); + Ok(Phase1ProfileStatus { draft, state, push }) + } + + async fn profile_push_status_for( + &self, + draft: &AuthoredDraft, + ) -> Result<Option<PushStatus>, Phase1DraftError> { + let Some(_) = draft.operation_id() else { + return Ok(None); + }; + self.sync()? + .push_status(sync_id_for(draft)?) + .await + .map_err(|_| Phase1DraftError::Operation) + } + async fn finish_queue( &self, ready: AuthoredDraft, @@ -2370,6 +2734,36 @@ fn push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> .map_err(|_| Phase1DraftError::InvalidQueuePolicy) } +fn profile_push_request(draft: &AuthoredDraft) -> Result<PushRequest, Phase1DraftError> { + let payload = Phase1ProfilePayload::decode(draft)?; + let policy = payload.queue.ok_or(Phase1DraftError::InvalidQueuePolicy)?; + let (targets, satisfaction, cancellation) = policy.materialize()?; + let plan = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) + .map_err(|_| Phase1DraftError::Corrupt)? + .into_plan(); + let public_key = PublicKey::from_bytes(*draft.author()) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let actor = Actor::new(public_key, ActorSource::ExplicitPublicKey, AuthorRole::ALL) + .map_err(|_| Phase1DraftError::IdentityUnavailable)?; + let sync_id = sync_id_for(draft)?; + let idempotency = IdempotencyKey::parse(format!( + "phase1-profile-{}", + hex::encode(draft.draft_id().as_bytes()) + )) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + PushRequest::new( + sync_id, + idempotency, + actor, + plan, + targets, + satisfaction, + policy.delivery_deadline_unix_ms, + cancellation, + ) + .map_err(|_| Phase1DraftError::InvalidQueuePolicy) +} + fn operation_id( draft_id: AuthoredDraftId, ready_payload: &[u8], @@ -2690,6 +3084,12 @@ pub fn phase1_new_addressable_identifier() -> String { uuid::Uuid::new_v4().simple().to_string() } +/// Generates one opaque operation identity for host-visible cancellation and +/// receipt correlation without delegating identity policy to the host. +pub fn phase1_new_operation_id() -> Result<[u8; 16], Phase1DraftError> { + phase1_random_id() +} + fn phase1_random_id() -> Result<[u8; 16], Phase1DraftError> { let value = *uuid::Uuid::new_v4().as_bytes(); if value.iter().all(|byte| *byte == 0) { @@ -2879,6 +3279,62 @@ mod tests { } #[tokio::test] + async fn profile_metadata_uses_the_durable_outbox_with_stable_operation_identity() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [( + "wss://write.example", + radroots_sdk::transport::RelayAccess::ReadWrite, + )], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_profile_metadata( + ProfileMetadataCommand::new( + "grower".to_owned(), + Some("Local Grower".to_owned()), + Some("Seasonal produce".to_owned()), + None, + None, + Some("grower@farm.example".to_owned()), + Some(false), + ) + .unwrap(), + ) + .await + .unwrap(); + let operation_id = *saved.draft().draft_id().as_bytes(); + assert_eq!(saved.state(), Phase1OutboxState::Draft); + assert_eq!( + PlanWireV1::from_json( + Phase1ProfilePayload::decode(saved.draft()) + .unwrap() + .plan_wire_json + .as_slice(), + ) + .unwrap() + .plan() + .body() + .kind(), + 0 + ); + + let queued = runtime + .phase1_queue_profile(operation_id, saved.draft().revision().get()) + .await + .unwrap(); + assert_eq!(queued.state(), Phase1OutboxState::Queued); + assert_eq!(*queued.draft().draft_id().as_bytes(), operation_id); + let cancelled = runtime + .phase1_cancel_profile(operation_id, queued.draft().revision().get()) + .await + .unwrap(); + assert_eq!(cancelled.state(), Phase1OutboxState::Cancelled); + assert_eq!(*cancelled.draft().draft_id().as_bytes(), operation_id); + } + + #[tokio::test] async fn add_queue_intent_fails_closed_without_a_writable_relay() { let profile = radroots_sdk::transport::RelayProfile::explicit( radroots_sdk::transport::RelayProfileKind::Public, diff --git a/crates/mobile_core/src/runtime/product_surface/settings.rs b/crates/mobile_core/src/runtime/product_surface/settings.rs @@ -887,7 +887,14 @@ impl SettingsError { impl RadrootsRuntime { pub async fn phase1_settings(&self) -> Result<MobileSettings, SettingsError> { let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; - load_settings(storage).await + let mut settings = load_settings(storage).await?; + let session = self.identity_session.read().await; + if let Some((revision, identity)) = session.as_ref() + && *revision == settings.revision + { + settings.identity = identity.clone(); + } + Ok(settings) } pub async fn phase1_replace_settings( @@ -896,7 +903,45 @@ impl RadrootsRuntime { ) -> Result<SettingsTransition, SettingsError> { let _guard = self.settings_lock.lock().await; let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; - replace_settings(storage, command).await + let transition = replace_settings(storage, command).await?; + *self.identity_session.write().await = None; + Ok(transition) + } + + /// Applies one secret-free identity transition atomically against the + /// current settings revision. Unlock evidence is process-local: it is + /// observable for the current runtime but never written to durable state. + pub async fn phase1_apply_identity_command( + &self, + expected_revision: u64, + command: IdentityCommand, + ) -> Result<SettingsTransition, SettingsError> { + let _guard = self.settings_lock.lock().await; + let storage = self.client.storage().map_err(|_| SettingsError::Storage)?; + let mut prior = load_settings(storage).await?; + if prior.revision != expected_revision { + return Err(SettingsError::RevisionConflict); + } + if let Some((revision, identity)) = self.identity_session.read().await.as_ref() + && *revision == prior.revision + { + prior.identity = identity.clone(); + } + let next_identity = prior.identity.apply(command.clone())?; + if matches!(command, IdentityCommand::Unlock) { + *self.identity_session.write().await = Some((prior.revision, next_identity.clone())); + return Ok(SettingsTransition { + settings: prior.with_identity(next_identity), + runtime_restart_required: false, + outbox_requeue_required: false, + media_cache_invalidation_required: false, + }); + } + let command = + ReplaceMobileSettings::new(prior.revision, prior.with_identity(next_identity))?; + let transition = replace_settings(storage, command).await?; + *self.identity_session.write().await = None; + Ok(transition) } } @@ -1459,6 +1504,74 @@ mod tests { } #[tokio::test] + async fn atomic_identity_commands_persist_public_state_but_keep_unlock_process_local() { + let runtime = RadrootsRuntime::test_memory().unwrap(); + let begun = runtime + .phase1_apply_identity_command( + 1, + IdentityCommand::BeginImport { + operation_id: "import-1".to_owned(), + }, + ) + .await + .unwrap(); + assert_eq!(begun.settings.revision(), 2); + assert_eq!( + begun.settings.identity().pending_import_operation_id(), + Some("import-1") + ); + + let completed = runtime + .phase1_apply_identity_command( + 2, + IdentityCommand::CompleteImport { + operation_id: "import-1".to_owned(), + identity: IdentityRecord::new( + "primary", + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + }, + ) + .await + .unwrap(); + assert_eq!(completed.settings.revision(), 3); + assert_eq!( + completed.settings.identity().active_identity_id(), + Some("primary") + ); + + let unlocked = runtime + .phase1_apply_identity_command(3, IdentityCommand::Unlock) + .await + .unwrap(); + assert_eq!(unlocked.settings.revision(), 3); + assert_eq!( + unlocked.settings.identity().lock_state(), + IdentityLockState::Unlocked + ); + assert_eq!( + runtime + .phase1_settings() + .await + .unwrap() + .identity() + .lock_state(), + IdentityLockState::Unlocked + ); + + let locked = runtime + .phase1_apply_identity_command(3, IdentityCommand::Lock) + .await + .unwrap(); + assert_eq!(locked.settings.revision(), 4); + assert_eq!( + locked.settings.identity().lock_state(), + IdentityLockState::Locked + ); + } + + #[tokio::test] async fn concurrent_replacements_cannot_both_commit_the_same_revision() { let runtime = RadrootsRuntime::test_memory().unwrap(); let settings = runtime.phase1_settings().await.unwrap(); diff --git a/crates/mobile_core/src/runtime/product_surface/today.rs b/crates/mobile_core/src/runtime/product_surface/today.rs @@ -2408,6 +2408,7 @@ mod tests { platform_app: RwLock::new(None), store_public_key: None, settings_lock: tokio::sync::Mutex::new(()), + identity_session: tokio::sync::RwLock::new(None), inbound_media_directory: None, inbound_media_lock: tokio::sync::Mutex::new(()), }; diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_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/crates/mobile_ffi/src/error.rs b/crates/mobile_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/crates/mobile_ffi/src/lib.rs b/crates/mobile_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/crates/mobile_ffi/src/operations.rs b/crates/mobile_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/crates/mobile_ffi/src/runtime.rs b/crates/mobile_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/crates/mobile_ffi/src/subscription.rs b/crates/mobile_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/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_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/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_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");