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