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:
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()