lib

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

commit d7c023544deb3310ef4d7a177bf527c9a45d0b27
parent 875c423d487cdc7c140bf7742c7986748ba7e602
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:
Mcrates/mobile_core/src/runtime/product_surface.rs | 3++-
Mcrates/mobile_core/src/runtime/product_surface/outbox.rs | 515++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/mobile_ffi/src/dto.rs | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/mobile_ffi/src/runtime.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/mobile_ffi/tests/runtime_delegation.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 820 insertions(+), 16 deletions(-)

diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -34,7 +34,8 @@ pub use model::{ }; #[cfg(feature = "mobile-social")] pub use outbox::{ - Phase1CancellationPolicy, Phase1DraftError, Phase1DraftStatus, Phase1MediaOrphanRecord, + Phase1CancellationPolicy, Phase1DraftError, Phase1DraftEventTiming, Phase1DraftFormSnapshot, + Phase1DraftKind, Phase1DraftMediaSnapshot, Phase1DraftStatus, Phase1MediaOrphanRecord, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, Phase1RelaySatisfaction, }; diff --git a/crates/mobile_core/src/runtime/product_surface/outbox.rs b/crates/mobile_core/src/runtime/product_surface/outbox.rs @@ -3,7 +3,12 @@ use std::collections::BTreeSet; use radroots_blossom::{ BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim, }; -use radroots_event::contract::AuthorRole; +use radroots_event::{ + contract::AuthorRole, + post::deletion::{ + AuthoredNip09DeletionRequest, Nip09DeletionAddressTarget, Nip09DeletionEventTarget, + }, +}; use radroots_event_codec::authoring::PlanWireV1; use radroots_identity::PublicKey; use radroots_signing::{ @@ -32,7 +37,7 @@ use thiserror::Error; use super::{ AddCommandType, CardId, CardSourceIdentity, LocalAuthorOverlay, LocalNetwork, Phase1AddCommand, - TodayCardType, TodayError, + TodayCardType, TodayError, phase1_retraction_plan, }; use crate::runtime::RadrootsRuntime; @@ -43,6 +48,114 @@ 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"; +/// Product intent represented by one durable draft/outbox item. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1DraftKind { + #[default] + Add, + Retraction, +} + +/// Event timing profile retained for a reopenable Add form. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase1DraftEventTiming { + AllDay, + Timed, +} + +/// Secret-free, restart-safe media fields retained with an Add form. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1DraftMediaSnapshot { + 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, +} + +/// Immutable, validated presentation input used to reopen a durable draft. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1DraftFormSnapshot { + pub command_type: AddCommandType, + pub content: String, + pub identifier: Option<String>, + pub title: Option<String>, + pub summary: Option<String>, + pub location: Option<String>, + pub event_timing: Option<Phase1DraftEventTiming>, + 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<Phase1DraftMediaSnapshot>, +} + +impl Phase1DraftFormSnapshot { + fn validate( + &self, + command_type: AddCommandType, + media: &[Phase1MediaPrerequisite], + ) -> Result<(), Phase1DraftError> { + let bounded = |value: &str, maximum: usize| { + value.len() <= maximum && !value.chars().any(char::is_control) + }; + if self.command_type != command_type + || self.content.len() > 65_535 + || self.media.len() != media.len() + || [ + self.identifier.as_deref(), + self.title.as_deref(), + self.summary.as_deref(), + self.location.as_deref(), + self.event_start_date.as_deref(), + self.event_end_date.as_deref(), + self.event_timezone.as_deref(), + self.price_amount.as_deref(), + self.currency.as_deref(), + self.unit.as_deref(), + self.quantity.as_deref(), + self.food_status.as_deref(), + ] + .into_iter() + .flatten() + .any(|value| !bounded(value, 1_024)) + { + return Err(Phase1DraftError::InvalidDraft); + } + for (snapshot, prerequisite) in self.media.iter().zip(media) { + if snapshot.opaque_reference.is_empty() + || snapshot.opaque_reference != prerequisite.local_reference() + || snapshot.url != prerequisite.url + || snapshot.sha256 != prerequisite.sha256() + || snapshot.media_type != prerequisite.media_type() + || snapshot.byte_size != prerequisite.byte_size() + || snapshot.width == 0 + || snapshot.height == 0 + || snapshot.alt.trim().is_empty() + || !bounded(&snapshot.alt, 1_024) + || snapshot.prepared_at_unix_s == 0 + { + return Err(Phase1DraftError::InvalidMedia); + } + } + Ok(()) + } +} + /// Durable state of one media prerequisite referenced by an Add command. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] @@ -165,6 +278,18 @@ impl Phase1MediaPrerequisite { pub fn url(&self) -> &str { self.url.as_str() } + pub fn local_reference(&self) -> &str { + self.local_reference.as_str() + } + pub fn sha256(&self) -> &str { + self.sha256.as_str() + } + pub fn media_type(&self) -> &str { + self.media_type.as_str() + } + pub const fn byte_size(&self) -> u64 { + self.byte_size + } pub const fn stage(&self) -> Phase1MediaStage { self.stage } @@ -342,7 +467,9 @@ impl Phase1OutboxState { #[derive(Clone, Debug, Eq, PartialEq)] pub struct Phase1DraftStatus { draft: AuthoredDraft, + kind: Phase1DraftKind, command_type: AddCommandType, + form: Option<Phase1DraftFormSnapshot>, media: Vec<Phase1MediaPrerequisite>, state: Phase1OutboxState, card_id: CardId, @@ -356,6 +483,12 @@ impl Phase1DraftStatus { pub const fn command_type(&self) -> AddCommandType { self.command_type } + pub const fn kind(&self) -> Phase1DraftKind { + self.kind + } + pub const fn form(&self) -> Option<&Phase1DraftFormSnapshot> { + self.form.as_ref() + } pub fn media(&self) -> &[Phase1MediaPrerequisite] { self.media.as_slice() } @@ -404,7 +537,13 @@ pub enum Phase1DraftError { #[serde(deny_unknown_fields)] struct Phase1DraftPayload { schema_version: u16, + #[serde(default)] + kind: Phase1DraftKind, command_type: AddCommandType, + #[serde(default)] + form: Option<Phase1DraftFormSnapshot>, + #[serde(default)] + target_card_id: Option<CardId>, plan_wire_json: Vec<u8>, media: Vec<Phase1MediaPrerequisite>, queue: Option<Phase1QueuePolicy>, @@ -415,10 +554,14 @@ impl Phase1DraftPayload { command: &Phase1AddCommand, plan_wire_json: Vec<u8>, media: Vec<Phase1MediaPrerequisite>, + form: Option<Phase1DraftFormSnapshot>, ) -> Result<Self, Phase1DraftError> { let value = Self { schema_version: DRAFT_SCHEMA_VERSION, + kind: Phase1DraftKind::Add, command_type: command.command_type(), + form, + target_card_id: None, plan_wire_json, media, queue: None, @@ -427,10 +570,44 @@ impl Phase1DraftPayload { Ok(value) } + fn retraction( + command_type: AddCommandType, + target_card_id: CardId, + plan_wire_json: Vec<u8>, + ) -> Result<Self, Phase1DraftError> { + let value = Self { + schema_version: DRAFT_SCHEMA_VERSION, + kind: Phase1DraftKind::Retraction, + command_type, + form: None, + target_card_id: Some(target_card_id), + plan_wire_json, + media: Vec::new(), + queue: None, + }; + value.validate()?; + Ok(value) + } + fn validate(&self) -> Result<(), Phase1DraftError> { if self.schema_version != DRAFT_SCHEMA_VERSION || self.media.len() > DRAFT_MEDIA_MAX { return Err(Phase1DraftError::Corrupt); } + match self.kind { + Phase1DraftKind::Add => { + if self.target_card_id.is_some() { + return Err(Phase1DraftError::Corrupt); + } + if let Some(form) = &self.form { + form.validate(self.command_type, &self.media)?; + } + } + Phase1DraftKind::Retraction => { + if self.form.is_some() || self.target_card_id.is_none() || !self.media.is_empty() { + return Err(Phase1DraftError::Corrupt); + } + } + } let integrity = PlanWireV1::from_json(self.plan_wire_json.as_slice()) .map_err(|_| Phase1DraftError::Corrupt)?; let plan = integrity.plan(); @@ -485,6 +662,53 @@ impl RadrootsRuntime { expected_revision: Option<u64>, persisted_at_unix_ms: u64, ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_save_draft_inner( + draft_id, + command, + authored_at_unix_s, + media, + None, + expected_revision, + persisted_at_unix_ms, + ) + .await + } + + /// Creates or replaces a draft while retaining its validated, reopenable form. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_draft_with_form( + &self, + draft_id: [u8; 16], + command: Phase1AddCommand, + authored_at_unix_s: u64, + media: Vec<Phase1MediaPrerequisite>, + form: Phase1DraftFormSnapshot, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + self.phase1_save_draft_inner( + draft_id, + command, + authored_at_unix_s, + media, + Some(form), + expected_revision, + persisted_at_unix_ms, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn phase1_save_draft_inner( + &self, + draft_id: [u8; 16], + command: Phase1AddCommand, + authored_at_unix_s: u64, + media: Vec<Phase1MediaPrerequisite>, + form: Option<Phase1DraftFormSnapshot>, + expected_revision: Option<u64>, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { let author = self.draft_author()?; let draft_id = AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; @@ -494,7 +718,7 @@ impl RadrootsRuntime { let wire = PlanWireV1::from_plan(&plan) .to_json() .map_err(|_| Phase1DraftError::InvalidDraft)?; - let payload = Phase1DraftPayload::new(&command, wire, media)?; + let payload = Phase1DraftPayload::new(&command, wire, media, form)?; let bytes = payload.encode()?; let storage = self.storage()?; let expected = expected_revision @@ -542,6 +766,73 @@ impl RadrootsRuntime { self.draft_status_from(receipt.draft().clone()).await } + /// Persists an independent strict NIP-09 retraction as a normal durable outbox item. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_save_retraction_draft( + &self, + draft_id: [u8; 16], + command_type: AddCommandType, + target_card_id: CardId, + target_event_id: &str, + target_kind: u32, + target_address: Option<&str>, + reason: &str, + authored_at_unix_s: u64, + persisted_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let target_shape_valid = match command_type { + AddCommandType::CreateUpdate + | AddCommandType::CreatePhotoUpdate + | AddCommandType::CreateAsk => target_kind == 1 && target_address.is_none(), + AddCommandType::CreateEvent => { + matches!(target_kind, 31_922 | 31_923) && target_address.is_some() + } + AddCommandType::CreateFoodAvailability => { + target_kind == 30_402 && target_address.is_some() + } + }; + if !target_shape_valid || authored_at_unix_s == 0 || persisted_at_unix_ms == 0 { + return Err(Phase1DraftError::InvalidDraft); + } + let author = self.draft_author()?; + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let event_target = Nip09DeletionEventTarget::parse(target_event_id, target_kind) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let address_targets = target_address + .map(Nip09DeletionAddressTarget::parse) + .transpose() + .map_err(|_| Phase1DraftError::InvalidDraft)? + .into_iter() + .collect(); + let request = + AuthoredNip09DeletionRequest::new(reason, vec![event_target], address_targets) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let plan = phase1_retraction_plan(&request, authored_at_unix_s, hex::encode(author)) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let wire = PlanWireV1::from_plan(&plan) + .to_json() + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let payload = Phase1DraftPayload::retraction(command_type, target_card_id, wire)?; + let bytes = payload.encode()?; + let draft = AuthoredDraft::initial( + draft_id, + author, + DRAFT_PAYLOAD_SCHEMA, + bytes, + AuthoredDraftStage::Draft, + None, + persisted_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::InvalidDraft)?; + let receipt = self + .storage()? + .append_authored_draft(draft, None) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + /// Advances one media prerequisite without mutating any prior revision. pub async fn phase1_update_draft_media( &self, @@ -865,6 +1156,76 @@ impl RadrootsRuntime { self.draft_status_from(head).await } + /// Advances one durably queued draft through signing, local admission, and + /// at most one bounded relay-delivery attempt. + pub async fn phase1_advance_draft( + &self, + draft_id: [u8; 16], + expected_revision: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let head = self + .storage()? + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected || head.stage() != AuthoredDraftStage::Queued { + return Err(Phase1DraftError::RevisionConflict); + } + let request = push_request(&head)?; + let operation_id = request.operation_id(); + let sync = self.sync()?; + let mut status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + + if matches!( + status.artifact().signing_state(), + SigningState::Planned | SigningState::Retryable + ) { + sync.sign_prepared(request) + .await + .map_err(|_| Phase1DraftError::Operation)?; + status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if status.artifact().signing_state() == SigningState::Signed + && matches!( + status.artifact().admission_state(), + AdmissionState::Pending | AdmissionState::Retryable + ) + { + sync.admit_signed(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + status = sync + .push_status(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)? + .ok_or(Phase1DraftError::Corrupt)?; + } + if status.artifact().admission_state().is_admitted() + && matches!( + status.delivery_plan().state(), + AuthoredDeliveryState::Pending | AuthoredDeliveryState::Retryable + ) + { + sync.deliver_push(operation_id) + .await + .map_err(|_| Phase1DraftError::Operation)?; + } + self.draft_status_from(head).await + } + /// Signs one short-lived BUD-11 upload credential for HTTP use only. /// /// The returned value is not persisted and its distinct plan type cannot @@ -1164,7 +1525,10 @@ impl RadrootsRuntime { let payload = Phase1DraftPayload::decode(&draft)?; let integrity = PlanWireV1::from_json(payload.plan_wire_json.as_slice()) .map_err(|_| Phase1DraftError::Corrupt)?; - let card_id = card_id(payload.command_type, integrity.plan())?; + let card_id = payload + .target_card_id + .map(Ok) + .unwrap_or_else(|| card_id(payload.command_type, integrity.plan()))?; let push = self.push_status_for(&draft).await?; if draft.stage() == AuthoredDraftStage::Queued && push.is_none() { return Err(Phase1DraftError::Corrupt); @@ -1172,7 +1536,9 @@ impl RadrootsRuntime { let state = aggregate_state(&draft, push.as_ref()); Ok(Phase1DraftStatus { draft, + kind: payload.kind, command_type: payload.command_type, + form: payload.form, media: payload.media, state, card_id, @@ -1535,6 +1901,147 @@ mod tests { .unwrap() } + fn update_form() -> Phase1DraftFormSnapshot { + Phase1DraftFormSnapshot { + command_type: AddCommandType::CreateUpdate, + content: "Harvest update".to_owned(), + identifier: None, + title: None, + summary: None, + location: None, + event_timing: None, + event_start_date: None, + event_end_date: None, + event_start_unix_s: None, + event_end_unix_s: None, + event_timezone: None, + price_amount: None, + currency: None, + unit: None, + quantity: None, + food_status: None, + media: Vec::new(), + } + } + + #[tokio::test] + async fn form_snapshots_reopen_exactly_and_freeze_after_queue() { + let runtime = runtime(); + let id = [6; 16]; + let saved = runtime + .phase1_save_draft_with_form( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + 1_900_000_000, + Vec::new(), + update_form(), + None, + 10, + ) + .await + .unwrap(); + assert_eq!(saved.kind(), Phase1DraftKind::Add); + assert_eq!(saved.form(), Some(&update_form())); + assert_eq!( + runtime.phase1_draft_status(id).await.unwrap().form(), + Some(&update_form()) + ); + + let queued = runtime + .phase1_queue_draft(id, 1, policy(), 11) + .await + .unwrap(); + assert_eq!(queued.form(), Some(&update_form())); + assert_eq!( + runtime + .phase1_save_draft_with_form( + id, + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Changed").unwrap()), + 1_900_000_001, + Vec::new(), + update_form(), + Some(queued.draft().revision().get()), + 12, + ) + .await + .unwrap_err(), + Phase1DraftError::RevisionConflict + ); + } + + #[tokio::test] + async fn retraction_is_independent_and_add_advance_attempts_delivery() { + let runtime = signing_runtime(); + let target = CardId::parse(&"a".repeat(64)).unwrap(); + let retraction = runtime + .phase1_save_retraction_draft( + [5; 16], + AddCommandType::CreateUpdate, + target, + &"b".repeat(64), + 1, + None, + "Replaced by a corrected copy", + 1_900_000_000, + 20, + ) + .await + .unwrap(); + assert_eq!(retraction.kind(), Phase1DraftKind::Retraction); + assert_eq!(retraction.card_id(), target); + assert!(retraction.form().is_none()); + let queued = runtime + .phase1_queue_draft([5; 16], retraction.draft().revision().get(), policy(), 21) + .await + .unwrap(); + let signed_retraction = runtime + .phase1_sign_queued_draft([5; 16], queued.draft().revision().get()) + .await; + let signed_retraction = signed_retraction.unwrap(); + assert_eq!(signed_retraction.kind(), Phase1DraftKind::Retraction); + let push = signed_retraction + .push() + .expect("durable retraction operation"); + assert_eq!( + push.artifact() + .signed() + .expect("signed retraction") + .event() + .kind(), + 5 + ); + + let saved = runtime + .phase1_save_draft( + [4; 16], + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Deliver me").unwrap()), + 1_900_000_001, + Vec::new(), + None, + 30, + ) + .await + .unwrap(); + let queued = runtime + .phase1_queue_draft([4; 16], saved.draft().revision().get(), policy(), 31) + .await + .unwrap(); + let _ = runtime + .phase1_advance_draft([4; 16], queued.draft().revision().get()) + .await; + let advanced = runtime.phase1_draft_status([4; 16]).await.unwrap(); + let push = advanced.push().expect("durable add operation"); + assert!(push.artifact().admission_state().is_admitted()); + assert!(!push.delivery_plan().attempts().is_empty()); + assert!(matches!( + advanced.state(), + Phase1OutboxState::Retryable + | Phase1OutboxState::PartiallyDelivered + | Phase1OutboxState::Complete + | Phase1OutboxState::Terminal + )); + } + #[tokio::test] async fn media_free_draft_queues_offline_and_recovers_exactly() { let runtime = runtime(); diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_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/crates/mobile_ffi/src/runtime.rs b/crates/mobile_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/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_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,