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 ac4a41fbd7d2ae6636b09cfd9a1811c93daea122
parent aa144b2f2f2afe3e9adc1afb5a41306c698b932c
Author: triesap <tyson@radroots.org>
Date:   Sat,  8 Aug 2026 02:11:36 +0000

feat(mobile): complete durable Add operations

- retain validated reopenable form snapshots in immutable draft revisions
- advance queued work through opaque signing admission and bounded delivery
- persist retractions as independent strict NIP-09 authored operations
- expose typed FFI state with retry cancellation and settlement evidence

Diffstat:
Mcore/crates/tera_ffi/src/dto.rs | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcore/crates/tera_ffi/src/runtime.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 307 insertions(+), 11 deletions(-)

diff --git a/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_ffi/src/dto.rs @@ -21,6 +21,7 @@ use radroots_mobile_core::runtime::{ AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate, LocalNetwork, LocalNetworkRelayPolicy, MeSnapshot, MediaReference, MediaVerificationState, Phase1AddCommand, Phase1CancellationPolicy, + Phase1DraftEventTiming, Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, Phase1RelaySatisfaction, ProfileSummary, SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType, TodayPage, TodayProjectionUpdate, @@ -865,6 +866,17 @@ pub struct FfiAddDraftInput { } #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiRetractionDraftInput { + pub schema_version: u16, + pub command_type: FfiAddCommandType, + pub target_card_id: String, + pub target_event_id: String, + pub target_kind: u32, + pub target_address: Option<String>, + pub reason: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct FfiBlossomUploadInput { pub schema_version: u16, pub draft_id: String, @@ -886,6 +898,21 @@ impl FfiAddDraftInput { self, authored_at_unix_s: u64, ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> { + self.command_media_and_form(authored_at_unix_s) + .map(|(command, media, _)| (command, media)) + } + + pub(crate) fn command_media_and_form( + self, + authored_at_unix_s: u64, + ) -> Result< + ( + Phase1AddCommand, + Vec<Phase1MediaPrerequisite>, + Phase1DraftFormSnapshot, + ), + RadrootsAppError, + > { require_schema(self.schema_version)?; if authored_at_unix_s == 0 || self.media.len() > 20 { return Err(RadrootsAppError::invalid_argument("invalid_add_draft")); @@ -907,6 +934,7 @@ impl FfiAddDraftInput { .iter() .map(PreparedMedia::post_image) .collect::<Result<Vec<_>, _>>()?; + let form = self.form_snapshot(); let command = match self.command_type { FfiAddCommandType::CreateUpdate => { reject_media(&prepared)?; @@ -936,7 +964,53 @@ impl FfiAddDraftInput { food_command(self, authored_at_unix_s, &prepared)?, ), }; - Ok((command, prerequisites)) + Ok((command, prerequisites, form)) + } + + fn form_snapshot(&self) -> Phase1DraftFormSnapshot { + Phase1DraftFormSnapshot { + command_type: match self.command_type { + FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, + FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate, + FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk, + FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent, + FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability, + }, + content: self.content.clone(), + identifier: self.identifier.clone(), + title: self.title.clone(), + summary: self.summary.clone(), + location: self.location.clone(), + event_timing: self.event_timing.map(|value| match value { + FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay, + FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed, + }), + event_start_date: self.event_start_date.clone(), + event_end_date: self.event_end_date.clone(), + event_start_unix_s: self.event_start_unix_s, + event_end_unix_s: self.event_end_unix_s, + event_timezone: self.event_timezone.clone(), + price_amount: self.price_amount.clone(), + currency: self.currency.clone(), + unit: self.unit.clone(), + quantity: self.quantity.clone(), + food_status: self.food_status.clone(), + media: self + .media + .iter() + .map(|value| Phase1DraftMediaSnapshot { + opaque_reference: value.opaque_reference.clone(), + url: value.url.clone(), + sha256: value.sha256.clone(), + media_type: value.media_type.clone(), + byte_size: value.byte_size, + width: value.width, + height: value.height, + alt: value.alt.clone(), + prepared_at_unix_s: value.prepared_at_unix_s, + }) + .collect(), + } } } @@ -1320,6 +1394,107 @@ pub enum FfiOutboxState { Complete, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] +pub enum FfiDraftKind { + Add, + Retraction, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<Phase1DraftKind> for FfiDraftKind { + fn from(value: Phase1DraftKind) -> Self { + match value { + Phase1DraftKind::Add => Self::Add, + Phase1DraftKind::Retraction => Self::Retraction, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiDraftFormMediaRecord { + pub schema_version: u16, + pub opaque_reference: String, + pub url: String, + pub sha256: String, + pub media_type: String, + pub byte_size: u64, + pub width: u32, + pub height: u32, + pub alt: String, + pub prepared_at_unix_s: u64, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<&Phase1DraftMediaSnapshot> for FfiDraftFormMediaRecord { + fn from(value: &Phase1DraftMediaSnapshot) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + opaque_reference: value.opaque_reference.clone(), + url: value.url.clone(), + sha256: value.sha256.clone(), + media_type: value.media_type.clone(), + byte_size: value.byte_size, + width: value.width, + height: value.height, + alt: value.alt.clone(), + prepared_at_unix_s: value.prepared_at_unix_s, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct FfiDraftFormRecord { + pub schema_version: u16, + pub command_type: FfiAddCommandType, + pub content: String, + pub identifier: Option<String>, + pub title: Option<String>, + pub summary: Option<String>, + pub location: Option<String>, + pub event_timing: Option<FfiEventTimingKind>, + pub event_start_date: Option<String>, + pub event_end_date: Option<String>, + pub event_start_unix_s: Option<u64>, + pub event_end_unix_s: Option<u64>, + pub event_timezone: Option<String>, + pub price_amount: Option<String>, + pub currency: Option<String>, + pub unit: Option<String>, + pub quantity: Option<String>, + pub food_status: Option<String>, + pub media: Vec<FfiDraftFormMediaRecord>, +} + +#[cfg_attr(coverage_nightly, coverage(off))] +impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord { + fn from(value: &Phase1DraftFormSnapshot) -> Self { + Self { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: value.command_type.into(), + content: value.content.clone(), + identifier: value.identifier.clone(), + title: value.title.clone(), + summary: value.summary.clone(), + location: value.location.clone(), + event_timing: value.event_timing.map(|value| match value { + Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay, + Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed, + }), + event_start_date: value.event_start_date.clone(), + event_end_date: value.event_end_date.clone(), + event_start_unix_s: value.event_start_unix_s, + event_end_unix_s: value.event_end_unix_s, + event_timezone: value.event_timezone.clone(), + price_amount: value.price_amount.clone(), + currency: value.currency.clone(), + unit: value.unit.clone(), + quantity: value.quantity.clone(), + food_status: value.food_status.clone(), + media: value.media.iter().map(Into::into).collect(), + } + } +} + #[cfg_attr(coverage_nightly, coverage(off))] impl From<Phase1OutboxState> for FfiOutboxState { fn from(value: Phase1OutboxState) -> Self { @@ -1347,7 +1522,9 @@ pub struct FfiDraftStatusRecord { pub draft_id: String, pub revision: u64, pub author_public_key: String, + pub kind: FfiDraftKind, pub command_type: FfiAddCommandType, + pub form: Option<FfiDraftFormRecord>, pub state: FfiOutboxState, pub card_id: String, pub operation_id: Option<String>, @@ -1367,7 +1544,9 @@ impl From<Phase1DraftStatus> for FfiDraftStatusRecord { draft_id: hex::encode(draft.draft_id().as_bytes()), revision: draft.revision().get(), author_public_key: hex::encode(draft.author()), + kind: value.kind().into(), command_type: value.command_type().into(), + form: value.form().map(Into::into), state: value.state().into(), card_id: value.card_id().to_hex(), operation_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())), diff --git a/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_ffi/src/runtime.rs @@ -9,11 +9,11 @@ use crate::subscription::SubscriptionHub; use crate::{ FfiAddDraftInput, FfiAddSchemaRecord, FfiBlossomUploadInput, FfiCapabilityRecord, FfiCardAddParityRecord, FfiDraftStatusRecord, FfiIdentityStatusRecord, FfiLocalNetworkRecord, - FfiMeRecord, FfiQueuePolicyRecord, FfiRelayStatusReportRecord, FfiRuntimeChangeKind, - FfiRuntimeInfoRecord, FfiSearchResultRecord, FfiShutdownRecord, FfiStorageStatusRecord, - FfiSubscriptionHandle, FfiTodayPageRecord, FfiTodayProjectionUpdate, FfiTodayRefreshRecord, - FfiTodaySyncRecord, RadrootsAppError, RadrootsHostSigner, RadrootsRuntimeObserver, add_schemas, - decode_id, + 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)] @@ -375,15 +375,16 @@ impl RadrootsRuntime { expected_revision: Option<u64>, persisted_at_unix_ms: u64, ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { - let (command, media) = input.command_and_media(authored_at_unix_s)?; + let (command, media, form) = input.command_media_and_form(authored_at_unix_s)?; let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; let status = self .inner - .phase1_save_draft( + .phase1_save_draft_with_form( decoded_id, command, authored_at_unix_s, media, + form, expected_revision, persisted_at_unix_ms, ) @@ -394,6 +395,59 @@ impl RadrootsRuntime { Ok(status.into()) } + pub async fn phase1_save_retraction_draft( + &self, + draft_id: String, + input: FfiRetractionDraftInput, + authored_at_unix_s: u64, + persisted_at_unix_ms: u64, + ) -> Result<FfiDraftStatusRecord, RadrootsAppError> { + if input.schema_version != crate::MOBILE_FFI_SCHEMA_VERSION { + return Err(RadrootsAppError::invalid_argument( + "unsupported_schema_version", + )); + } + let decoded_id = decode_id(&draft_id, "invalid_draft_id")?; + let command_type = match input.command_type { + crate::FfiAddCommandType::CreateUpdate => { + radroots_mobile_core::runtime::product_surface::AddCommandType::CreateUpdate + } + crate::FfiAddCommandType::CreatePhotoUpdate => { + radroots_mobile_core::runtime::product_surface::AddCommandType::CreatePhotoUpdate + } + crate::FfiAddCommandType::CreateAsk => { + radroots_mobile_core::runtime::product_surface::AddCommandType::CreateAsk + } + crate::FfiAddCommandType::CreateEvent => { + radroots_mobile_core::runtime::product_surface::AddCommandType::CreateEvent + } + crate::FfiAddCommandType::CreateFoodAvailability => { + radroots_mobile_core::runtime::product_surface::AddCommandType::CreateFoodAvailability + } + }; + let card_id = + radroots_mobile_core::runtime::product_surface::CardId::parse(&input.target_card_id) + .map_err(|_| RadrootsAppError::invalid_argument("invalid_card_id"))?; + let status = self + .inner + .phase1_save_retraction_draft( + decoded_id, + command_type, + card_id, + &input.target_event_id, + input.target_kind, + input.target_address.as_deref(), + &input.reason, + authored_at_unix_s, + persisted_at_unix_ms, + ) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + pub async fn phase1_draft_status( &self, draft_id: String, @@ -471,6 +525,22 @@ impl RadrootsRuntime { Ok(status.into()) } + pub async fn phase1_advance_draft( + &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_advance_draft(decoded_id, expected_revision) + .await + .map_err(RadrootsAppError::from)?; + self.subscriptions + .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id)); + Ok(status.into()) + } + pub async fn phase1_upload_draft_media( &self, input: FfiBlossomUploadInput, diff --git a/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_ffi/tests/runtime_delegation.rs @@ -1,8 +1,8 @@ use radroots_mobile_ffi::{ FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy, - FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord, - FfiRelaySatisfaction, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, - RadrootsAppError, + FfiDraftKind, FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, + FfiQueuePolicyRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, FfiTodayCardType, + FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError, }; mod support; @@ -269,6 +269,11 @@ async fn native_boundary_delegates_the_complete_core_surface() { .await .expect("saved draft"); assert_eq!(saved.state, FfiOutboxState::Draft); + assert_eq!(saved.kind, FfiDraftKind::Add); + assert_eq!( + saved.form.as_ref().map(|form| form.content.as_str()), + Some("Farm stand opens at noon") + ); assert_eq!( runtime .phase1_draft_status(draft_id.clone()) @@ -312,6 +317,48 @@ async fn native_boundary_delegates_the_complete_core_surface() { .expect("cancelled draft"); assert_eq!(cancelled.state, FfiOutboxState::Cancelled); + let retraction_id = "0a".repeat(16); + let retraction = runtime + .phase1_save_retraction_draft( + retraction_id.clone(), + FfiRetractionDraftInput { + schema_version: MOBILE_FFI_SCHEMA_VERSION, + command_type: FfiAddCommandType::CreateUpdate, + target_card_id: "c".repeat(64), + target_event_id: "a".repeat(64), + target_kind: 1, + target_address: None, + reason: "Replaced with a corrected copy".to_owned(), + }, + 1_800_000_005, + 1_800_000_005_000, + ) + .await + .expect("saved retraction"); + assert_eq!(retraction.kind, FfiDraftKind::Retraction); + assert_eq!(retraction.card_id, "c".repeat(64)); + assert!(retraction.form.is_none()); + let queued_retraction = runtime + .phase1_queue_draft( + retraction_id.clone(), + retraction.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_006_000, + ) + .await + .expect("queued retraction"); + let cancelled_retraction = runtime + .phase1_cancel_draft(retraction_id, queued_retraction.revision, 1_800_000_007_000) + .await + .expect("cancelled retraction"); + assert_eq!(cancelled_retraction.state, FfiOutboxState::Cancelled); + let upload = FfiBlossomUploadInput { schema_version: MOBILE_FFI_SCHEMA_VERSION + 1, draft_id,