field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 53502e95d28bc3b553f7ccfb18a24bb120760bd0
parent 4fdc741c8f1a8d3e1831cdb311b2f64cdeb675a2
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:
Mcore/crates/tera_ffi/src/dto.rs | 32+++++++++++++++++++++++++++++---
Mcore/crates/tera_ffi/src/error.rs | 9+++++++++
Mcore/crates/tera_ffi/src/runtime.rs | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 45++++++++++-----------------------------------
4 files changed, 184 insertions(+), 47 deletions(-)

diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_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/core/crates/tera_ffi/src/error.rs b/core/crates/tera_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/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_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()