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 2295b883a64a4ac9fa8229bebcbfccb7bc009324
parent 79a2c9a053ffa022686cf0c934181244474deffc
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 19:11:00 +0000

feat(sdk): add verified Blossom transport

- enforce bounded BUD-11 authorization and exact-byte BUD-02 uploads
- validate BUD-01 retrievals, redirects, descriptors, media, and network policy
- persist verified media, retry evidence, cancellation, and possible orphans
- prove simulator I/O, redaction, package boundaries, and release coverage

Diffstat:
Mcore/crates/tera_core/src/runtime/builder.rs | 35+++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/mod.rs | 13+++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface.rs | 5+++--
Mcore/crates/tera_core/src/runtime/product_surface/outbox.rs | 429++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcore/crates/tera_core/src/runtime/sdk.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 481 insertions(+), 62 deletions(-)

diff --git a/core/crates/tera_core/src/runtime/builder.rs b/core/crates/tera_core/src/runtime/builder.rs @@ -8,6 +8,8 @@ pub struct RuntimeBuilder { signer: Option<std::sync::Arc<dyn radroots_signing::Signer>>, #[cfg(feature = "mobile-social")] relay_profile: radroots_sdk::transport::RelayProfile, + #[cfg(feature = "mobile-social")] + blossom_config: Option<radroots_sdk::transport::BlossomConfig>, } impl RuntimeBuilder { @@ -20,6 +22,8 @@ impl RuntimeBuilder { #[cfg(feature = "mobile-social")] relay_profile: radroots_sdk::transport::RelayProfile::public(Vec::<String>::new()) .expect("bundled public relay profile is valid"), + #[cfg(feature = "mobile-social")] + blossom_config: None, } } @@ -40,6 +44,17 @@ impl RuntimeBuilder { self } + /// Installs one validated inert Blossom environment profile. + #[cfg(feature = "mobile-social")] + #[must_use] + pub fn blossom_config( + mut self, + blossom_config: radroots_sdk::transport::BlossomConfig, + ) -> Self { + self.blossom_config = Some(blossom_config); + self + } + /// Opens the exact authenticated user's durable SQLite store. pub async fn build(self) -> Result<RadrootsRuntime, RadrootsAppError> { if self.store.protected_data() == ProtectedDataAvailability::Unavailable { @@ -57,6 +72,8 @@ impl RuntimeBuilder { self.signer, #[cfg(feature = "mobile-social")] Some(self.relay_profile), + #[cfg(feature = "mobile-social")] + self.blossom_config, ) } } @@ -146,6 +163,24 @@ mod tests { .expect("configured profile"), report ); + assert!( + runtime + .configure_simulator_blossom(vec!["http://127.0.0.1:3000".to_owned()]) + .is_ok() + ); + assert_eq!( + runtime.sdk_blossom_profile().expect("Blossom profile"), + Some("simulator_local".to_owned()) + ); + assert!( + runtime + .configure_public_blossom(vec!["http://127.0.0.1:3000".to_owned()]) + .is_err() + ); + assert_eq!( + runtime.sdk_blossom_profile().expect("unchanged profile"), + Some("simulator_local".to_owned()) + ); runtime.shutdown().await.expect("shutdown"); } diff --git a/core/crates/tera_core/src/runtime/mod.rs b/core/crates/tera_core/src/runtime/mod.rs @@ -37,6 +37,9 @@ impl RadrootsRuntime { #[cfg(feature = "mobile-social")] relay_profile: Option< radroots_sdk::transport::RelayProfile, >, + #[cfg(feature = "mobile-social")] blossom_config: Option< + radroots_sdk::transport::BlossomConfig, + >, ) -> Result<Self, RadrootsAppError> { #[cfg(feature = "mobile-social")] let builder = { @@ -48,6 +51,14 @@ impl RadrootsRuntime { } let builder = builder .nostr(nostr_slot) + .blossom({ + let slot = radroots_sdk::transport::BlossomSlot::new(); + if let Some(config) = blossom_config { + slot.configure(config) + .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?; + } + slot + }) .host_sync(radroots_sdk::sync::HostPolicy::standard()); match signer { Some(signer) => builder.signing(radroots_sdk::signing::Provider::host(signer)), @@ -74,6 +85,8 @@ impl RadrootsRuntime { None, #[cfg(feature = "mobile-social")] None, + #[cfg(feature = "mobile-social")] + None, ) } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -34,8 +34,9 @@ pub use model::{ }; #[cfg(feature = "mobile-social")] pub use outbox::{ - Phase1CancellationPolicy, Phase1DraftError, Phase1DraftStatus, Phase1MediaPrerequisite, - Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, Phase1RelaySatisfaction, + Phase1CancellationPolicy, Phase1DraftError, Phase1DraftStatus, Phase1MediaOrphanRecord, + Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy, + Phase1RelaySatisfaction, }; pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; diff --git a/core/crates/tera_core/src/runtime/product_surface/outbox.rs b/core/crates/tera_core/src/runtime/product_surface/outbox.rs @@ -1,6 +1,8 @@ use std::collections::BTreeSet; -use radroots_blossom::{BlobUrl, MediaType, authorization::AuthoredUploadClaim}; +use radroots_blossom::{ + BlobUrl, ByteVerifiedDescriptor, MediaType, authorization::AuthoredUploadClaim, +}; use radroots_event::contract::AuthorRole; use radroots_event_codec::authoring::PlanWireV1; use radroots_identity::PublicKey; @@ -64,37 +66,50 @@ pub struct Phase1MediaPrerequisite { byte_size: u64, stage: Phase1MediaStage, failure_code: Option<String>, + upload_attempts: u8, + verified_at_unix_ms: Option<u64>, + orphan: Option<Phase1MediaOrphanRecord>, +} + +/// Durable, secret-safe evidence that a remote blob may be unreferenced. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Phase1MediaOrphanRecord { + reason_code: String, + recorded_at_unix_ms: u64, +} + +impl Phase1MediaOrphanRecord { + pub fn reason_code(&self) -> &str { + self.reason_code.as_str() + } + + pub const fn recorded_at_unix_ms(&self) -> u64 { + self.recorded_at_unix_ms + } } impl Phase1MediaPrerequisite { pub fn new( local_reference: impl Into<String>, - url: impl Into<String>, - sha256: impl Into<String>, - media_type: impl Into<String>, - byte_size: u64, - stage: Phase1MediaStage, + descriptor: &ByteVerifiedDescriptor, ) -> Result<Self, Phase1DraftError> { let value = Self { local_reference: local_reference.into(), - url: url.into(), - sha256: sha256.into(), - media_type: media_type.into(), - byte_size, - stage, + url: descriptor.url().as_blob_url().as_str().to_owned(), + sha256: descriptor.sha256().to_hex(), + media_type: descriptor.media_type().as_str().to_owned(), + byte_size: descriptor.size(), + stage: Phase1MediaStage::Pending, failure_code: None, + upload_attempts: 0, + verified_at_unix_ms: None, + orphan: None, }; value.validate()?; Ok(value) } - pub fn with_failure_code(mut self, code: impl Into<String>) -> Result<Self, Phase1DraftError> { - self.stage = Phase1MediaStage::Failed; - self.failure_code = Some(code.into()); - self.validate()?; - Ok(self) - } - fn validate(&self) -> Result<(), Phase1DraftError> { let blob = BlobUrl::parse(self.url.as_str()).map_err(|_| Phase1DraftError::InvalidMedia)?; let hash = blob.hash_path().hash().to_string(); @@ -111,7 +126,36 @@ impl Phase1MediaPrerequisite { || code != code.trim() || code.chars().any(char::is_control) }) - || (self.stage == Phase1MediaStage::Failed) != self.failure_code.is_some() + || self.orphan.as_ref().is_some_and(|record| { + record.reason_code.is_empty() + || record.reason_code.len() > DRAFT_FAILURE_CODE_MAX_BYTES + || record.reason_code != record.reason_code.trim() + || record.reason_code.chars().any(char::is_control) + || record.recorded_at_unix_ms == 0 + }) + || match self.stage { + Phase1MediaStage::Pending | Phase1MediaStage::Preparing => { + self.failure_code.is_some() + || self.upload_attempts != 0 + || self.verified_at_unix_ms.is_some() + || self.orphan.is_some() + } + Phase1MediaStage::Uploading => { + self.failure_code.is_some() + || self.verified_at_unix_ms.is_some() + || self.orphan.is_some() + } + Phase1MediaStage::Verified => { + self.failure_code.is_some() + || self.upload_attempts == 0 + || self.verified_at_unix_ms.is_none() + || self.orphan.is_some() + } + Phase1MediaStage::Failed => { + self.failure_code.is_none() || self.verified_at_unix_ms.is_some() + } + Phase1MediaStage::Orphaned => self.failure_code.is_some() || self.orphan.is_none(), + } { return Err(Phase1DraftError::InvalidMedia); } @@ -124,6 +168,34 @@ impl Phase1MediaPrerequisite { pub const fn stage(&self) -> Phase1MediaStage { self.stage } + + pub const fn upload_attempts(&self) -> u8 { + self.upload_attempts + } + + pub const fn verified_at_unix_ms(&self) -> Option<u64> { + self.verified_at_unix_ms + } + + pub const fn orphan(&self) -> Option<&Phase1MediaOrphanRecord> { + self.orphan.as_ref() + } + + fn matches_receipt(&self, receipt: &radroots_sdk::transport::BlossomUploadReceipt) -> bool { + let descriptor = receipt.descriptor(); + self.url == descriptor.url().as_blob_url().as_str() + && self.sha256 == descriptor.sha256().to_hex() + && self.media_type == descriptor.media_type().as_str() + && self.byte_size == descriptor.size() + && receipt.attempts() > 0 + && receipt.verified_at_unix_ms() > 0 + } + + fn is_remote_verified(&self) -> bool { + self.stage == Phase1MediaStage::Verified + && self.upload_attempts > 0 + && self.verified_at_unix_ms.is_some() + } } /// Closed delivery-satisfaction profiles available to Phase 1 Add. @@ -505,11 +577,19 @@ impl RadrootsRuntime { .iter_mut() .find(|media| media.url == url) .ok_or(Phase1DraftError::InvalidMedia)?; - if !valid_media_transition(media.stage, stage) { + if !valid_media_transition(media.stage, stage) + || matches!( + stage, + Phase1MediaStage::Verified | Phase1MediaStage::Orphaned + ) + { return Err(Phase1DraftError::InvalidMedia); } media.stage = stage; media.failure_code = failure_code; + if stage != Phase1MediaStage::Failed { + media.failure_code = None; + } media.validate()?; let next_stage = match stage { Phase1MediaStage::Pending | Phase1MediaStage::Preparing => { @@ -530,6 +610,134 @@ impl RadrootsRuntime { self.draft_status_from(receipt.draft().clone()).await } + /// Records the only proof that can advance media to remote-byte-verified. + pub async fn phase1_complete_draft_media( + &self, + draft_id: [u8; 16], + expected_revision: u64, + url: &str, + receipt: radroots_sdk::transport::BlossomUploadReceipt, + updated_at_unix_ms: 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 storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected + || head.stage().is_terminal() + || matches!( + head.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) + { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let media = payload + .media + .iter_mut() + .find(|media| media.url == url) + .ok_or(Phase1DraftError::InvalidMedia)?; + if media.stage != Phase1MediaStage::Uploading || !media.matches_receipt(&receipt) { + return Err(Phase1DraftError::InvalidMedia); + } + media.stage = Phase1MediaStage::Verified; + media.failure_code = None; + media.upload_attempts = receipt.attempts(); + media.verified_at_unix_ms = Some(receipt.verified_at_unix_ms()); + media.orphan = None; + media.validate()?; + let next = head + .successor( + payload.encode()?, + AuthoredDraftStage::MediaUploading, + None, + updated_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next, Some(expected)) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + + /// Persists a redacted recoverable Blossom failure and possible-orphan evidence. + pub async fn phase1_fail_draft_media( + &self, + draft_id: [u8; 16], + expected_revision: u64, + url: &str, + error: &radroots_sdk::transport::BlossomError, + updated_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + if updated_at_unix_ms == 0 { + return Err(Phase1DraftError::InvalidMedia); + } + let draft_id = + AuthoredDraftId::new(draft_id).map_err(|_| Phase1DraftError::InvalidDraft)?; + let expected = AuthoredDraftRevision::new(expected_revision) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let storage = self.storage()?; + let head = storage + .authored_draft_head(draft_id) + .await + .map_err(|_| Phase1DraftError::Storage)? + .ok_or(Phase1DraftError::NotFound)?; + if head.revision() != expected + || head.stage().is_terminal() + || matches!( + head.stage(), + AuthoredDraftStage::ReadyToSign | AuthoredDraftStage::Queued + ) + { + return Err(Phase1DraftError::RevisionConflict); + } + let mut payload = Phase1DraftPayload::decode(&head)?; + let media = payload + .media + .iter_mut() + .find(|media| media.url == url) + .ok_or(Phase1DraftError::InvalidMedia)?; + if !matches!( + media.stage, + Phase1MediaStage::Pending + | Phase1MediaStage::Preparing + | Phase1MediaStage::Uploading + | Phase1MediaStage::Failed + ) { + return Err(Phase1DraftError::InvalidMedia); + } + media.stage = Phase1MediaStage::Failed; + media.failure_code = Some(error.code().to_owned()); + media.upload_attempts = error.attempts(); + media.verified_at_unix_ms = None; + media.orphan = error.possible_orphan().then(|| Phase1MediaOrphanRecord { + reason_code: error.code().to_owned(), + recorded_at_unix_ms: updated_at_unix_ms, + }); + media.validate()?; + let next = head + .successor( + payload.encode()?, + AuthoredDraftStage::MediaUploading, + None, + updated_at_unix_ms, + ) + .map_err(|_| Phase1DraftError::RevisionConflict)?; + let receipt = storage + .append_authored_draft(next, Some(expected)) + .await + .map_err(map_draft_storage_error)?; + self.draft_status_from(receipt.draft().clone()).await + } + /// Freezes queue intent and atomically prepares the canonical outbox before /// returning `queued`. Network connectivity is neither read nor required. pub async fn phase1_queue_draft( @@ -573,7 +781,7 @@ impl RadrootsRuntime { if payload .media .iter() - .any(|media| media.stage != Phase1MediaStage::Verified) + .any(|media| !media.is_remote_verified()) { return Err(Phase1DraftError::MediaNotReady); } @@ -700,6 +908,115 @@ impl RadrootsRuntime { .map_err(|_| Phase1DraftError::Operation) } + /// Runs the complete durable BUD-11/BUD-02/BUD-01 media transaction. + /// + /// Final image bytes are bound before authorization. The draft becomes + /// verified only after the upload descriptor and a full retrieval agree. + #[allow(clippy::too_many_arguments)] + pub async fn phase1_upload_draft_media( + &self, + draft_id: [u8; 16], + expected_revision: u64, + request: radroots_sdk::transport::BlossomUploadRequest, + authorization_content: radroots_blossom::authorization::AuthorizationContent, + authorization_created_at_unix_s: u64, + authorization_lifetime_seconds: u64, + operation_id: [u8; 16], + artifact_id: [u8; 16], + signing_deadline_unix_ms: u64, + signing_cancellation: Phase1CancellationPolicy, + transfer_cancellation: radroots_sdk::transport::BlossomCancellation, + updated_at_unix_ms: u64, + ) -> Result<Phase1DraftStatus, Phase1DraftError> { + let url = request.expected_url().as_str().to_owned(); + let uploading = self + .phase1_update_draft_media( + draft_id, + expected_revision, + url.as_str(), + Phase1MediaStage::Uploading, + None, + updated_at_unix_ms, + ) + .await?; + let revision = uploading.draft().revision().get(); + let blossom = self + .client + .blossom() + .map_err(|_| Phase1DraftError::OperationUnavailable)? + .ok_or(Phase1DraftError::OperationUnavailable)?; + let claim = match blossom.authored_upload_claim( + &request, + authorization_content, + authorization_created_at_unix_s, + authorization_lifetime_seconds, + ) { + Ok(claim) => claim, + Err(_) => { + self.phase1_update_draft_media( + draft_id, + revision, + url.as_str(), + Phase1MediaStage::Failed, + Some("blossom_authorization_failed".to_owned()), + updated_at_unix_ms, + ) + .await?; + return Err(Phase1DraftError::Operation); + } + }; + let authorization = match self + .phase1_authorize_blossom_upload( + operation_id, + artifact_id, + claim, + signing_deadline_unix_ms, + signing_cancellation, + ) + .await + { + Ok(authorization) => authorization, + Err(error) => { + self.phase1_update_draft_media( + draft_id, + revision, + url.as_str(), + Phase1MediaStage::Failed, + Some("blossom_authorization_failed".to_owned()), + updated_at_unix_ms, + ) + .await?; + return Err(error); + } + }; + match blossom + .upload(request, authorization, transfer_cancellation) + .await + { + Ok(receipt) => { + self.phase1_complete_draft_media( + draft_id, + revision, + url.as_str(), + receipt, + updated_at_unix_ms, + ) + .await + } + Err(error) => { + self.phase1_fail_draft_media( + draft_id, + revision, + url.as_str(), + &error, + updated_at_unix_ms, + ) + .await?; + Err(Phase1DraftError::Operation) + } + } + } + /// Returns durable draft state composed with canonical authored-operation state. pub async fn phase1_draft_status( &self, @@ -765,10 +1082,10 @@ impl RadrootsRuntime { .await .map_err(|_| Phase1DraftError::Operation)?; if status.artifact().signed().is_none() { - mark_possible_orphans(&mut payload.media); + mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms); } } else { - mark_possible_orphans(&mut payload.media); + mark_possible_orphans(&mut payload.media, cancelled_at_unix_ms); } let next = head .successor( @@ -990,10 +1307,15 @@ fn draft_stage_for_media(media: &[Phase1MediaPrerequisite]) -> AuthoredDraftStag } } -fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite]) { +fn mark_possible_orphans(media: &mut [Phase1MediaPrerequisite], recorded_at_unix_ms: u64) { for media in media { - if media.stage == Phase1MediaStage::Verified { + if media.stage == Phase1MediaStage::Verified || media.orphan.is_some() { media.stage = Phase1MediaStage::Orphaned; + media.failure_code = None; + media.orphan = Some(Phase1MediaOrphanRecord { + reason_code: "draft_cancelled_after_upload".to_owned(), + recorded_at_unix_ms, + }); } } } @@ -1180,6 +1502,7 @@ mod tests { Some(PublicKey::from_hex(AUTHOR).unwrap()), None, None, + None, ) .unwrap() } @@ -1194,6 +1517,7 @@ mod tests { Some(PublicKey::from_hex(AUTHOR).unwrap()), Some(std::sync::Arc::new(signer)), None, + None, ) .unwrap() } @@ -1437,11 +1761,13 @@ mod tests { } #[tokio::test] - async fn media_phase_revisions_gate_queue_and_record_possible_orphans() { + async fn media_phase_revisions_gate_queue_and_reject_forged_verification() { let runtime = runtime(); let id = [9; 16]; let (command, mut media) = photo_command(); media.stage = Phase1MediaStage::Pending; + media.upload_attempts = 0; + media.verified_at_unix_ms = None; media.validate().unwrap(); let saved = runtime .phase1_save_draft(id, command, 1_784_347_200, vec![media], None, 30) @@ -1477,41 +1803,27 @@ mod tests { ) .await .unwrap(); - let verified = runtime - .phase1_update_draft_media( - id, - 3, - uploading.media()[0].url(), - Phase1MediaStage::Verified, - None, - 33, - ) - .await - .unwrap(); - assert_eq!(verified.media()[0].stage(), Phase1MediaStage::Verified); assert_eq!( runtime .phase1_update_draft_media( id, - 4, - verified.media()[0].url(), - Phase1MediaStage::Uploading, + 3, + uploading.media()[0].url(), + Phase1MediaStage::Verified, None, - 34, + 33, ) .await .unwrap_err(), Phase1DraftError::InvalidMedia ); - let queued = runtime - .phase1_queue_draft(id, 4, policy(), 34) - .await - .unwrap(); - let cancelled = runtime - .phase1_cancel_draft(id, queued.draft().revision().get(), 35) - .await - .unwrap(); - assert_eq!(cancelled.media()[0].stage(), Phase1MediaStage::Orphaned); + assert_eq!( + runtime + .phase1_queue_draft(id, 3, policy(), 34) + .await + .unwrap_err(), + Phase1DraftError::MediaNotReady + ); assert_eq!(runtime.phase1_draft_heads(10).await.unwrap().len(), 1); } @@ -1554,6 +1866,8 @@ mod tests { .unwrap() .verify_bytes(bytes, &media_type) .unwrap(); + let mut prerequisite = + Phase1MediaPrerequisite::new("protected://draft/photo-1", &descriptor).unwrap(); let image = AuthoredPostImage::new( AuthoredImage::try_from(descriptor).unwrap(), PostImageDimensions::new(1200, 900).unwrap(), @@ -1563,15 +1877,10 @@ mod tests { let command = Phase1AddCommand::CreatePhotoUpdate( CreatePhotoUpdate::new(format!("Harvest photo {url}"), vec![image]).unwrap(), ); - let prerequisite = Phase1MediaPrerequisite::new( - "protected://draft/photo-1", - url, - hash.to_hex(), - "image/webp", - bytes.len() as u64, - Phase1MediaStage::Verified, - ) - .unwrap(); + prerequisite.stage = Phase1MediaStage::Verified; + prerequisite.upload_attempts = 2; + prerequisite.verified_at_unix_ms = Some(1_784_347_100_000); + prerequisite.validate().unwrap(); (command, prerequisite) } diff --git a/core/crates/tera_core/src/runtime/sdk.rs b/core/crates/tera_core/src/runtime/sdk.rs @@ -122,6 +122,67 @@ impl RadrootsRuntime { .map_err(RadrootsAppError::from_sdk) } + /// Installs explicit public TLS Blossom origins without probing them. + #[cfg(feature = "mobile-social")] + pub fn configure_public_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { + self.configure_blossom_profile( + radroots_sdk::transport::BlossomProfile::public(origins) + .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, + ) + } + + /// Installs exact-loopback simulator Blossom origins without probing them. + #[cfg(feature = "mobile-social")] + pub fn configure_simulator_blossom( + &self, + origins: Vec<String>, + ) -> Result<(), RadrootsAppError> { + self.configure_blossom_profile( + radroots_sdk::transport::BlossomProfile::simulator(origins) + .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, + ) + } + + /// Installs explicit physical-device TLS Blossom origins without probing them. + #[cfg(feature = "mobile-social")] + pub fn configure_device_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> { + self.configure_blossom_profile( + radroots_sdk::transport::BlossomProfile::device(origins) + .map_err(|error| RadrootsAppError::runtime(error.code().to_owned()))?, + ) + } + + #[cfg(feature = "mobile-social")] + fn configure_blossom_profile( + &self, + profile: radroots_sdk::transport::BlossomProfile, + ) -> Result<(), RadrootsAppError> { + self.client + .configure_blossom(radroots_sdk::transport::BlossomConfig::from_profile( + profile, + )) + .map_err(RadrootsAppError::from_sdk) + } + + /// Returns the inert Blossom environment profile, when configured. + #[cfg(feature = "mobile-social")] + pub fn sdk_blossom_profile(&self) -> Result<Option<String>, RadrootsAppError> { + let profile = self + .client + .blossom() + .map_err(RadrootsAppError::from_sdk)? + .and_then(radroots_sdk::transport::BlossomSlot::profile_kind); + Ok(profile.map(|profile| { + match profile { + radroots_sdk::transport::BlossomProfileKind::Public => "public", + radroots_sdk::transport::BlossomProfileKind::Simulator => "simulator_local", + radroots_sdk::transport::BlossomProfileKind::Device => "device_development", + _ => "unknown", + } + .to_owned() + })) + } + /// Returns passive relay evidence without DNS, socket, or probe work. #[cfg(feature = "mobile-social")] pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> {