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