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 cfdd57eed012723db3c491c6877308afa4a95ef4
parent 2a0bd61727ffade9a28a3927553ceb8c1faf38af
Author: triesap <tyson@radroots.org>
Date:   Mon, 10 Aug 2026 20:35:58 +0000

feat: authorize native mobile uploads

Diffstat:
Mcore/crates/tera_ffi/src/dto.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcore/crates/tera_ffi/src/operations.rs | 7++-----
Mcore/crates/tera_ffi/src/runtime.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 30+++++++++++++++++++++++-------
4 files changed, 151 insertions(+), 47 deletions(-)

diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs @@ -32,7 +32,7 @@ use radroots_mobile_core::runtime::{ }, sdk::{ SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord, - SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, + SdkRelayAccessRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, SdkStorageStatusRecord, }, }; @@ -658,6 +658,7 @@ pub struct FfiAddFieldRecord { pub required: bool, pub choices: Vec<String>, pub max_bytes: Option<u64>, + pub max_items: Option<u16>, } #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] @@ -681,7 +682,19 @@ pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { required, choices: choices.iter().map(|value| (*value).to_owned()).collect(), max_bytes, + max_items: None, }; + let media_field = |label: &str, required: bool, max_items| FfiAddFieldRecord { + max_items: Some(max_items), + ..field( + "media", + label, + Kind::Media, + required, + &[], + Some(MEDIA_FILE_MAX_BYTES), + ) + }; vec![ FfiAddSchemaRecord { schema_version: MOBILE_FFI_SCHEMA_VERSION, @@ -709,14 +722,7 @@ pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { &[], Some(65_535), ), - field( - "media", - "Photos", - Kind::Media, - true, - &[], - Some(MEDIA_FILE_MAX_BYTES), - ), + media_field("Photos", true, 20), ], }, FfiAddSchemaRecord { @@ -732,14 +738,7 @@ pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { &[], Some(65_535), ), - field( - "media", - "Photos", - Kind::Media, - false, - &[], - Some(MEDIA_FILE_MAX_BYTES), - ), + media_field("Photos", false, 20), ], }, FfiAddSchemaRecord { @@ -767,14 +766,7 @@ pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { &[], Some(256), ), - field( - "media", - "Photo", - Kind::Media, - false, - &[], - Some(MEDIA_FILE_MAX_BYTES), - ), + media_field("Photo", false, 1), ], }, FfiAddSchemaRecord { @@ -821,14 +813,7 @@ pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { &[], Some(64), ), - field( - "media", - "Photos", - Kind::Media, - false, - &[], - Some(MEDIA_FILE_MAX_BYTES), - ), + media_field("Photos", false, 20), ], }, ] @@ -915,6 +900,32 @@ pub struct FfiBlossomUploadIntent { pub media: FfiPreparedMediaInput, } +/// Secret-bearing native job. Hosts may use the authorization header for the +/// immediate OS request but must not persist it in application metadata. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiNativeUploadJobRecord { + pub schema_version: u16, + pub operation_id: String, + pub draft: FfiDraftStatusRecord, + pub remote_url: String, + pub authorization_header: String, + pub expected_sha256: String, + pub media_type: String, + pub byte_size: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiNativeUploadCompletionInput { + pub schema_version: u16, + pub draft_id: String, + pub expected_revision: u64, + pub media: FfiPreparedMediaInput, + pub status_code: u16, + pub response_media_type: Option<String>, + pub response_content_encoding: Option<String>, + pub response_body: Vec<u8>, +} + impl FfiAddDraftInput { pub(crate) fn command_and_media( self, @@ -1647,6 +1658,7 @@ pub struct FfiDraftStatusRecord { pub updated_at_unix_ms: u64, pub media: Vec<FfiDraftMediaRecord>, pub settlement: Option<FfiOperationSettlementRecord>, + pub is_revision: bool, } #[cfg_attr(coverage_nightly, coverage(off))] @@ -1669,6 +1681,7 @@ impl From<Phase1DraftStatus> for FfiDraftStatusRecord { updated_at_unix_ms: draft.updated_at_unix_ms(), media: value.media().iter().map(Into::into).collect(), settlement: settlement.map(Into::into), + is_revision: value.revision_policy().is_some(), } } } @@ -1822,11 +1835,17 @@ impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiRelayAccessRecord { + ReadOnly, + ReadWrite, +} + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct FfiRelayStatusRecord { pub schema_version: u16, pub relay_url: String, - pub access: String, + pub access: FfiRelayAccessRecord, pub read_state: String, pub write_state: String, pub read_last_attempt_unix_ms: Option<u64>, @@ -1841,7 +1860,10 @@ impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord { Self { schema_version: MOBILE_FFI_SCHEMA_VERSION, relay_url: value.relay_url, - access: value.access, + access: match value.access { + SdkRelayAccessRecord::ReadOnly => FfiRelayAccessRecord::ReadOnly, + SdkRelayAccessRecord::ReadWrite => FfiRelayAccessRecord::ReadWrite, + }, read_state: value.read_state, write_state: value.write_state, read_last_attempt_unix_ms: value.read_last_attempt_unix_ms, diff --git a/core/crates/tera_ffi/src/operations.rs b/core/crates/tera_ffi/src/operations.rs @@ -491,10 +491,8 @@ impl From<Phase1ProfileStatus> for FfiProfileStatusRecord { #[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, @@ -503,12 +501,11 @@ pub struct FfiRevisionInputRecord { impl FfiRevisionInputRecord { pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, RadrootsAppError> { require_schema(self.schema_version)?; - Phase1RevisionTarget::new( - self.command_type.into(), + Phase1RevisionTarget::from_source( + self.replacement.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(), ) diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -733,6 +733,75 @@ impl RadrootsRuntime { Ok(status.into()) } + /// Persists the upload transition before returning an immutable native + /// background-transfer job. + pub async fn phase1_prepare_add_media_background( + &self, + input: crate::FfiBlossomUploadIntent, + ) -> Result<crate::FfiNativeUploadJobRecord, 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, job) = self + .inner + .phase1_prepare_native_upload(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(crate::FfiNativeUploadJobRecord { + schema_version: crate::MOBILE_FFI_SCHEMA_VERSION, + operation_id: hex::encode(job.operation_id()), + draft: status.into(), + remote_url: job.remote_url().to_owned(), + authorization_header: job.authorization_header().to_owned(), + expected_sha256: job.expected_sha256().to_owned(), + media_type: job.media_type().to_owned(), + byte_size: job.byte_size(), + }) + } + + /// Accepts only bounded native HTTP evidence; Rust performs descriptor and + /// exact-byte retrieval verification before advancing durable state. + pub async fn phase1_complete_add_media_background( + &self, + input: crate::FfiNativeUploadCompletionInput, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION + || input.response_body.len() > 16_384 + { + return Err(RadrootsAppError::invalid_argument( + "invalid_native_upload_completion", + )); + } + 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_complete_native_upload( + intent, + input.status_code, + input.response_media_type.as_deref(), + input.response_content_encoding.as_deref(), + input.response_body.as_slice(), + ) + .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, diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -3,8 +3,9 @@ use radroots_mobile_ffi::{ FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiIdentityCommandKind, FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord, - FfiRelaySatisfaction, FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, - FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, + FfiRelayAccessRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, FfiRevisionInputRecord, + FfiRevisionPhase, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, + RadrootsAppError, }; mod support; @@ -40,7 +41,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { assert_eq!(public.read_availability, "unavailable"); assert_eq!(public.write_availability, "unavailable"); assert_eq!(public.relays.len(), 1); - assert_eq!(public.relays[0].access, "read_write"); + assert_eq!(public.relays[0].access, FfiRelayAccessRecord::ReadWrite); assert_eq!(public.relays[0].read_state, "unobserved"); assert_eq!(public.relays[0].write_state, "unobserved"); @@ -52,7 +53,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { .expect("relay status") .expect("public profile"); assert_eq!(public.relays.len(), 2); - assert_eq!(public.relays[1].access, "read_write"); + assert_eq!(public.relays[1].access, FfiRelayAccessRecord::ReadWrite); assert!( runtime .configure_public_relays(vec!["ws://127.0.0.1:7447".to_owned()]) @@ -68,7 +69,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { .expect("simulator profile"); assert_eq!(simulator.profile, "simulator_local"); assert_eq!(simulator.relays.len(), 1); - assert_eq!(simulator.relays[0].access, "read_write"); + assert_eq!(simulator.relays[0].access, FfiRelayAccessRecord::ReadWrite); assert!( runtime .phase1_local_network(FfiLocalNetworkRecord { @@ -183,6 +184,22 @@ async fn native_boundary_delegates_the_complete_core_surface() { for (index, item) in parity.iter().enumerate() { assert_eq!(item.command_type, schemas[index].command_type); } + assert_eq!( + schemas[1] + .fields + .iter() + .find(|field| field.id == "media") + .and_then(|field| field.max_items), + Some(20) + ); + assert_eq!( + schemas[3] + .fields + .iter() + .find(|field| field.id == "media") + .and_then(|field| field.max_items), + Some(1) + ); let local_network = runtime .phase1_local_network(FfiLocalNetworkRecord { schema_version: MOBILE_FFI_SCHEMA_VERSION, @@ -484,10 +501,8 @@ async fn native_boundary_delegates_the_complete_core_surface() { 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 { @@ -517,6 +532,7 @@ async fn native_boundary_delegates_the_complete_core_surface() { .expect("save lossless revision intent"); assert_eq!(revision.phase, FfiRevisionPhase::ReplacementPending); assert_eq!(revision.operation_id, revision.replacement.draft_id); + assert!(revision.replacement.is_revision); let cancelled_revision = runtime .phase1_cancel_revision(revision.operation_id.clone()) .await