lib

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

commit d5baa12725cec1737b800f1cf9cf9357c08713e5
parent cf8e7e283c5057105b93367a8f29a0548f3f1d88
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 16:35:42 +0000

feat: own add queue and upload policy

- derive draft identifiers and policy timestamps inside mobile core
- select writable relays and settlement from the active typed profile
- expose intent-only save queue recovery upload and cancellation calls
- verify all Add variants and the UniFFI delegation boundary

Diffstat:
MCargo.lock | 1+
Mcrates/mobile_core/Cargo.toml | 1+
Mcrates/mobile_core/src/runtime/product_surface.rs | 10++++++----
Mcrates/mobile_core/src/runtime/product_surface/outbox.rs | 427++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/mobile_ffi/src/dto.rs | 32+++++++++++++++++++++++++++++---
Mcrates/mobile_ffi/src/error.rs | 9+++++++++
Mcrates/mobile_ffi/src/runtime.rs | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 45++++++++++-----------------------------------
8 files changed, 618 insertions(+), 52 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3599,6 +3599,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "uuid", ] [[package]] diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -49,6 +49,7 @@ serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true, features = ["sync"] } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] nostr = { workspace = true, features = ["std"] } diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -36,10 +36,12 @@ pub use model::{ }; #[cfg(feature = "mobile-social")] pub use outbox::{ - Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, Phase1DraftFormSnapshot, - Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1MediaOrphanRecord, - Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, - Phase1RelaySatisfaction, + Phase1AddIntent, Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, + Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, + Phase1ExistingDraft, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, + Phase1OutboxState, Phase1QueueIntent, Phase1QueuePolicy, Phase1RelaySatisfaction, + Phase1UploadIntent, Phase1UploadPlan, phase1_new_addressable_identifier, + 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 @@ -1,4 +1,8 @@ -use std::collections::BTreeSet; +use std::{ + collections::BTreeSet, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use radroots_blossom::{ BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim, @@ -47,6 +51,11 @@ const DRAFT_MEDIA_MAX: usize = 20; const DRAFT_LOCAL_REFERENCE_MAX_BYTES: usize = 4_096; const DRAFT_FAILURE_CODE_MAX_BYTES: usize = 96; const DRAFT_OPERATION_DOMAIN: &[u8] = b"radroots.mobile.phase1-draft-operation.v1\0"; +const ADD_DELIVERY_TIMEOUT_MS: u64 = 24 * 60 * 60 * 1_000; +const BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS: u64 = 5; +const BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS: u64 = 5 * 60; +const BLOSSOM_SIGNING_TIMEOUT_MS: u64 = 60 * 1_000; +const BLOSSOM_AUTHORIZATION_CONTENT: &str = "Upload exact Radroots image"; /// Product intent represented by one durable draft/outbox item. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -427,6 +436,151 @@ impl Phase1QueuePolicy { } } +/// Existing durable draft selected for an optimistic replacement. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1ExistingDraft { + draft_id: [u8; 16], + expected_revision: u64, +} + +impl Phase1ExistingDraft { + pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + Ok(Self { + draft_id, + expected_revision, + }) + } +} + +/// One typed Add save intent. Rust supplies the creation identifier and all +/// policy timestamps; a caller can only name an existing revision to replace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1AddIntent { + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + existing: Option<Phase1ExistingDraft>, +} + +impl Phase1AddIntent { + pub fn new( + command: Phase1AddCommand, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + existing: Option<Phase1ExistingDraft>, + ) -> Result<Self, Phase1DraftError> { + if media.len() > DRAFT_MEDIA_MAX { + return Err(Phase1DraftError::InvalidMedia); + } + form.validate(command.command_type(), &media)?; + Ok(Self { + command, + media, + form, + existing, + }) + } +} + +/// Minimal queue intent. Relay selection, settlement, deadline, and +/// cancellation are derived from the active typed Rust transport profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Phase1QueueIntent { + draft_id: [u8; 16], + expected_revision: u64, +} + +impl Phase1QueueIntent { + pub fn new(draft_id: [u8; 16], expected_revision: u64) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + Ok(Self { + draft_id, + expected_revision, + }) + } +} + +/// Bounded exact-byte input for one Rust-planned Blossom upload attempt. +#[derive(Clone)] +pub struct Phase1UploadIntent { + draft_id: [u8; 16], + expected_revision: u64, + bytes: Arc<[u8]>, + media_type: MediaType, + dimensions: radroots_sdk::transport::BlossomImageDimensions, +} + +impl Phase1UploadIntent { + pub fn new( + draft_id: [u8; 16], + expected_revision: u64, + bytes: Arc<[u8]>, + media_type: MediaType, + width: u32, + height: u32, + ) -> Result<Self, Phase1DraftError> { + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + if bytes.is_empty() { + return Err(Phase1DraftError::InvalidMedia); + } + let dimensions = radroots_sdk::transport::BlossomImageDimensions::new(width, height) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + Ok(Self { + draft_id, + expected_revision, + bytes, + media_type, + dimensions, + }) + } +} + +/// Immutable Rust-derived policy for one upload attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Phase1UploadPlan { + pub authorization_content: String, + pub authorization_created_at_unix_s: u64, + pub authorization_lifetime_seconds: u64, + pub operation_id: [u8; 16], + pub artifact_id: [u8; 16], + pub signing_deadline_unix_ms: u64, + pub cancellation: Phase1CancellationPolicy, + pub updated_at_unix_ms: u64, +} + +impl Phase1UploadPlan { + fn derive( + now_unix_ms: u64, + operation_id: [u8; 16], + artifact_id: [u8; 16], + ) -> Result<Self, Phase1DraftError> { + let now_unix_s = now_unix_ms / 1_000; + if now_unix_s == 0 { + return Err(Phase1DraftError::ClockUnavailable); + } + Ok(Self { + authorization_content: BLOSSOM_AUTHORIZATION_CONTENT.to_owned(), + authorization_created_at_unix_s: now_unix_s + .saturating_sub(BLOSSOM_AUTHORIZATION_BACKDATE_SECONDS), + authorization_lifetime_seconds: BLOSSOM_AUTHORIZATION_LIFETIME_SECONDS, + operation_id, + artifact_id, + signing_deadline_unix_ms: now_unix_ms + .checked_add(BLOSSOM_SIGNING_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?, + cancellation: Phase1CancellationPolicy::LocalCooperative, + updated_at_unix_ms: now_unix_ms, + }) + } +} + /// Honest aggregate state for a local draft and its durable authored operation. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Phase1OutboxState { @@ -533,6 +687,12 @@ pub enum Phase1DraftError { Operation, #[error("phase 1 Today overlay failed")] Overlay, + #[error("phase 1 operation clock is unavailable")] + ClockUnavailable, + #[error("phase 1 operation deadline overflowed")] + DeadlineOverflow, + #[error("no configured relay authorizes publication")] + NoWritableRelay, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -653,6 +813,123 @@ impl Phase1DraftPayload { } impl RadrootsRuntime { + /// Persists one complete Add intent with Rust-owned identity and time. + pub async fn phase1_save_add_intent( + &self, + intent: Phase1AddIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let authored_at_unix_s = now_unix_ms / 1_000; + let (draft_id, expected_revision) = match intent.existing { + Some(existing) => (existing.draft_id, Some(existing.expected_revision)), + None => (phase1_random_id()?, None), + }; + self.phase1_save_draft_with_form( + draft_id, + intent.command, + authored_at_unix_s, + intent.media, + intent.form, + expected_revision, + now_unix_ms, + ) + .await + } + + /// Freezes the canonical Rust-owned queue policy for the active relay + /// profile before preparing the durable outbox operation. + pub async fn phase1_queue_add_intent( + &self, + intent: Phase1QueueIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let report = self + .client + .nostr_status() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let relay_urls = report + .relays() + .iter() + .filter(|relay| relay.endpoint().access().can_write()) + .map(|relay| relay.endpoint().url().as_str().to_owned()) + .collect::<Vec<_>>(); + if relay_urls.is_empty() { + return Err(Phase1DraftError::NoWritableRelay); + } + let deadline = now_unix_ms + .checked_add(ADD_DELIVERY_TIMEOUT_MS) + .ok_or(Phase1DraftError::DeadlineOverflow)?; + let policy = 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. + pub async fn phase1_recover_add_intent( + &self, + draft_id: [u8; 16], + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_recover_draft_queue(draft_id, phase1_operation_now_unix_ms()?) + .await + } + + /// Plans authorization, signing, and state timestamps in Rust, then runs + /// one complete exact-byte upload attempt. + pub async fn phase1_upload_add_media_intent( + &self, + intent: Phase1UploadIntent, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let now_unix_ms = phase1_operation_now_unix_ms()?; + let plan = Phase1UploadPlan::derive(now_unix_ms, phase1_random_id()?, phase1_random_id()?)?; + let request = radroots_sdk::transport::BlossomUploadRequest::new( + intent.bytes, + intent.media_type, + intent.dimensions, + now_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + let content = radroots_blossom::authorization::AuthorizationContent::parse( + &plan.authorization_content, + ) + .map_err(|_| Phase1DraftError::InvalidMedia)?; + self.phase1_upload_draft_media( + intent.draft_id, + intent.expected_revision, + request, + content, + plan.authorization_created_at_unix_s, + plan.authorization_lifetime_seconds, + plan.operation_id, + plan.artifact_id, + plan.signing_deadline_unix_ms, + plan.cancellation, + radroots_sdk::transport::BlossomCancellation::default(), + plan.updated_at_unix_ms, + ) + .await + } + + /// Cancels local work with a Rust-owned transition timestamp. + pub async fn phase1_cancel_add_intent( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_cancel_draft(draft_id, expected_revision, phase1_operation_now_unix_ms()?) + .await + } + /// Creates or replaces the editable content of one immutable-revision draft. #[allow(clippy::too_many_arguments)] pub async fn phase1_save_draft( @@ -1830,6 +2107,29 @@ fn map_overlay_error(_: TodayError) -> Phase1DraftError { Phase1DraftError::Overlay } +/// Captures the canonical wall-clock input for Rust-owned Phase 1 policy. +pub fn phase1_operation_now_unix_ms() -> Result<u64, Phase1DraftError> { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| u64::try_from(duration.as_millis()).ok()) + .filter(|value| *value >= 1_000) + .ok_or(Phase1DraftError::ClockUnavailable) +} + +/// Generates a canonical public identifier for an addressable Add form. +pub fn phase1_new_addressable_identifier() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +fn phase1_random_id() -> Result<[u8; 16], Phase1DraftError> { + let value = *uuid::Uuid::new_v4().as_bytes(); + if value.iter().all(|byte| *byte == 0) { + return Err(Phase1DraftError::OperationUnavailable); + } + Ok(value) +} + #[cfg(test)] mod tests { use super::*; @@ -1864,6 +2164,17 @@ mod tests { .unwrap() } + fn profiled_runtime(profile: radroots_sdk::transport::RelayProfile) -> RadrootsRuntime { + RadrootsRuntime::from_client_builder( + ClientBuilder::memory_default(), + Some(PublicKey::from_hex(AUTHOR).unwrap()), + None, + Some(profile), + None, + ) + .unwrap() + } + fn signing_runtime() -> RadrootsRuntime { let signer = radroots_nostr::signing::LocalSigner::new( radroots_nostr::key::SecretKey::parse(SECRET).unwrap(), @@ -1916,6 +2227,120 @@ mod tests { } } + #[test] + fn upload_policy_derivation_is_exact_and_bounded() { + let plan = Phase1UploadPlan::derive(1_800_000_000_000, [7; 16], [8; 16]).unwrap(); + assert_eq!(plan.authorization_content, BLOSSOM_AUTHORIZATION_CONTENT); + assert_eq!(plan.authorization_created_at_unix_s, 1_799_999_995); + assert_eq!(plan.authorization_lifetime_seconds, 300); + assert_eq!(plan.operation_id, [7; 16]); + assert_eq!(plan.artifact_id, [8; 16]); + assert_eq!(plan.signing_deadline_unix_ms, 1_800_000_060_000); + assert_eq!( + plan.cancellation, + Phase1CancellationPolicy::LocalCooperative + ); + assert_eq!(plan.updated_at_unix_ms, 1_800_000_000_000); + assert_eq!( + Phase1UploadPlan::derive(u64::MAX, [7; 16], [8; 16]).unwrap_err(), + Phase1DraftError::DeadlineOverflow + ); + } + + #[tokio::test] + async fn add_intent_owns_draft_identity_time_and_writable_relay_policy() { + let profile = radroots_sdk::transport::RelayProfile::explicit( + radroots_sdk::transport::RelayProfileKind::Public, + [ + ( + "wss://read.example", + radroots_sdk::transport::RelayAccess::ReadOnly, + ), + ( + "wss://write.example", + radroots_sdk::transport::RelayAccess::ReadWrite, + ), + ], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_add_intent( + Phase1AddIntent::new( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + update_form(), + None, + ) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(saved.draft().draft_id().as_bytes(), &[0; 16]); + assert!(saved.draft().created_at_unix_ms() >= 1_700_000_000_000); + + let queued = runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + *saved.draft().draft_id().as_bytes(), + saved.draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap(); + let payload = Phase1DraftPayload::decode(queued.draft()).unwrap(); + let queue = payload.queue.unwrap(); + assert_eq!(queue.relay_urls, vec!["wss://write.example"]); + assert_eq!(queue.satisfaction, Phase1RelaySatisfaction::AllAccepted); + assert_eq!( + queue.cancellation, + Phase1CancellationPolicy::LocalCooperative + ); + assert!( + queue.delivery_deadline_unix_ms + >= queued.draft().updated_at_unix_ms() + ADD_DELIVERY_TIMEOUT_MS + ); + } + + #[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, + [( + "wss://read.example", + radroots_sdk::transport::RelayAccess::ReadOnly, + )], + ) + .unwrap(); + let runtime = profiled_runtime(profile); + let saved = runtime + .phase1_save_add_intent( + Phase1AddIntent::new( + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Vec::new(), + update_form(), + None, + ) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + runtime + .phase1_queue_add_intent( + Phase1QueueIntent::new( + *saved.draft().draft_id().as_bytes(), + saved.draft().revision().get(), + ) + .unwrap(), + ) + .await + .unwrap_err(), + Phase1DraftError::NoWritableRelay + ); + } + #[tokio::test] async fn form_snapshots_reopen_exactly_and_freeze_after_queue() { let runtime = runtime(); diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs @@ -25,9 +25,10 @@ use radroots_mobile_core::runtime::{ MediaReference, MediaVerificationState, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftEventTiming, Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, - Phase1QueuePolicy, Phase1RelaySatisfaction, ProfileSummary, SearchResult, SearchResultType, - SupportingProfile, ThreadEntry, TodayCard, TodayCardType, TodayPage, TodayProjectionUpdate, - TodayRefreshReceipt, TodayRelaySyncState, TodaySyncReceipt, + Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary, + SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType, + TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, TodayRelaySyncState, + TodaySyncReceipt, }, sdk::{ SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord, @@ -902,6 +903,15 @@ pub struct FfiBlossomUploadInput { pub updated_at_unix_ms: u64, } +/// Minimal host input for a Rust-planned exact-byte upload attempt. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiBlossomUploadIntent { + pub schema_version: u16, + pub draft_id: String, + pub expected_revision: u64, + pub media: FfiPreparedMediaInput, +} + impl FfiAddDraftInput { pub(crate) fn command_and_media( self, @@ -1154,6 +1164,22 @@ fn read_media_file_descriptor( } impl PreparedMedia { + pub(crate) fn into_upload_intent( + self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1UploadIntent, RadrootsAppError> { + Phase1UploadIntent::new( + draft_id, + expected_revision, + self.bytes, + self.media_type, + self.width, + self.height, + ) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload")) + } + pub(crate) fn upload_request( &self, verified_at_unix_ms: u64, diff --git a/crates/mobile_ffi/src/error.rs b/crates/mobile_ffi/src/error.rs @@ -191,6 +191,15 @@ impl From<Phase1DraftError> for RadrootsAppError { ("authoring_failed", true, &["retry", "inspect_outbox"][..]) } Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]), + Phase1DraftError::ClockUnavailable => { + ("operation_clock_unavailable", true, &["retry"][..]) + } + Phase1DraftError::DeadlineOverflow => ("operation_deadline_overflow", false, &[][..]), + Phase1DraftError::NoWritableRelay => ( + "writable_relay_unavailable", + true, + &["configure_relay", "retry"][..], + ), }; Self::failure( code, diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs @@ -1,7 +1,9 @@ use std::sync::Arc; -use radroots_mobile_core::runtime::product_surface::LocalNetworkRelayPolicy; -use radroots_mobile_core::runtime::product_surface::TodayPageRequest; +use radroots_mobile_core::runtime::product_surface::{ + LocalNetworkRelayPolicy, Phase1AddIntent, Phase1ExistingDraft, Phase1QueueIntent, + TodayPageRequest, phase1_new_addressable_identifier, phase1_operation_now_unix_ms, +}; use crate::dto::PreparedMedia; use crate::signer::HostSignerAdapter; @@ -9,13 +11,13 @@ use crate::subscription::SubscriptionHub; use crate::{ FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomConfigurationRecord, FfiBlossomEndpointAuthority, FfiBlossomEvidenceRecord, FfiBlossomHostKind, - FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord, - FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, FfiQueuePolicyRecord, - FfiRelayStatusReportRecord, FfiRetractionDraftInput, FfiRuntimeChangeKind, - FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, FfiStorageStatusRecord, - FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, FfiTodayRefreshRecord, - FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, RadrootsRuntimeObserver, add_schemas, - decode_id, + FfiBlossomUploadInput, FfiBlossomUploadIntent, FfiCapabilityRecord, FfiCardAddParityRecord, + FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, FfiMeRecord, + FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRetractionDraftInput, + FfiRuntimeChangeKind, FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, + FfiStorageStatusRecord, FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, + FfiTodayRefreshRecord, FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, + RadrootsRuntimeObserver, add_schemas, decode_id, }; #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] @@ -391,6 +393,55 @@ impl RadrootsRuntime { .map(|_| ()) } + /// Saves one new or existing Add form while Rust owns all identity and + /// timestamp policy. Addressable identifiers are generated when omitted. + pub async fn phase1_save_add_intent( + &self, + mut input: FfiAddDraftInput, + existing_draft_id: Option<String>, + expected_revision: Option<u64>, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.identifier.is_none() + && matches!( + input.command_type, + crate::FfiAddCommandType::CreateEvent + | crate::FfiAddCommandType::CreateFoodAvailability + ) + { + input.identifier = Some(phase1_new_addressable_identifier()); + } + let authored_at_unix_s = + phase1_operation_now_unix_ms().map_err(RadrootsAppError::from)? / 1_000; + let blossom = self + .inner + .sdk_blossom_slot() + .map_err(RadrootsAppError::from)?; + let (command, media, form) = + input.command_media_and_form(authored_at_unix_s, blossom.as_ref())?; + let existing = match (existing_draft_id, expected_revision) { + (Some(draft_id), Some(revision)) => Some( + Phase1ExistingDraft::new(decode_id(&draft_id, "invalid_draft_id")?, revision) + .map_err(RadrootsAppError::from)?, + ), + (None, None) => None, + _ => { + return Err(RadrootsAppError::invalid_argument("invalid_existing_draft")); + } + }; + let status = self + .inner + .phase1_save_add_intent( + Phase1AddIntent::new(command, media, form, existing) + .map_err(RadrootsAppError::from)?, + ) + .await + .map_err(RadrootsAppError::from)?; + let draft_id = hex::encode(status.draft().draft_id().as_bytes()); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + #[allow(clippy::too_many_arguments)] pub async fn phase1_save_draft( &self, @@ -523,6 +574,25 @@ impl RadrootsRuntime { Ok(status.into()) } + /// Queues with the Rust-owned active relay and settlement policy. + pub async fn phase1_queue_add_intent( + &self, + draft_id: String, + expected_revision: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let intent = Phase1QueueIntent::new(decoded_id, expected_revision) + .map_err(RadrootsAppError::from)?; + let status = self + .inner + .phase1_queue_add_intent(intent) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + pub async fn phase1_recover_draft_queue( &self, draft_id: String, @@ -539,6 +609,21 @@ impl RadrootsRuntime { Ok(status.into()) } + pub async fn phase1_recover_add_intent( + &self, + draft_id: String, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_recover_add_intent(decoded_id) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + pub async fn phase1_sign_queued_draft( &self, draft_id: String, @@ -614,6 +699,32 @@ impl RadrootsRuntime { Ok(status.into()) } + /// Runs a Rust-planned BUD-11/BUD-02/BUD-01 upload attempt. The host + /// supplies only the selected bounded file handle and draft revision. + pub async fn phase1_upload_add_media_intent( + &self, + input: FfiBlossomUploadIntent, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { + return Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )); + } + let draft_id = decode_id(&input.draft_id, "invalid_draft_id")?; + let intent = PreparedMedia::try_from(input.media)? + .into_upload_intent(draft_id, input.expected_revision)?; + let status = self + .inner + .phase1_upload_add_media_intent(intent) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Media, Some(input.draft_id.clone())); + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(input.draft_id)); + Ok(status.into()) + } + pub async fn phase1_cancel_draft( &self, draft_id: String, @@ -630,6 +741,22 @@ impl RadrootsRuntime { .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); Ok(status.into()) } + + pub async fn phase1_cancel_add_intent( + &self, + draft_id: String, + expected_revision: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let status = self + .inner + .phase1_cancel_add_intent(decoded_id, expected_revision) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } } impl RadrootsRuntime { diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs @@ -1,6 +1,6 @@ use radroots_mobile_ffi::{ FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind, - FfiBlossomUploadInput, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord, + FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, @@ -291,17 +291,12 @@ async fn native_boundary_delegates_the_complete_core_surface() { runtime .phase1_validate_add_draft(add.clone(), 1_800_000_001) .expect("valid draft"); - let draft_id = "07".repeat(16); let saved = runtime - .phase1_save_draft( - draft_id.clone(), - add, - 1_800_000_001, - None, - 1_800_000_001_000, - ) + .phase1_save_add_intent(add, None, None) .await .expect("saved draft"); + let draft_id = saved.draft_id.clone(); + assert_eq!(draft_id.len(), 32); assert_eq!(saved.state, FfiOutboxState::Draft); assert_eq!(saved.kind, FfiDraftKind::Add); assert_eq!( @@ -325,28 +320,17 @@ async fn native_boundary_delegates_the_complete_core_surface() { 1 ); let queued = runtime - .phase1_queue_draft( - draft_id.clone(), - saved.revision, - FfiQueuePolicyRecord { - schema_version: MOBILE_FFI_SCHEMA_VERSION, - relay_urls: vec!["wss://write.example".to_owned()], - satisfaction: FfiRelaySatisfaction::AllAccepted, - delivery_deadline_unix_ms: 1_800_100_000_000, - cancellation: FfiCancellationPolicy::LocalCooperative, - }, - 1_800_000_002_000, - ) + .phase1_queue_add_intent(draft_id.clone(), saved.revision) .await .expect("queued draft"); assert_eq!(queued.state, FfiOutboxState::Queued); let recovered = runtime - .phase1_recover_draft_queue(draft_id.clone(), 1_800_000_003_000) + .phase1_recover_add_intent(draft_id.clone()) .await .expect("recovered queue"); assert_eq!(recovered.revision, queued.revision); let cancelled = runtime - .phase1_cancel_draft(draft_id.clone(), recovered.revision, 1_800_000_004_000) + .phase1_cancel_add_intent(draft_id.clone(), recovered.revision) .await .expect("cancelled draft"); assert_eq!(cancelled.state, FfiOutboxState::Cancelled); @@ -393,7 +377,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { .expect("cancelled retraction"); assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled); - let upload = FfiBlossomUploadInput { + let upload = FfiBlossomUploadIntent { schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, draft_id, expected_revision: cancelled.revision, @@ -409,18 +393,9 @@ async fn native_boundary_delegates_the_complete_core_surface() { alt: "unused".to_owned(), prepared_at_unix_s: 1_800_000_000, }, - authorization_content: "Upload exact image".to_owned(), - authorization_created_at_unix_s: 1_800_000_000, - authorization_lifetime_seconds: 60, - operation_id: "08".repeat(16), - artifact_id: "09".repeat(16), - signing_deadline_unix_ms: 1_800_000_100_000, - signing_cancellation: FfiCancellationPolicy::LocalCooperative, - verified_at_unix_ms: 1_800_000_000_000, - updated_at_unix_ms: 1_800_000_005_000, }; let upload_error = runtime - .phase1_upload_draft_media(upload.clone()) + .phase1_upload_add_media_intent(upload.clone()) .await .expect_err("unsupported upload schema"); assert_eq!(upload_error.report().code, "unsupported_schema_version"); @@ -429,7 +404,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { invalid_id_upload.draft_id = "not-a-draft-id".to_owned(); assert_eq!( runtime - .phase1_upload_draft_media(invalid_id_upload) + .phase1_upload_add_media_intent(invalid_id_upload) .await .expect_err("invalid draft id") .report()