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:
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,