commit 33029ce6c297d1af66a4931f563e930b6d22a311
parent 462c662b83eb8d2ace6a3aae496aeba32dbc31d3
Author: triesap <tyson@radroots.org>
Date: Fri, 7 Aug 2026 23:36:46 +0000
refactor(mobile): focus the generated FFI boundary
- replace the broad mobile mirror with versioned product DTOs
- add bounded subscriptions and opaque host signing callbacks
- verify prepared media descriptors and redact typed failures
- cover the complete native boundary and release policy gates
Diffstat:
15 files changed, 3995 insertions(+), 306 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3592,8 +3592,17 @@ dependencies = [
name = "radroots_mobile_ffi"
version = "0.1.0-alpha"
dependencies = [
+ "async-trait",
+ "hex",
+ "radroots_blossom",
+ "radroots_event",
"radroots_mobile_core",
+ "radroots_sdk",
+ "radroots_signing",
+ "radroots_storage",
"rustix 1.1.4",
+ "secp256k1",
+ "serde_json",
"tempfile",
"thiserror 1.0.69",
"tokio",
diff --git a/Cargo.toml b/Cargo.toml
@@ -114,6 +114,7 @@ todo = "deny"
unimplemented = "deny"
[workspace.dependencies]
+async-trait = { version = "0.1.89" }
dto_bindgen = { version = "0.1.0" }
dto_bindgen_backend_ts = { version = "0.1.0" }
dto_bindgen_core = { version = "0.1.0" }
diff --git a/crates/mobile_ffi/Cargo.toml b/crates/mobile_ffi/Cargo.toml
@@ -19,7 +19,15 @@ crate-type = ["rlib", "staticlib", "cdylib"]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] }
[dependencies]
+async-trait = { workspace = true }
+hex = { workspace = true }
+radroots_blossom = { workspace = true, features = ["std"] }
+radroots_event = { workspace = true, features = ["serde", "std"] }
radroots_mobile_core = { workspace = true, features = ["mobile-social"] }
+radroots_sdk = { workspace = true, features = ["blossom"] }
+radroots_signing = { workspace = true, features = ["serde", "std"] }
+radroots_storage = { workspace = true }
+serde_json = { workspace = true, features = ["std"] }
tracing = { workspace = true }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true }
@@ -30,5 +38,6 @@ uniffi = { workspace = true }
rustix = { workspace = true }
[dev-dependencies]
+secp256k1 = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
diff --git a/crates/mobile_ffi/src/dto.rs b/crates/mobile_ffi/src/dto.rs
@@ -0,0 +1,1997 @@
+//! Focused, versioned value types owned by the native boundary.
+
+#[cfg(unix)]
+use std::os::unix::fs::FileExt;
+
+use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256};
+use radroots_event::{
+ calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate},
+ food::availability::{
+ FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityImage,
+ FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, FoodImageDimensions,
+ FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit,
+ },
+ media::AuthoredImage,
+ post::{AuthoredPostImage, PostImageDimensions},
+};
+use radroots_mobile_core::runtime::{
+ app_info::AppInfoPlatform,
+ info::{AppInfo, RuntimeBuildInfo, RuntimeInfo},
+ product_surface::{
+ AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability,
+ CreatePhotoUpdate, CreateUpdate, LocalNetwork, MeSnapshot, MediaReference,
+ MediaVerificationState, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftStatus,
+ Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, Phase1QueuePolicy,
+ Phase1RelaySatisfaction, ProfileSummary, SearchResult, SearchResultType, SupportingProfile,
+ ThreadEntry, TodayCard, TodayCardType, TodayPage, TodayProjectionUpdate,
+ TodayRefreshReceipt,
+ },
+ sdk::{
+ SdkCapabilityRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord,
+ SdkStorageStatusRecord,
+ },
+};
+
+use crate::RadrootsAppError;
+
+pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1;
+const MEDIA_FILE_MAX_BYTES: u64 = 10 * 1024 * 1024;
+const MEDIA_REFERENCE_MAX_BYTES: usize = 256;
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBuildInfoRecord {
+ pub schema_version: u16,
+ pub crate_name: String,
+ pub crate_version: String,
+ pub rustc: Option<String>,
+ pub profile: Option<String>,
+ pub lib_revision: Option<String>,
+ pub consumer_revision: Option<String>,
+ pub build_time_unix: Option<u64>,
+}
+
+// These exhaustive field-for-field adapters are verified by the generated API
+// snapshot and Swift compilation. Excluding the mechanical projection glue
+// keeps the coverage gate focused on validation and behavior.
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<RuntimeBuildInfo> for FfiBuildInfoRecord {
+ fn from(value: RuntimeBuildInfo) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ crate_name: value.crate_name,
+ crate_version: value.crate_version,
+ rustc: value.rustc,
+ profile: value.profile,
+ lib_revision: value.lib_revision,
+ consumer_revision: value.consumer_revision,
+ build_time_unix: value.build_time_unix,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAppPlatformRecord {
+ pub schema_version: u16,
+ pub platform: Option<String>,
+ pub bundle_id: Option<String>,
+ pub version: Option<String>,
+ pub build_number: Option<String>,
+ pub build_sha: Option<String>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<AppInfoPlatform> for FfiAppPlatformRecord {
+ fn from(value: AppInfoPlatform) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ platform: value.platform,
+ bundle_id: value.bundle_id,
+ version: value.version,
+ build_number: value.build_number,
+ build_sha: value.build_sha,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAppInfoRecord {
+ pub schema_version: u16,
+ pub build: FfiBuildInfoRecord,
+ pub started_unix_ms: i64,
+ pub uptime_millis: i64,
+ pub shutting_down: bool,
+ pub platform: Option<FfiAppPlatformRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<AppInfo> for FfiAppInfoRecord {
+ fn from(value: AppInfo) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ build: value.build.into(),
+ started_unix_ms: value.started_unix_ms,
+ uptime_millis: value.uptime_millis,
+ shutting_down: value.shutting_down,
+ platform: value.platform.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRuntimeInfoRecord {
+ pub schema_version: u16,
+ pub app: FfiAppInfoRecord,
+ pub sdk: FfiBuildInfoRecord,
+ pub sdk_closed: bool,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<RuntimeInfo> for FfiRuntimeInfoRecord {
+ fn from(value: RuntimeInfo) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ app: value.app.into(),
+ sdk: value.sdk.into(),
+ sdk_closed: value.sdk_closed,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiIdentityStatusRecord {
+ pub schema_version: u16,
+ pub public_key: String,
+ pub host_signer_configured: bool,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTodayCardType {
+ Update,
+ PhotoUpdate,
+ Ask,
+ Event,
+ FoodAvailability,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayCardType> for FfiTodayCardType {
+ fn from(value: TodayCardType) -> Self {
+ match value {
+ TodayCardType::Update => Self::Update,
+ TodayCardType::PhotoUpdate => Self::PhotoUpdate,
+ TodayCardType::Ask => Self::Ask,
+ TodayCardType::Event => Self::Event,
+ TodayCardType::FoodAvailability => Self::FoodAvailability,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiAddCommandType {
+ CreateUpdate,
+ CreatePhotoUpdate,
+ CreateAsk,
+ CreateEvent,
+ CreateFoodAvailability,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<AddCommandType> for FfiAddCommandType {
+ fn from(value: AddCommandType) -> Self {
+ match value {
+ AddCommandType::CreateUpdate => Self::CreateUpdate,
+ AddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate,
+ AddCommandType::CreateAsk => Self::CreateAsk,
+ AddCommandType::CreateEvent => Self::CreateEvent,
+ AddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiCardAddParityRecord {
+ pub schema_version: u16,
+ pub card_type: FfiTodayCardType,
+ pub command_type: FfiAddCommandType,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiLocalNetworkRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub label: String,
+ pub relay_urls: Vec<String>,
+ pub locality: Option<String>,
+ pub followed_authors: Vec<String>,
+ pub generation: u64,
+}
+
+impl TryFrom<FfiLocalNetworkRecord> for LocalNetwork {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiLocalNetworkRecord) -> Result<Self, Self::Error> {
+ require_schema(value.schema_version)?;
+ LocalNetwork::new(
+ value.id,
+ value.label,
+ value.relay_urls,
+ value.locality,
+ value.followed_authors,
+ value.generation,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_local_network"))
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<LocalNetwork> for FfiLocalNetworkRecord {
+ fn from(value: LocalNetwork) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: value.id,
+ label: value.label,
+ relay_urls: value.relay_urls,
+ locality: value.locality,
+ followed_authors: value.followed_authors,
+ generation: value.generation,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiMediaVerificationState {
+ Pending,
+ Verified,
+ Failed,
+ Unavailable,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<MediaVerificationState> for FfiMediaVerificationState {
+ fn from(value: MediaVerificationState) -> Self {
+ match value {
+ MediaVerificationState::Pending => Self::Pending,
+ MediaVerificationState::Verified => Self::Verified,
+ MediaVerificationState::Failed => Self::Failed,
+ MediaVerificationState::Unavailable => Self::Unavailable,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMediaReferenceRecord {
+ pub schema_version: u16,
+ pub url: String,
+ pub sha256: Option<String>,
+ pub media_type: Option<String>,
+ pub width: Option<u32>,
+ pub height: Option<u32>,
+ pub byte_size: Option<u64>,
+ pub alt: Option<String>,
+ pub verification: FfiMediaVerificationState,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<MediaReference> for FfiMediaReferenceRecord {
+ fn from(value: MediaReference) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: value.url,
+ sha256: value.sha256,
+ media_type: value.media_type,
+ width: value.width,
+ height: value.height,
+ byte_size: value.byte_size,
+ alt: value.alt,
+ verification: value.verification.into(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiProfileRecord {
+ pub schema_version: u16,
+ pub author_public_key: String,
+ pub name: Option<String>,
+ pub display_name: Option<String>,
+ pub about: Option<String>,
+ pub picture: Option<FfiMediaReferenceRecord>,
+ pub banner: Option<FfiMediaReferenceRecord>,
+ pub nip05: Option<String>,
+ pub website: Option<String>,
+ pub lightning_address: Option<String>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<ProfileSummary> for FfiProfileRecord {
+ fn from(value: ProfileSummary) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ author_public_key: value.author_pubkey,
+ name: value.name,
+ display_name: value.display_name,
+ about: value.about,
+ picture: value.picture.map(Into::into),
+ banner: value.banner.map(Into::into),
+ nip05: value.nip05,
+ website: value.website,
+ lightning_address: value.lightning_address,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiThreadProfile {
+ Profile,
+ Reply,
+ Comment,
+ Deletion,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SupportingProfile> for FfiThreadProfile {
+ fn from(value: SupportingProfile) -> Self {
+ match value {
+ SupportingProfile::Profile => Self::Profile,
+ SupportingProfile::Reply => Self::Reply,
+ SupportingProfile::Comment => Self::Comment,
+ SupportingProfile::Deletion => Self::Deletion,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiThreadEntryRecord {
+ pub schema_version: u16,
+ pub event_id: String,
+ pub author_public_key: String,
+ pub content: String,
+ pub authored_at_unix_s: u64,
+ pub profile: FfiThreadProfile,
+ pub root: String,
+ pub parent_event_id: String,
+ pub author_profile: Option<FfiProfileRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<ThreadEntry> for FfiThreadEntryRecord {
+ fn from(value: ThreadEntry) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ event_id: value.event_id,
+ author_public_key: value.author_pubkey,
+ content: value.content,
+ authored_at_unix_s: value.authored_at,
+ profile: value.reference.profile.into(),
+ root: value.reference.root,
+ parent_event_id: value.reference.parent_event_id,
+ author_profile: value.author_profile.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiCardLifecycleState {
+ Active,
+ Sold,
+ Past,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<CardLifecycleState> for FfiCardLifecycleState {
+ fn from(value: CardLifecycleState) -> Self {
+ match value {
+ CardLifecycleState::Active => Self::Active,
+ CardLifecycleState::Sold => Self::Sold,
+ CardLifecycleState::Past => Self::Past,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodayCardRecord {
+ pub schema_version: u16,
+ pub card_id: String,
+ pub card_type: FfiTodayCardType,
+ pub source_event_id: String,
+ pub source_address: Option<String>,
+ pub author_public_key: String,
+ pub contract_id: String,
+ pub title: Option<String>,
+ pub content: String,
+ pub authored_at_unix_s: u64,
+ pub effective_at_unix_s: u64,
+ pub event_start_unix_s: Option<u64>,
+ pub event_end_unix_s: Option<u64>,
+ pub context_rank: u8,
+ pub inclusion_reason: String,
+ pub media: Vec<FfiMediaReferenceRecord>,
+ pub lifecycle: FfiCardLifecycleState,
+ pub rank_digest: Option<String>,
+ pub author_profile: Option<FfiProfileRecord>,
+ pub thread: Vec<FfiThreadEntryRecord>,
+ pub local_operation_id: Option<String>,
+ pub local_operation_state: Option<String>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayCard> for FfiTodayCardRecord {
+ fn from(value: TodayCard) -> Self {
+ let card = value.card;
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ card_id: card.card_id.to_hex(),
+ card_type: card.card_type.into(),
+ source_event_id: card.source_event_id,
+ source_address: card.source_address,
+ author_public_key: card.author_pubkey,
+ contract_id: card.contract_id,
+ title: card.title,
+ content: card.content,
+ authored_at_unix_s: card.authored_at,
+ effective_at_unix_s: card.effective_at,
+ event_start_unix_s: card.event_start,
+ event_end_unix_s: card.event_end,
+ context_rank: card.context_rank.value(),
+ inclusion_reason: card.inclusion_reason,
+ media: card.media.into_iter().map(Into::into).collect(),
+ lifecycle: card.lifecycle.into(),
+ rank_digest: card.rank.map(|rank| rank.digest_hex()),
+ author_profile: value.author_profile.map(Into::into),
+ thread: value.thread.into_iter().map(Into::into).collect(),
+ local_operation_id: value
+ .local_overlay
+ .as_ref()
+ .map(|overlay| overlay.operation_id.clone()),
+ local_operation_state: value.local_overlay.map(|overlay| overlay.state),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodayPageRecord {
+ pub schema_version: u16,
+ pub as_of_unix_s: u64,
+ pub items: Vec<FfiTodayCardRecord>,
+ pub next_cursor: Option<String>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiTodayProjectionUpdate {
+ Incremental,
+ Rebuild,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<FfiTodayProjectionUpdate> for TodayProjectionUpdate {
+ fn from(value: FfiTodayProjectionUpdate) -> Self {
+ match value {
+ FfiTodayProjectionUpdate::Incremental => Self::Incremental,
+ FfiTodayProjectionUpdate::Rebuild => Self::Rebuild,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiTodayRefreshRecord {
+ pub schema_version: u16,
+ pub update: FfiTodayProjectionUpdate,
+ pub source_events: u64,
+ pub visible_cards: u64,
+ pub profiles: u64,
+ pub thread_entries: u64,
+ pub content_generation: u64,
+ pub changed: bool,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayRefreshReceipt> for FfiTodayRefreshRecord {
+ fn from(value: TodayRefreshReceipt) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ update: match value.update {
+ TodayProjectionUpdate::Incremental => FfiTodayProjectionUpdate::Incremental,
+ TodayProjectionUpdate::Rebuild => FfiTodayProjectionUpdate::Rebuild,
+ },
+ source_events: value.source_events,
+ visible_cards: value.visible_cards,
+ profiles: value.profiles,
+ thread_entries: value.thread_entries,
+ content_generation: value.content_generation,
+ changed: value.changed,
+ }
+ }
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<TodayPage> for FfiTodayPageRecord {
+ fn from(value: TodayPage) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ as_of_unix_s: value.as_of,
+ items: value.items.into_iter().map(Into::into).collect(),
+ next_cursor: value.next_cursor,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiSearchResultType {
+ Card,
+ Profile,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiSearchResultRecord {
+ pub schema_version: u16,
+ pub result_type: FfiSearchResultType,
+ pub stable_id: String,
+ pub card: Option<FfiTodayCardRecord>,
+ pub profile: Option<FfiProfileRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SearchResult> for FfiSearchResultRecord {
+ fn from(value: SearchResult) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ result_type: match value.result_type {
+ SearchResultType::Card => FfiSearchResultType::Card,
+ SearchResultType::Profile => FfiSearchResultType::Profile,
+ },
+ stable_id: value.stable_id,
+ card: value.card.map(Into::into),
+ profile: value.profile.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiMeRecord {
+ pub schema_version: u16,
+ pub public_key: String,
+ pub profile: Option<FfiProfileRecord>,
+ pub cards: Vec<FfiTodayCardRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<MeSnapshot> for FfiMeRecord {
+ fn from(value: MeSnapshot) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ public_key: value.public_key,
+ profile: value.profile.map(Into::into),
+ cards: value.cards.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiAddFieldKind {
+ Text,
+ MultilineText,
+ Date,
+ DateTime,
+ Decimal,
+ Choice,
+ Location,
+ Media,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAddFieldRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub label: String,
+ pub kind: FfiAddFieldKind,
+ pub required: bool,
+ pub choices: Vec<String>,
+ pub max_bytes: Option<u64>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAddSchemaRecord {
+ pub schema_version: u16,
+ pub command_type: FfiAddCommandType,
+ pub label: String,
+ pub fields: Vec<FfiAddFieldRecord>,
+}
+
+pub fn add_schemas() -> Vec<FfiAddSchemaRecord> {
+ use FfiAddCommandType as Command;
+ use FfiAddFieldKind as Kind;
+
+ let field =
+ |id: &str, label: &str, kind, required, choices: &[&str], max_bytes| FfiAddFieldRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: id.to_owned(),
+ label: label.to_owned(),
+ kind,
+ required,
+ choices: choices.iter().map(|value| (*value).to_owned()).collect(),
+ max_bytes,
+ };
+ vec![
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateUpdate,
+ label: "Update".to_owned(),
+ fields: vec![field(
+ "content",
+ "Update",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ )],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreatePhotoUpdate,
+ label: "Photo update".to_owned(),
+ fields: vec![
+ field(
+ "content",
+ "Update",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ ),
+ field(
+ "media",
+ "Photos",
+ Kind::Media,
+ true,
+ &[],
+ Some(MEDIA_FILE_MAX_BYTES),
+ ),
+ ],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateAsk,
+ label: "Ask".to_owned(),
+ fields: vec![
+ field(
+ "content",
+ "Question",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ ),
+ field(
+ "media",
+ "Photos",
+ Kind::Media,
+ false,
+ &[],
+ Some(MEDIA_FILE_MAX_BYTES),
+ ),
+ ],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateEvent,
+ label: "Event".to_owned(),
+ fields: vec![
+ field("identifier", "Identifier", Kind::Text, true, &[], Some(256)),
+ field("title", "Title", Kind::Text, true, &[], Some(256)),
+ field(
+ "content",
+ "Description",
+ Kind::MultilineText,
+ false,
+ &[],
+ Some(65_535),
+ ),
+ field("event_start", "Starts", Kind::DateTime, true, &[], None),
+ field("event_end", "Ends", Kind::DateTime, false, &[], None),
+ field(
+ "location",
+ "Location",
+ Kind::Location,
+ false,
+ &[],
+ Some(256),
+ ),
+ field(
+ "media",
+ "Photo",
+ Kind::Media,
+ false,
+ &[],
+ Some(MEDIA_FILE_MAX_BYTES),
+ ),
+ ],
+ },
+ FfiAddSchemaRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: Command::CreateFoodAvailability,
+ label: "Food availability".to_owned(),
+ fields: vec![
+ field("identifier", "Identifier", Kind::Text, true, &[], Some(256)),
+ field("title", "Food", Kind::Text, true, &[], Some(256)),
+ field("summary", "Summary", Kind::Text, true, &[], Some(256)),
+ field(
+ "content",
+ "Details",
+ Kind::MultilineText,
+ true,
+ &[],
+ Some(65_535),
+ ),
+ field(
+ "location",
+ "Pickup location",
+ Kind::Location,
+ true,
+ &[],
+ Some(256),
+ ),
+ field("price_amount", "Price", Kind::Decimal, true, &[], Some(64)),
+ field("currency", "Currency", Kind::Choice, true, &[], Some(3)),
+ field(
+ "unit",
+ "Unit",
+ Kind::Choice,
+ true,
+ &[
+ "g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket",
+ ],
+ None,
+ ),
+ field(
+ "quantity",
+ "Available quantity",
+ Kind::Decimal,
+ false,
+ &[],
+ Some(64),
+ ),
+ field(
+ "media",
+ "Photos",
+ Kind::Media,
+ false,
+ &[],
+ Some(MEDIA_FILE_MAX_BYTES),
+ ),
+ ],
+ },
+ ]
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiEventTimingKind {
+ AllDay,
+ Timed,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiPreparedMediaInput {
+ pub schema_version: u16,
+ pub opaque_reference: String,
+ pub file_descriptor: u64,
+ 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,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiAddDraftInput {
+ 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<FfiPreparedMediaInput>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiBlossomUploadInput {
+ pub schema_version: u16,
+ pub draft_id: String,
+ pub expected_revision: u64,
+ pub media: FfiPreparedMediaInput,
+ pub authorization_content: String,
+ pub authorization_created_at_unix_s: u64,
+ pub authorization_lifetime_seconds: u64,
+ pub operation_id: String,
+ pub artifact_id: String,
+ pub signing_deadline_unix_ms: u64,
+ pub signing_cancellation: FfiCancellationPolicy,
+ pub verified_at_unix_ms: u64,
+ pub updated_at_unix_ms: u64,
+}
+
+impl FfiAddDraftInput {
+ pub(crate) fn command_and_media(
+ self,
+ authored_at_unix_s: u64,
+ ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), RadrootsAppError> {
+ require_schema(self.schema_version)?;
+ if authored_at_unix_s == 0 || self.media.len() > 20 {
+ return Err(RadrootsAppError::invalid_argument("invalid_add_draft"));
+ }
+ let prepared = self
+ .media
+ .iter()
+ .cloned()
+ .map(PreparedMedia::try_from)
+ .collect::<Result<Vec<_>, _>>()?;
+ let prerequisites = prepared
+ .iter()
+ .map(|value| {
+ Phase1MediaPrerequisite::new(value.opaque_reference.clone(), &value.descriptor)
+ })
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_reference"))?;
+ let post_images = prepared
+ .iter()
+ .map(PreparedMedia::post_image)
+ .collect::<Result<Vec<_>, _>>()?;
+ let command = match self.command_type {
+ FfiAddCommandType::CreateUpdate => {
+ reject_media(&prepared)?;
+ Phase1AddCommand::CreateUpdate(
+ CreateUpdate::new(self.content)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_update"))?,
+ )
+ }
+ FfiAddCommandType::CreatePhotoUpdate => Phase1AddCommand::CreatePhotoUpdate(
+ CreatePhotoUpdate::new(
+ content_with_media_references(self.content, &prepared)?,
+ post_images,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_photo_update"))?,
+ ),
+ FfiAddCommandType::CreateAsk => Phase1AddCommand::CreateAsk(
+ CreateAsk::new(
+ content_with_media_references(self.content, &prepared)?,
+ post_images,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_ask"))?,
+ ),
+ FfiAddCommandType::CreateEvent => {
+ Phase1AddCommand::CreateEvent(event_command(&self, prepared.first())?)
+ }
+ FfiAddCommandType::CreateFoodAvailability => Phase1AddCommand::CreateFoodAvailability(
+ food_command(self, authored_at_unix_s, &prepared)?,
+ ),
+ };
+ Ok((command, prerequisites))
+ }
+}
+
+pub(crate) struct PreparedMedia {
+ opaque_reference: String,
+ descriptor: radroots_blossom::ByteVerifiedDescriptor,
+ bytes: std::sync::Arc<[u8]>,
+ media_type: MediaType,
+ width: u32,
+ height: u32,
+ alt: String,
+}
+
+impl TryFrom<FfiPreparedMediaInput> for PreparedMedia {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiPreparedMediaInput) -> Result<Self, Self::Error> {
+ require_schema(value.schema_version)?;
+ if !opaque_media_reference_is_valid(&value.opaque_reference)
+ || value.byte_size == 0
+ || value.byte_size > MEDIA_FILE_MAX_BYTES
+ || value.width == 0
+ || value.height == 0
+ || value.prepared_at_unix_s == 0
+ || value.alt.trim().is_empty()
+ || value.alt.len() > 1_024
+ {
+ return Err(RadrootsAppError::invalid_argument(
+ "invalid_media_reference",
+ ));
+ }
+ let byte_size = usize::try_from(value.byte_size)
+ .map_err(|_| RadrootsAppError::invalid_argument("media_size_mismatch"))?;
+ let bytes = read_media_file_descriptor(value.file_descriptor, value.byte_size, byte_size)?;
+ let media_type = MediaType::parse(&value.media_type)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_type"))?;
+ let descriptor = BlobDescriptor::new(
+ BlobUrl::parse(&value.url)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_url"))?,
+ Sha256::from_hex(&value.sha256)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_media_digest"))?,
+ value.byte_size,
+ media_type.clone(),
+ value.prepared_at_unix_s,
+ )
+ .and_then(BlobDescriptor::approve_reference)
+ .and_then(|descriptor| descriptor.verify_bytes(&bytes, &media_type))
+ .map_err(|_| RadrootsAppError::invalid_argument("media_verification_failed"))?;
+ Ok(Self {
+ opaque_reference: value.opaque_reference,
+ descriptor,
+ bytes: bytes.into(),
+ media_type,
+ width: value.width,
+ height: value.height,
+ alt: value.alt,
+ })
+ }
+}
+
+#[cfg(unix)]
+fn read_media_file_descriptor(
+ file_descriptor: u64,
+ expected_size: u64,
+ byte_size: usize,
+) -> Result<Vec<u8>, RadrootsAppError> {
+ let file = std::fs::File::open(format!("/dev/fd/{file_descriptor}"))
+ .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?;
+ let metadata = file
+ .metadata()
+ .map_err(|_| RadrootsAppError::invalid_argument("media_handle_unavailable"))?;
+ if !metadata.is_file() || metadata.len() != expected_size {
+ return Err(RadrootsAppError::invalid_argument("media_size_mismatch"));
+ }
+ let mut bytes = vec![0; byte_size];
+ file.read_exact_at(&mut bytes, 0)
+ .map_err(|_| RadrootsAppError::invalid_argument("media_read_failed"))?;
+ Ok(bytes)
+}
+
+#[cfg(not(unix))]
+fn read_media_file_descriptor(
+ _file_descriptor: u64,
+ _expected_size: u64,
+ _byte_size: usize,
+) -> Result<Vec<u8>, RadrootsAppError> {
+ Err(RadrootsAppError::failure(
+ "media_handle_unsupported",
+ "capability",
+ false,
+ &[],
+ "Protected media handles are unsupported on this platform.",
+ ))
+}
+
+impl PreparedMedia {
+ fn authored_image(&self) -> Result<AuthoredImage, RadrootsAppError> {
+ AuthoredImage::try_from_verified_descriptor(self.descriptor.clone())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_media"))
+ }
+
+ fn post_image(&self) -> Result<AuthoredPostImage, RadrootsAppError> {
+ AuthoredPostImage::new(
+ self.authored_image()?,
+ PostImageDimensions::new(self.width, self.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?,
+ self.alt.clone(),
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image"))
+ }
+
+ pub(crate) fn upload_request(
+ &self,
+ verified_at_unix_ms: u64,
+ ) -> Result<radroots_sdk::transport::BlossomUploadRequest, RadrootsAppError> {
+ let dimensions =
+ radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?;
+ radroots_sdk::transport::BlossomUploadRequest::new(
+ self.descriptor.url().as_blob_url().clone(),
+ std::sync::Arc::clone(&self.bytes),
+ self.media_type.clone(),
+ dimensions,
+ verified_at_unix_ms,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_upload"))
+ }
+}
+
+fn event_command(
+ input: &FfiAddDraftInput,
+ image: Option<&PreparedMedia>,
+) -> Result<CreateEvent, RadrootsAppError> {
+ if input.media.len() > 1 {
+ return Err(RadrootsAppError::invalid_argument("event_image_limit"));
+ }
+ let identifier = required(input.identifier.as_deref(), "event_identifier_required")?;
+ let title = required(input.title.as_deref(), "event_title_required")?;
+ let timing = input
+ .event_timing
+ .ok_or_else(|| RadrootsAppError::invalid_argument("event_timing_required"))?;
+ match timing {
+ FfiEventTimingKind::AllDay => {
+ let start = CalendarDate::parse(required(
+ input.event_start_date.as_deref(),
+ "event_start_date_required",
+ )?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_start_date"))?;
+ let mut event = AuthoredCalendarDateEvent::new(identifier, title, start)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?;
+ if let Some(end) = input.event_end_date.as_deref() {
+ event = event
+ .with_end(CalendarDate::parse(end).map_err(|_| {
+ RadrootsAppError::invalid_argument("invalid_event_end_date")
+ })?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?;
+ }
+ if !input.content.is_empty() {
+ event = event
+ .with_description(input.content.clone())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?;
+ }
+ if let Some(location) = input.location.clone() {
+ event = event
+ .with_locations(vec![location])
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?;
+ }
+ if let Some(image) = image {
+ event = event
+ .with_image(image.authored_image()?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?;
+ }
+ Ok(CreateEvent::date(event))
+ }
+ FfiEventTimingKind::Timed => {
+ let start = input
+ .event_start_unix_s
+ .filter(|value| *value != 0)
+ .ok_or_else(|| RadrootsAppError::invalid_argument("event_start_required"))?;
+ let mut event = AuthoredCalendarTimeEvent::new(identifier, title, start)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event"))?;
+ if let Some(end) = input.event_end_unix_s {
+ event = event
+ .with_end(end)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_range"))?;
+ }
+ if let Some(timezone) = input.event_timezone.as_deref() {
+ event = event
+ .with_start_tzid(timezone)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_timezone"))?;
+ }
+ if !input.content.is_empty() {
+ event = event
+ .with_description(input.content.clone())
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_description"))?;
+ }
+ if let Some(location) = input.location.clone() {
+ event = event
+ .with_locations(vec![location])
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_location"))?;
+ }
+ if let Some(image) = image {
+ event = event
+ .with_image(image.authored_image()?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_event_image"))?;
+ }
+ Ok(CreateEvent::time(event))
+ }
+ }
+}
+
+fn food_command(
+ input: FfiAddDraftInput,
+ authored_at_unix_s: u64,
+ media: &[PreparedMedia],
+) -> Result<CreateFoodAvailability, RadrootsAppError> {
+ let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_unit"))?;
+ let images = media
+ .iter()
+ .map(|image| {
+ Ok(FoodAvailabilityImage::new(
+ image.authored_image()?,
+ FoodImageDimensions::new(image.width, image.height)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_image_dimensions"))?,
+ ))
+ })
+ .collect::<Result<Vec<_>, RadrootsAppError>>()?;
+ let status = input.food_status.as_deref().unwrap_or("active");
+ let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts {
+ content: FoodContent::new(input.content)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_content"))?,
+ identifier: FoodIdentifier::parse(required(
+ input.identifier.as_deref(),
+ "food_identifier_required",
+ )?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_identifier"))?,
+ title: FoodText::new(required(input.title, "food_title_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_title"))?,
+ summary: FoodText::new(required(input.summary, "food_summary_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_summary"))?,
+ published_at: FoodPublishedAt::new(authored_at_unix_s)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_published_at"))?,
+ location: FoodText::new(required(input.location, "food_location_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_location"))?,
+ price: FoodPrice::new(
+ required(input.price_amount, "food_price_required")?,
+ FoodCurrency::parse(required(input.currency, "food_currency_required")?)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_currency"))?,
+ unit,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_price"))?,
+ quantity: input
+ .quantity
+ .map(|quantity| FoodQuantity::new(quantity, unit))
+ .transpose()
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_quantity"))?,
+ status: FoodAvailabilityStatus::parse(status)
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_status"))?,
+ images,
+ })
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_food_availability"))?;
+ Ok(CreateFoodAvailability::new(details))
+}
+
+fn reject_media(media: &[PreparedMedia]) -> Result<(), RadrootsAppError> {
+ if media.is_empty() {
+ Ok(())
+ } else {
+ Err(RadrootsAppError::invalid_argument("media_not_allowed"))
+ }
+}
+
+fn content_with_media_references(
+ mut content: String,
+ media: &[PreparedMedia],
+) -> Result<String, RadrootsAppError> {
+ if content.trim().is_empty() {
+ return Err(RadrootsAppError::invalid_argument("content_required"));
+ }
+ for item in media {
+ let url = item.descriptor.url().as_str();
+ match content.match_indices(url).count() {
+ 0 => {
+ if !content.ends_with('\n') {
+ content.push('\n');
+ }
+ content.push_str(url);
+ }
+ 1 => {}
+ _ => {
+ return Err(RadrootsAppError::invalid_argument(
+ "duplicate_media_reference",
+ ));
+ }
+ }
+ }
+ Ok(content)
+}
+
+fn required<T>(value: Option<T>, code: &'static str) -> Result<T, RadrootsAppError> {
+ value.ok_or_else(|| RadrootsAppError::invalid_argument(code))
+}
+
+fn opaque_media_reference_is_valid(value: &str) -> bool {
+ value.len() > "media:".len()
+ && value.len() <= MEDIA_REFERENCE_MAX_BYTES
+ && value.starts_with("media:")
+ && value["media:".len()..].bytes().all(|byte| {
+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
+ })
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiMediaStage {
+ Pending,
+ Preparing,
+ Uploading,
+ Verified,
+ Failed,
+ Orphaned,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1MediaStage> for FfiMediaStage {
+ fn from(value: Phase1MediaStage) -> Self {
+ match value {
+ Phase1MediaStage::Pending => Self::Pending,
+ Phase1MediaStage::Preparing => Self::Preparing,
+ Phase1MediaStage::Uploading => Self::Uploading,
+ Phase1MediaStage::Verified => Self::Verified,
+ Phase1MediaStage::Failed => Self::Failed,
+ Phase1MediaStage::Orphaned => Self::Orphaned,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiDraftMediaRecord {
+ pub schema_version: u16,
+ pub url: String,
+ pub stage: FfiMediaStage,
+ pub upload_attempts: u8,
+ pub verified_at_unix_ms: Option<u64>,
+ pub possible_orphan: bool,
+ pub orphan_reason_code: Option<String>,
+ pub orphan_recorded_at_unix_ms: Option<u64>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<&Phase1MediaPrerequisite> for FfiDraftMediaRecord {
+ fn from(value: &Phase1MediaPrerequisite) -> Self {
+ let orphan = value.orphan();
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: value.url().to_owned(),
+ stage: value.stage().into(),
+ upload_attempts: value.upload_attempts(),
+ verified_at_unix_ms: value.verified_at_unix_ms(),
+ possible_orphan: orphan.is_some(),
+ orphan_reason_code: orphan.map(|value| value.reason_code().to_owned()),
+ orphan_recorded_at_unix_ms: orphan.map(|value| value.recorded_at_unix_ms()),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiOutboxState {
+ Draft,
+ MediaPreparing,
+ MediaUploading,
+ ReadyToSign,
+ Signing,
+ Signed,
+ Queued,
+ Delivering,
+ PartiallyDelivered,
+ Retryable,
+ Terminal,
+ Cancelled,
+ Complete,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1OutboxState> for FfiOutboxState {
+ fn from(value: Phase1OutboxState) -> Self {
+ match value {
+ Phase1OutboxState::Draft => Self::Draft,
+ Phase1OutboxState::MediaPreparing => Self::MediaPreparing,
+ Phase1OutboxState::MediaUploading => Self::MediaUploading,
+ Phase1OutboxState::ReadyToSign => Self::ReadyToSign,
+ Phase1OutboxState::Signing => Self::Signing,
+ Phase1OutboxState::Signed => Self::Signed,
+ Phase1OutboxState::Queued => Self::Queued,
+ Phase1OutboxState::Delivering => Self::Delivering,
+ Phase1OutboxState::PartiallyDelivered => Self::PartiallyDelivered,
+ Phase1OutboxState::Retryable => Self::Retryable,
+ Phase1OutboxState::Terminal => Self::Terminal,
+ Phase1OutboxState::Cancelled => Self::Cancelled,
+ Phase1OutboxState::Complete => Self::Complete,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiDraftStatusRecord {
+ pub schema_version: u16,
+ pub draft_id: String,
+ pub revision: u64,
+ pub author_public_key: String,
+ pub command_type: FfiAddCommandType,
+ pub state: FfiOutboxState,
+ pub card_id: String,
+ pub operation_id: Option<String>,
+ pub created_at_unix_ms: u64,
+ pub updated_at_unix_ms: u64,
+ pub media: Vec<FfiDraftMediaRecord>,
+ pub settlement: Option<FfiOperationSettlementRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<Phase1DraftStatus> for FfiDraftStatusRecord {
+ fn from(value: Phase1DraftStatus) -> Self {
+ let draft = value.draft();
+ let settlement = value.push().map(|push| push.settlement());
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ draft_id: hex::encode(draft.draft_id().as_bytes()),
+ revision: draft.revision().get(),
+ author_public_key: hex::encode(draft.author()),
+ command_type: value.command_type().into(),
+ state: value.state().into(),
+ card_id: value.card_id().to_hex(),
+ operation_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())),
+ created_at_unix_ms: draft.created_at_unix_ms(),
+ updated_at_unix_ms: draft.updated_at_unix_ms(),
+ media: value.media().iter().map(Into::into).collect(),
+ settlement: settlement.map(Into::into),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiOperationSettlementRecord {
+ pub schema_version: u16,
+ pub artifacts: u16,
+ pub signed: u16,
+ pub admitted: u16,
+ pub pending: u16,
+ pub retryable: u16,
+ pub indeterminate: u16,
+ pub failed_terminal: u16,
+ pub cancelled: u16,
+ pub delivery_plans: u16,
+ pub delivery_satisfied: u16,
+ pub delivery_pending: u16,
+ pub delivery_retryable: u16,
+ pub delivery_exhausted: u16,
+ pub delivery_failed_terminal: u16,
+ pub delivery_cancelled: u16,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<radroots_storage::authored::OperationSettlement> for FfiOperationSettlementRecord {
+ fn from(value: radroots_storage::authored::OperationSettlement) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ artifacts: value.artifacts(),
+ signed: value.signed(),
+ admitted: value.admitted(),
+ pending: value.pending(),
+ retryable: value.retryable(),
+ indeterminate: value.indeterminate(),
+ failed_terminal: value.failed_terminal(),
+ cancelled: value.cancelled(),
+ delivery_plans: value.delivery_plans(),
+ delivery_satisfied: value.delivery_satisfied(),
+ delivery_pending: value.delivery_pending(),
+ delivery_retryable: value.delivery_retryable(),
+ delivery_exhausted: value.delivery_exhausted(),
+ delivery_failed_terminal: value.delivery_failed_terminal(),
+ delivery_cancelled: value.delivery_cancelled(),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRelaySatisfaction {
+ AnyAccepted,
+ AllAccepted,
+ AnyDelivered,
+ AllDelivered,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiCancellationPolicy {
+ PreservePublishedRequest,
+ LocalCooperative,
+}
+
+impl FfiCancellationPolicy {
+ pub(crate) const fn core(self) -> Phase1CancellationPolicy {
+ match self {
+ Self::PreservePublishedRequest => Phase1CancellationPolicy::PreservePublishedRequest,
+ Self::LocalCooperative => Phase1CancellationPolicy::LocalCooperative,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiQueuePolicyRecord {
+ pub schema_version: u16,
+ pub relay_urls: Vec<String>,
+ pub satisfaction: FfiRelaySatisfaction,
+ pub delivery_deadline_unix_ms: u64,
+ pub cancellation: FfiCancellationPolicy,
+}
+
+impl TryFrom<FfiQueuePolicyRecord> for Phase1QueuePolicy {
+ type Error = RadrootsAppError;
+
+ fn try_from(value: FfiQueuePolicyRecord) -> Result<Self, Self::Error> {
+ require_schema(value.schema_version)?;
+ Phase1QueuePolicy::new(
+ value.relay_urls,
+ match value.satisfaction {
+ FfiRelaySatisfaction::AnyAccepted => Phase1RelaySatisfaction::AnyAccepted,
+ FfiRelaySatisfaction::AllAccepted => Phase1RelaySatisfaction::AllAccepted,
+ FfiRelaySatisfaction::AnyDelivered => Phase1RelaySatisfaction::AnyDelivered,
+ FfiRelaySatisfaction::AllDelivered => Phase1RelaySatisfaction::AllDelivered,
+ },
+ value.delivery_deadline_unix_ms,
+ match value.cancellation {
+ FfiCancellationPolicy::PreservePublishedRequest => {
+ Phase1CancellationPolicy::PreservePublishedRequest
+ }
+ FfiCancellationPolicy::LocalCooperative => {
+ Phase1CancellationPolicy::LocalCooperative
+ }
+ },
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_queue_policy"))
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiCapabilityRecord {
+ pub schema_version: u16,
+ pub id: String,
+ pub compiled: bool,
+ pub configured: bool,
+ pub availability: String,
+ pub maturity: String,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkCapabilityRecord> for FfiCapabilityRecord {
+ fn from(value: SdkCapabilityRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: value.id,
+ compiled: value.compiled,
+ configured: value.configured,
+ availability: value.availability,
+ maturity: value.maturity,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiStorageStatusRecord {
+ pub schema_version: u16,
+ pub backend: String,
+ pub open_mode: String,
+ pub shutdown: String,
+ pub integrity: String,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord {
+ fn from(value: SdkStorageStatusRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ backend: value.backend,
+ open_mode: value.open_mode,
+ shutdown: value.shutdown,
+ integrity: value.integrity,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRelayStatusRecord {
+ pub schema_version: u16,
+ pub relay_url: String,
+ pub access: String,
+ pub read_state: String,
+ pub write_state: String,
+ pub read_last_attempt_unix_ms: Option<u64>,
+ pub write_last_attempt_unix_ms: Option<u64>,
+ pub read_next_attempt_unix_ms: Option<u64>,
+ pub write_next_attempt_unix_ms: Option<u64>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord {
+ fn from(value: SdkRelayStatusRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_url: value.relay_url,
+ access: value.access,
+ read_state: value.read_state,
+ write_state: value.write_state,
+ read_last_attempt_unix_ms: value.read_last_attempt_unix_ms,
+ write_last_attempt_unix_ms: value.write_last_attempt_unix_ms,
+ read_next_attempt_unix_ms: value.read_next_attempt_unix_ms,
+ write_next_attempt_unix_ms: value.write_next_attempt_unix_ms,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRelayStatusReportRecord {
+ pub schema_version: u16,
+ pub profile: String,
+ pub state: String,
+ pub read_availability: String,
+ pub write_availability: String,
+ pub relays: Vec<FfiRelayStatusRecord>,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord {
+ fn from(value: SdkRelayStatusReportRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ profile: value.profile,
+ state: value.state,
+ read_availability: value.read_availability,
+ write_availability: value.write_availability,
+ relays: value.relays.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiShutdownRecord {
+ pub schema_version: u16,
+ pub state: String,
+ pub already_closed: bool,
+}
+
+#[cfg_attr(coverage_nightly, coverage(off))]
+impl From<SdkShutdownRecord> for FfiShutdownRecord {
+ fn from(value: SdkShutdownRecord) -> Self {
+ Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ state: value.state,
+ already_closed: value.already_closed,
+ }
+ }
+}
+
+pub(crate) fn decode_id(value: &str, code: &'static str) -> Result<[u8; 16], RadrootsAppError> {
+ if value.len() != 32 {
+ return Err(RadrootsAppError::invalid_argument(code));
+ }
+ let bytes = hex::decode(value).map_err(|_| RadrootsAppError::invalid_argument(code))?;
+ bytes
+ .try_into()
+ .map_err(|_| RadrootsAppError::invalid_argument(code))
+}
+
+fn require_schema(schema_version: u16) -> Result<(), RadrootsAppError> {
+ if schema_version == MOBILE_FFI_SCHEMA_VERSION {
+ Ok(())
+ } else {
+ Err(RadrootsAppError::invalid_argument(
+ "unsupported_schema_version",
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::io::Write;
+ use std::os::fd::AsRawFd;
+
+ use super::*;
+
+ fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreatePhotoUpdate,
+ content: "Fresh carrots from this morning.".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![FfiPreparedMediaInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: "media:carrots-01".to_owned(),
+ file_descriptor,
+ url: format!("https://blossom.example/{digest}.jpg"),
+ sha256: digest,
+ media_type: "image/jpeg".to_owned(),
+ byte_size: bytes.len() as u64,
+ width: 2,
+ height: 2,
+ alt: "A basket of carrots".to_owned(),
+ prepared_at_unix_s: 1_800_000_000,
+ }],
+ }
+ }
+
+ fn text_input(command_type: FfiAddCommandType) -> FfiAddDraftInput {
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type,
+ content: "Fresh from the farm".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(),
+ }
+ }
+
+ #[test]
+ fn exact_five_add_inputs_build_their_typed_core_commands() {
+ let (update, media) = text_input(FfiAddCommandType::CreateUpdate)
+ .command_and_media(1_800_000_000)
+ .expect("update");
+ assert!(matches!(update, Phase1AddCommand::CreateUpdate(_)));
+ assert!(media.is_empty());
+
+ let (ask, media) = text_input(FfiAddCommandType::CreateAsk)
+ .command_and_media(1_800_000_000)
+ .expect("ask");
+ assert!(matches!(ask, Phase1AddCommand::CreateAsk(_)));
+ assert!(media.is_empty());
+
+ let mut all_day = text_input(FfiAddCommandType::CreateEvent);
+ all_day.identifier = Some("market-day".to_owned());
+ all_day.title = Some("Farmers market".to_owned());
+ all_day.location = Some("Town square".to_owned());
+ all_day.event_timing = Some(FfiEventTimingKind::AllDay);
+ all_day.event_start_date = Some("2026-08-08".to_owned());
+ all_day.event_end_date = Some("2026-08-09".to_owned());
+ let (event, media) = all_day
+ .command_and_media(1_800_000_000)
+ .expect("all-day event");
+ assert!(matches!(event, Phase1AddCommand::CreateEvent(_)));
+ assert!(media.is_empty());
+
+ let mut timed = text_input(FfiAddCommandType::CreateEvent);
+ timed.identifier = Some("harvest-tour".to_owned());
+ timed.title = Some("Harvest tour".to_owned());
+ timed.event_timing = Some(FfiEventTimingKind::Timed);
+ timed.event_start_unix_s = Some(1_800_000_000);
+ timed.event_end_unix_s = Some(1_800_003_600);
+ timed.event_timezone = Some("America/Vancouver".to_owned());
+ let (event, media) = timed.command_and_media(1_800_000_000).expect("timed event");
+ assert!(matches!(event, Phase1AddCommand::CreateEvent(_)));
+ assert!(media.is_empty());
+
+ let bytes = b"verified-food-image";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let mut food = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest);
+ food.command_type = FfiAddCommandType::CreateFoodAvailability;
+ food.identifier = Some("carrots-2026-08".to_owned());
+ food.title = Some("Carrots".to_owned());
+ food.summary = Some("Fresh bunches".to_owned());
+ food.location = Some("Victoria".to_owned());
+ food.price_amount = Some("4.5".to_owned());
+ food.currency = Some("CAD".to_owned());
+ food.unit = Some("bunch".to_owned());
+ food.quantity = Some("12".to_owned());
+ food.food_status = Some("active".to_owned());
+ let (food, media) = food
+ .command_and_media(1_800_000_000)
+ .expect("food availability");
+ assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_)));
+ assert_eq!(media.len(), 1);
+ }
+
+ #[test]
+ fn add_validation_reports_schema_shape_media_and_required_field_failures() {
+ let mut wrong_schema = text_input(FfiAddCommandType::CreateUpdate);
+ wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1;
+ assert_eq!(
+ wrong_schema
+ .command_and_media(1_800_000_000)
+ .expect_err("schema")
+ .report()
+ .code,
+ "unsupported_schema_version"
+ );
+ assert_eq!(
+ text_input(FfiAddCommandType::CreateUpdate)
+ .command_and_media(0)
+ .expect_err("authored time")
+ .report()
+ .code,
+ "invalid_add_draft"
+ );
+ assert_eq!(
+ text_input(FfiAddCommandType::CreateEvent)
+ .command_and_media(1_800_000_000)
+ .expect_err("event identity")
+ .report()
+ .code,
+ "event_identifier_required"
+ );
+ let mut food = text_input(FfiAddCommandType::CreateFoodAvailability);
+ food.unit = Some("crate".to_owned());
+ assert_eq!(
+ food.command_and_media(1_800_000_000)
+ .expect_err("food unit")
+ .report()
+ .code,
+ "invalid_food_unit"
+ );
+ }
+
+ #[test]
+ fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() {
+ let bytes = b"normalized-image-bytes";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone());
+
+ let (command, media) = input
+ .command_and_media(1_800_000_000)
+ .expect("verified media input");
+ assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_)));
+ assert_eq!(media.len(), 1);
+ assert_eq!(
+ media[0].url(),
+ format!("https://blossom.example/{digest}.jpg")
+ );
+ }
+
+ #[test]
+ fn prepared_media_rejects_digest_tamper_and_path_like_references() {
+ let bytes = b"normalized-image-bytes";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+
+ let tampered = photo_input(
+ file.as_file().as_raw_fd() as u64,
+ bytes,
+ Sha256::digest(b"other").to_hex(),
+ );
+ assert_eq!(
+ tampered
+ .command_and_media(1_800_000_000)
+ .expect_err("digest mismatch")
+ .report()
+ .code,
+ "media_verification_failed"
+ );
+
+ let mut path_like = photo_input(
+ file.as_file().as_raw_fd() as u64,
+ bytes,
+ Sha256::digest(bytes).to_hex(),
+ );
+ path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned();
+ assert_eq!(
+ path_like
+ .command_and_media(1_800_000_000)
+ .expect_err("path-like reference")
+ .report()
+ .code,
+ "invalid_media_reference"
+ );
+ }
+
+ #[test]
+ fn prepared_media_constructs_a_bounded_verified_upload_request() {
+ let bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone());
+ input.media[0].url = format!("https://blossom.example/{digest}.png");
+ input.media[0].media_type = "image/png".to_owned();
+
+ let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media");
+ let request = prepared
+ .upload_request(1_800_000_000_000)
+ .expect("bounded upload request");
+ assert_eq!(request.sha256().to_hex(), digest);
+ assert_eq!(request.byte_size(), bytes.len() as u64);
+ assert_eq!(request.media_type().as_str(), "image/png");
+ assert_eq!(request.dimensions().width(), 2);
+ assert_eq!(request.dimensions().height(), 2);
+ }
+
+ #[test]
+ fn media_validation_executes_every_bounded_shape_guard() {
+ let bytes = b"bounded-media";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let valid = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest).media[0].clone();
+
+ let mut invalid_values = Vec::new();
+ let mut value = valid.clone();
+ value.byte_size = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.byte_size = MEDIA_FILE_MAX_BYTES + 1;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.width = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.height = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.prepared_at_unix_s = 0;
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.alt = " ".to_owned();
+ invalid_values.push(value);
+ let mut value = valid.clone();
+ value.alt = "a".repeat(1_025);
+ invalid_values.push(value);
+
+ for value in invalid_values {
+ assert_eq!(
+ PreparedMedia::try_from(value)
+ .err()
+ .expect("invalid bounded media")
+ .report()
+ .code,
+ "invalid_media_reference"
+ );
+ }
+
+ let mut wrong_size = valid.clone();
+ wrong_size.byte_size += 1;
+ assert_eq!(
+ PreparedMedia::try_from(wrong_size)
+ .err()
+ .expect("descriptor size")
+ .report()
+ .code,
+ "media_size_mismatch"
+ );
+ for reference in ["media:", "Media:item", "media:BAD", "media:item/path"] {
+ assert!(!opaque_media_reference_is_valid(reference));
+ }
+ assert!(opaque_media_reference_is_valid("media:item_01-a"));
+ assert!(!opaque_media_reference_is_valid(&format!(
+ "media:{}",
+ "a".repeat(MEDIA_REFERENCE_MAX_BYTES)
+ )));
+ }
+
+ #[test]
+ fn event_and_post_optional_branches_remain_strict_and_complete() {
+ let mut minimal_date = text_input(FfiAddCommandType::CreateEvent);
+ minimal_date.content.clear();
+ minimal_date.identifier = Some("minimal-date".to_owned());
+ minimal_date.title = Some("Minimal date".to_owned());
+ minimal_date.event_timing = Some(FfiEventTimingKind::AllDay);
+ minimal_date.event_start_date = Some("2026-08-08".to_owned());
+ assert!(minimal_date.command_and_media(1_800_000_000).is_ok());
+
+ let mut minimal_time = text_input(FfiAddCommandType::CreateEvent);
+ minimal_time.content.clear();
+ minimal_time.identifier = Some("minimal-time".to_owned());
+ minimal_time.title = Some("Minimal time".to_owned());
+ minimal_time.event_timing = Some(FfiEventTimingKind::Timed);
+ minimal_time.event_start_unix_s = Some(1_800_000_000);
+ assert!(minimal_time.command_and_media(1_800_000_000).is_ok());
+
+ let bytes = b"event-image";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let mut event = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest);
+ event.command_type = FfiAddCommandType::CreateEvent;
+ event.identifier = Some("event-image".to_owned());
+ event.title = Some("Event image".to_owned());
+ event.event_timing = Some(FfiEventTimingKind::Timed);
+ event.event_start_unix_s = Some(1_800_000_000);
+ assert!(event.clone().command_and_media(1_800_000_000).is_ok());
+
+ let mut second = event.media[0].clone();
+ second.opaque_reference = "media:event-image-two".to_owned();
+ event.media.push(second);
+ assert_eq!(
+ event
+ .command_and_media(1_800_000_000)
+ .expect_err("event media limit")
+ .report()
+ .code,
+ "event_image_limit"
+ );
+ }
+
+ #[test]
+ fn content_references_and_identifier_decoding_cover_all_outcomes() {
+ let bytes = b"post-image";
+ let mut file = tempfile::NamedTempFile::new().expect("media file");
+ file.write_all(bytes).expect("write media");
+ file.flush().expect("flush media");
+ let digest = Sha256::digest(bytes).to_hex();
+ let input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest);
+ let prepared = PreparedMedia::try_from(input.media[0].clone()).expect("prepared media");
+ let url = prepared.descriptor.url().as_str().to_owned();
+
+ assert_eq!(
+ content_with_media_references(" ".to_owned(), std::slice::from_ref(&prepared))
+ .expect_err("blank content")
+ .report()
+ .code,
+ "content_required"
+ );
+ assert_eq!(
+ content_with_media_references(
+ format!("caption\n{url}"),
+ std::slice::from_ref(&prepared)
+ )
+ .expect("one existing reference")
+ .match_indices(&url)
+ .count(),
+ 1
+ );
+ assert!(
+ content_with_media_references("caption\n".to_owned(), std::slice::from_ref(&prepared))
+ .expect("newline append")
+ .ends_with(&url)
+ );
+ assert_eq!(
+ content_with_media_references(
+ format!("{url}\n{url}"),
+ std::slice::from_ref(&prepared),
+ )
+ .expect_err("duplicate reference")
+ .report()
+ .code,
+ "duplicate_media_reference"
+ );
+
+ let mut update = text_input(FfiAddCommandType::CreateUpdate);
+ update.media.push(input.media[0].clone());
+ assert_eq!(
+ update
+ .command_and_media(1_800_000_000)
+ .expect_err("update media")
+ .report()
+ .code,
+ "media_not_allowed"
+ );
+ let mut over_limit = text_input(FfiAddCommandType::CreateUpdate);
+ over_limit.media = vec![input.media[0].clone(); 21];
+ assert_eq!(
+ over_limit
+ .command_and_media(1_800_000_000)
+ .expect_err("media count")
+ .report()
+ .code,
+ "invalid_add_draft"
+ );
+
+ assert_eq!(decode_id(&"01".repeat(16), "bad").expect("id"), [1; 16]);
+ assert_eq!(
+ decode_id("01", "bad").expect_err("short id").report().code,
+ "bad"
+ );
+ assert_eq!(
+ decode_id(&"gg".repeat(16), "bad")
+ .expect_err("non-hex id")
+ .report()
+ .code,
+ "bad"
+ );
+ }
+}
diff --git a/crates/mobile_ffi/src/error.rs b/crates/mobile_ffi/src/error.rs
@@ -1,73 +1,317 @@
+use radroots_mobile_core::runtime::product_surface::{Phase1DraftError, TodayError};
use thiserror::Error;
-pub use radroots_mobile_core::{SdkErrorRecord, StoreErrorRecord};
+use crate::MOBILE_FFI_SCHEMA_VERSION;
-/// Versioned, secret-safe failure exposed across the native language boundary.
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct RadrootsErrorRecord {
+ pub schema_version: u16,
+ pub code: String,
+ pub category: String,
+ pub retryable: bool,
+ pub recovery_actions: Vec<String>,
+ pub operation_id: Option<String>,
+ pub capability_id: Option<String>,
+ pub safe_message: String,
+}
+
+/// The only error envelope exported across the native language boundary.
#[derive(Debug, Error, uniffi::Error)]
pub enum RadrootsAppError {
- #[error("initialization: {0}")]
- Initialization(String),
- #[error("sdk: {report:?}")]
- Sdk { report: SdkErrorRecord },
- #[error("store: {report:?}")]
- Store { report: StoreErrorRecord },
- #[error("runtime: {0}")]
- Runtime(String),
- #[error("unsupported: {0}")]
- Unsupported(String),
- #[error("internal: {0}")]
- Internal(String),
+ #[error("radroots operation failed: {report:?}")]
+ Failure { report: RadrootsErrorRecord },
+}
+
+impl RadrootsAppError {
+ pub(crate) fn initialization(_message: impl Into<String>) -> Self {
+ Self::failure(
+ "initialization_failed",
+ "initialization",
+ true,
+ &["retry"],
+ "The Radroots runtime could not be initialized.",
+ )
+ }
+
+ pub(crate) fn invalid_argument(code: impl Into<String>) -> Self {
+ Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: code.into(),
+ category: "validation".to_owned(),
+ retryable: false,
+ recovery_actions: vec!["correct_input".to_owned()],
+ operation_id: None,
+ capability_id: None,
+ safe_message: "The request is invalid.".to_owned(),
+ },
+ }
+ }
+
+ pub(crate) fn failure(
+ code: &str,
+ category: &str,
+ retryable: bool,
+ recovery_actions: &[&str],
+ safe_message: &str,
+ ) -> Self {
+ Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: code.to_owned(),
+ category: category.to_owned(),
+ retryable,
+ recovery_actions: recovery_actions
+ .iter()
+ .map(|action| (*action).to_owned())
+ .collect(),
+ operation_id: None,
+ capability_id: None,
+ safe_message: safe_message.to_owned(),
+ },
+ }
+ }
+
+ pub fn report(&self) -> &RadrootsErrorRecord {
+ match self {
+ Self::Failure { report } => report,
+ }
+ }
}
impl From<radroots_mobile_core::RadrootsAppError> for RadrootsAppError {
fn from(error: radroots_mobile_core::RadrootsAppError) -> Self {
match error {
- radroots_mobile_core::RadrootsAppError::Initialization(message) => {
- Self::Initialization(message)
+ radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: report.code,
+ category: report.class,
+ retryable: report.retryable,
+ recovery_actions: report.recovery_actions,
+ operation_id: report.operation_id,
+ capability_id: report.capability_id,
+ safe_message: report.message,
+ },
+ },
+ radroots_mobile_core::RadrootsAppError::Store { report } => Self::Failure {
+ report: RadrootsErrorRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ code: report.code,
+ category: report.class,
+ retryable: report.retryable,
+ recovery_actions: report.recovery_actions,
+ operation_id: None,
+ capability_id: None,
+ safe_message: report.message,
+ },
+ },
+ radroots_mobile_core::RadrootsAppError::Initialization(_) => {
+ Self::initialization("redacted")
}
- radroots_mobile_core::RadrootsAppError::Sdk { report } => Self::Sdk { report },
- radroots_mobile_core::RadrootsAppError::Store { report } => Self::Store { report },
- radroots_mobile_core::RadrootsAppError::Runtime(message) => Self::Runtime(message),
- radroots_mobile_core::RadrootsAppError::Unsupported(message) => {
- Self::Unsupported(message)
- }
- radroots_mobile_core::RadrootsAppError::Internal(message) => Self::Internal(message),
+ radroots_mobile_core::RadrootsAppError::Runtime(_) => Self::failure(
+ "runtime_failed",
+ "runtime",
+ true,
+ &["retry"],
+ "The runtime operation failed.",
+ ),
+ radroots_mobile_core::RadrootsAppError::Unsupported(_) => Self::failure(
+ "unsupported",
+ "capability",
+ false,
+ &[],
+ "The requested capability is unsupported.",
+ ),
+ radroots_mobile_core::RadrootsAppError::Internal(_) => Self::failure(
+ "internal_failure",
+ "internal",
+ false,
+ &["restart"],
+ "An internal Radroots error occurred.",
+ ),
}
}
}
-impl RadrootsAppError {
- pub(crate) fn initialization(message: impl Into<String>) -> Self {
- Self::Initialization(message.into())
+impl From<TodayError> for RadrootsAppError {
+ fn from(error: TodayError) -> Self {
+ let (code, retryable, actions) = match error {
+ TodayError::InvalidRequest | TodayError::EventNotVisible => {
+ ("today_invalid_request", false, &["correct_input"][..])
+ }
+ TodayError::ProjectionMissing | TodayError::SnapshotMissing => {
+ ("today_refresh_required", true, &["refresh"][..])
+ }
+ TodayError::CursorPositionMissing | TodayError::Cursor(_) => {
+ ("today_cursor_invalid", true, &["restart_pagination"][..])
+ }
+ TodayError::RuntimeUnavailable => ("today_runtime_unavailable", true, &["retry"][..]),
+ TodayError::CorruptProjection | TodayError::Serialization | TodayError::Storage(_) => {
+ ("today_state_failed", true, &["rebuild", "retry"][..])
+ }
+ };
+ Self::failure(
+ code,
+ "today",
+ retryable,
+ actions,
+ "The Today operation could not be completed.",
+ )
+ }
+}
+
+impl From<Phase1DraftError> for RadrootsAppError {
+ fn from(error: Phase1DraftError) -> Self {
+ let (code, retryable, actions) = match error {
+ Phase1DraftError::IdentityUnavailable => (
+ "identity_unavailable",
+ true,
+ &["unlock_identity", "retry"][..],
+ ),
+ Phase1DraftError::InvalidDraft => ("draft_invalid", false, &["correct_input"][..]),
+ Phase1DraftError::InvalidMedia => {
+ ("draft_media_invalid", false, &["replace_media"][..])
+ }
+ Phase1DraftError::InvalidQueuePolicy => {
+ ("draft_queue_policy_invalid", false, &["correct_input"][..])
+ }
+ Phase1DraftError::RevisionConflict => {
+ ("draft_revision_conflict", true, &["refresh"][..])
+ }
+ Phase1DraftError::NotFound => ("draft_not_found", false, &[][..]),
+ Phase1DraftError::Terminal => ("draft_terminal", false, &[][..]),
+ Phase1DraftError::MediaNotReady => {
+ ("draft_media_not_ready", true, &["retry_media"][..])
+ }
+ Phase1DraftError::OperationUnavailable => {
+ ("authoring_unavailable", true, &["retry"][..])
+ }
+ Phase1DraftError::Operation | Phase1DraftError::Storage | Phase1DraftError::Overlay => {
+ ("authoring_failed", true, &["retry", "inspect_outbox"][..])
+ }
+ Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]),
+ };
+ Self::failure(
+ code,
+ "authoring",
+ retryable,
+ actions,
+ "The authored operation could not be completed.",
+ )
}
}
#[cfg(test)]
-#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
- use super::RadrootsAppError;
+ use super::*;
#[test]
- fn every_core_error_variant_maps_without_string_erasure() {
- assert!(matches!(
- RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::initialization(
- "initialization"
- )),
- RadrootsAppError::Initialization(message) if message == "initialization"
- ));
- assert!(matches!(
- RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::runtime("runtime")),
- RadrootsAppError::Runtime(message) if message == "runtime"
- ));
- assert!(matches!(
- RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::unsupported(
- "unsupported"
- )),
- RadrootsAppError::Unsupported(message) if message == "unsupported"
- ));
- assert!(matches!(
- RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::internal("internal")),
- RadrootsAppError::Internal(message) if message == "internal"
+ fn internal_core_messages_are_not_copied_to_the_ffi_record() {
+ let error = RadrootsAppError::from(radroots_mobile_core::RadrootsAppError::internal(
+ "private/path/secret-value",
));
+ assert_eq!(error.report().code, "internal_failure");
+ assert!(!format!("{error:?}").contains("secret-value"));
+ assert!(!error.report().safe_message.contains("secret-value"));
+ }
+
+ #[test]
+ fn every_core_error_class_maps_to_a_versioned_redacted_record() {
+ let sdk = radroots_mobile_core::RadrootsAppError::Sdk {
+ report: radroots_mobile_core::SdkErrorRecord {
+ schema_version: 1,
+ code: "relay_unavailable".to_owned(),
+ class: "transport".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["retry".to_owned()],
+ operation_id: Some("operation".to_owned()),
+ capability_id: Some("relay".to_owned()),
+ message: "Safe relay failure".to_owned(),
+ },
+ };
+ let sdk = RadrootsAppError::from(sdk);
+ assert_eq!(sdk.report().code, "relay_unavailable");
+ assert_eq!(sdk.report().operation_id.as_deref(), Some("operation"));
+
+ let store = radroots_mobile_core::RadrootsAppError::Store {
+ report: radroots_mobile_core::StoreErrorRecord {
+ schema_version: 1,
+ code: "store_locked".to_owned(),
+ class: "storage".to_owned(),
+ retryable: true,
+ recovery_actions: vec!["unlock".to_owned()],
+ message: "Safe store failure".to_owned(),
+ },
+ };
+ let store = RadrootsAppError::from(store);
+ assert_eq!(store.report().code, "store_locked");
+ assert!(store.report().operation_id.is_none());
+
+ for (core, code, category) in [
+ (
+ radroots_mobile_core::RadrootsAppError::initialization("secret"),
+ "initialization_failed",
+ "initialization",
+ ),
+ (
+ radroots_mobile_core::RadrootsAppError::runtime("secret"),
+ "runtime_failed",
+ "runtime",
+ ),
+ (
+ radroots_mobile_core::RadrootsAppError::unsupported("secret"),
+ "unsupported",
+ "capability",
+ ),
+ (
+ radroots_mobile_core::RadrootsAppError::internal("secret"),
+ "internal_failure",
+ "internal",
+ ),
+ ] {
+ let ffi = RadrootsAppError::from(core);
+ assert_eq!(ffi.report().code, code);
+ assert_eq!(ffi.report().category, category);
+ assert!(!ffi.report().safe_message.contains("secret"));
+ }
+ }
+
+ #[test]
+ fn today_and_draft_failures_have_stable_recovery_classes() {
+ for error in [
+ TodayError::InvalidRequest,
+ TodayError::EventNotVisible,
+ TodayError::ProjectionMissing,
+ TodayError::SnapshotMissing,
+ TodayError::CursorPositionMissing,
+ TodayError::RuntimeUnavailable,
+ TodayError::CorruptProjection,
+ TodayError::Serialization,
+ ] {
+ let ffi = RadrootsAppError::from(error);
+ assert_eq!(ffi.report().category, "today");
+ assert!(!ffi.report().safe_message.is_empty());
+ }
+
+ for error in [
+ Phase1DraftError::IdentityUnavailable,
+ Phase1DraftError::InvalidDraft,
+ Phase1DraftError::InvalidMedia,
+ Phase1DraftError::InvalidQueuePolicy,
+ Phase1DraftError::RevisionConflict,
+ Phase1DraftError::NotFound,
+ Phase1DraftError::Terminal,
+ Phase1DraftError::MediaNotReady,
+ Phase1DraftError::OperationUnavailable,
+ Phase1DraftError::Operation,
+ Phase1DraftError::Storage,
+ Phase1DraftError::Overlay,
+ Phase1DraftError::Corrupt,
+ ] {
+ let ffi = RadrootsAppError::from(error);
+ assert_eq!(ffi.report().category, "authoring");
+ assert!(!ffi.report().safe_message.is_empty());
+ }
}
}
diff --git a/crates/mobile_ffi/src/lib.rs b/crates/mobile_ffi/src/lib.rs
@@ -5,12 +5,22 @@
uniffi::setup_scaffolding!("radroots_mobile_core");
+mod dto;
pub mod logging;
-mod remote;
mod runtime;
+mod signer;
+mod subscription;
-pub use error::{RadrootsAppError, SdkErrorRecord, StoreErrorRecord};
+pub use dto::*;
+pub use error::{RadrootsAppError, RadrootsErrorRecord};
pub use runtime::{ProtectedDataAvailability, RadrootsRuntime};
+pub use signer::{
+ HostSigningOutcome, HostSigningPurpose, HostSigningRequest, HostSigningResult,
+ RadrootsHostSigner, SignerAvailabilityRecord, SignerStatusRecord,
+};
+pub use subscription::{
+ FfiRuntimeChangeKind, FfiRuntimeChangeRecord, FfiSubscriptionHandle, RadrootsRuntimeObserver,
+};
mod error;
diff --git a/crates/mobile_ffi/src/remote.rs b/crates/mobile_ffi/src/remote.rs
@@ -1,143 +0,0 @@
-//! UniFFI converter ownership for ordinary Rust DTOs defined by mobile core.
-
-use radroots_mobile_core::runtime::app_info::*;
-use radroots_mobile_core::runtime::info::*;
-use radroots_mobile_core::runtime::product_surface::*;
-use radroots_mobile_core::runtime::sdk::*;
-use radroots_mobile_core::{SdkErrorRecord, StoreErrorRecord};
-
-#[uniffi::remote(Record)]
-pub struct SdkErrorRecord {
- pub schema_version: u16,
- pub code: String,
- pub class: String,
- pub retryable: bool,
- pub recovery_actions: Vec<String>,
- pub operation_id: Option<String>,
- pub capability_id: Option<String>,
- pub message: String,
-}
-
-#[uniffi::remote(Record)]
-pub struct StoreErrorRecord {
- pub schema_version: u16,
- pub code: String,
- pub class: String,
- pub retryable: bool,
- pub recovery_actions: Vec<String>,
- pub message: String,
-}
-
-#[uniffi::remote(Record)]
-pub struct AppInfoPlatform {
- pub platform: Option<String>,
- pub bundle_id: Option<String>,
- pub version: Option<String>,
- pub build_number: Option<String>,
- pub build_sha: Option<String>,
-}
-
-#[uniffi::remote(Record)]
-pub struct RuntimeBuildInfo {
- pub crate_name: String,
- pub crate_version: String,
- pub rustc: Option<String>,
- pub profile: Option<String>,
- pub lib_revision: Option<String>,
- pub consumer_revision: Option<String>,
- pub build_time_unix: Option<u64>,
-}
-
-#[uniffi::remote(Record)]
-pub struct AppInfo {
- pub build: RuntimeBuildInfo,
- pub started_unix_ms: i64,
- pub uptime_millis: i64,
- pub shutting_down: bool,
- pub platform: Option<AppInfoPlatform>,
-}
-
-#[uniffi::remote(Record)]
-pub struct RuntimeInfo {
- pub app: AppInfo,
- pub sdk: RuntimeBuildInfo,
- pub sdk_closed: bool,
-}
-
-#[uniffi::remote(Record)]
-pub struct SdkCapabilityRecord {
- pub id: String,
- pub compiled: bool,
- pub configured: bool,
- pub availability: String,
- pub maturity: String,
-}
-
-#[uniffi::remote(Record)]
-pub struct SdkStorageStatusRecord {
- pub backend: String,
- pub open_mode: String,
- pub shutdown: String,
- pub integrity: String,
-}
-
-#[uniffi::remote(Record)]
-pub struct SdkRelayStatusRecord {
- pub relay_url: String,
- pub access: String,
- pub read_state: String,
- pub write_state: String,
- pub read_last_attempt_unix_ms: Option<u64>,
- pub write_last_attempt_unix_ms: Option<u64>,
- pub read_next_attempt_unix_ms: Option<u64>,
- pub write_next_attempt_unix_ms: Option<u64>,
-}
-
-#[uniffi::remote(Record)]
-pub struct SdkRelayStatusReportRecord {
- pub profile: String,
- pub state: String,
- pub read_availability: String,
- pub write_availability: String,
- pub relays: Vec<SdkRelayStatusRecord>,
-}
-
-#[uniffi::remote(Record)]
-pub struct SdkShutdownRecord {
- pub state: String,
- pub already_closed: bool,
-}
-
-#[uniffi::remote(Enum)]
-pub enum TodayCardType {
- Update,
- PhotoUpdate,
- Ask,
- Event,
- FoodAvailability,
-}
-
-#[uniffi::remote(Enum)]
-pub enum AddCommandType {
- CreateUpdate,
- CreatePhotoUpdate,
- CreateAsk,
- CreateEvent,
- CreateFoodAvailability,
-}
-
-#[uniffi::remote(Record)]
-pub struct CardAddParity {
- pub card_type: TodayCardType,
- pub add_command_type: AddCommandType,
-}
-
-#[uniffi::remote(Record)]
-pub struct LocalNetwork {
- pub id: String,
- pub label: String,
- pub relay_urls: Vec<String>,
- pub locality: Option<String>,
- pub followed_authors: Vec<String>,
- pub generation: u64,
-}
diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs
@@ -1,12 +1,18 @@
-use radroots_mobile_core::runtime::{
- info::RuntimeInfo,
- product_surface::{AddCommandType, CardAddParity, LocalNetwork, TodayCardType},
- sdk::{
- SdkCapabilityRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, SdkStorageStatusRecord,
- },
-};
+use std::sync::Arc;
+
+use radroots_mobile_core::runtime::product_surface::TodayPageRequest;
-use crate::RadrootsAppError;
+use crate::dto::PreparedMedia;
+use crate::signer::HostSignerAdapter;
+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,
+ RadrootsAppError, RadrootsHostSigner, RadrootsRuntimeObserver, add_schemas, decode_id,
+};
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
pub enum ProtectedDataAvailability {
@@ -25,10 +31,12 @@ impl From<ProtectedDataAvailability>
}
}
-/// Native boundary object delegating all behavior to the ordinary Rust core.
+/// Native boundary object delegating all product behavior to the ordinary Rust core.
#[derive(uniffi::Object)]
pub struct RadrootsRuntime {
inner: radroots_mobile_core::RadrootsRuntime,
+ has_host_signer: bool,
+ subscriptions: Arc<SubscriptionHub>,
}
#[cfg_attr(not(coverage_nightly), uniffi::export)]
@@ -41,30 +49,58 @@ impl RadrootsRuntime {
source_generation_created_at_unix_ms: u64,
protected_data: ProtectedDataAvailability,
) -> Result<Self, RadrootsAppError> {
- let store = radroots_mobile_core::runtime::store::MobileUserStoreConfig::from_encoded(
+ build_runtime(
application_support_directory,
- public_key_hex.as_str(),
- source_generation_hex.as_str(),
+ public_key_hex,
+ source_generation_hex,
source_generation_created_at_unix_ms,
- protected_data.into(),
- )?;
- radroots_mobile_core::runtime::builder::RuntimeBuilder::new(store)
- .build()
- .await
- .map(|inner| Self { inner })
- .map_err(Into::into)
+ protected_data,
+ None,
+ )
+ .await
}
- pub async fn shutdown(&self) -> Result<SdkShutdownRecord, RadrootsAppError> {
- self.inner.shutdown().await.map_err(Into::into)
+ #[cfg_attr(not(coverage_nightly), uniffi::constructor)]
+ pub async fn with_host_signer(
+ application_support_directory: String,
+ public_key_hex: String,
+ source_generation_hex: String,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ host_signer: Box<dyn RadrootsHostSigner>,
+ ) -> Result<Self, RadrootsAppError> {
+ build_runtime(
+ application_support_directory,
+ public_key_hex,
+ source_generation_hex,
+ source_generation_created_at_unix_ms,
+ protected_data,
+ Some(host_signer),
+ )
+ .await
+ }
+
+ pub async fn shutdown(&self) -> Result<FfiShutdownRecord, RadrootsAppError> {
+ let result = self
+ .inner
+ .shutdown()
+ .await
+ .map(Into::into)
+ .map_err(Into::into);
+ if result.is_ok() {
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Lifecycle, None);
+ self.subscriptions.close();
+ }
+ result
}
pub fn uptime_millis(&self) -> i64 {
self.inner.uptime_millis()
}
- pub fn info(&self) -> RuntimeInfo {
- self.inner.info()
+ pub fn info(&self) -> FfiRuntimeInfoRecord {
+ self.inner.info().into()
}
pub fn info_json(&self) -> String {
@@ -83,16 +119,58 @@ impl RadrootsRuntime {
.set_app_info_platform(platform, bundle_id, version, build_number, build_sha);
}
- pub fn sdk_capabilities(&self) -> Vec<SdkCapabilityRecord> {
- self.inner.sdk_capabilities()
+ pub fn identity_status(&self) -> Result<FfiIdentityStatusRecord, RadrootsAppError> {
+ let public_key = self
+ .inner
+ .authenticated_store_public_key_hex()
+ .ok_or_else(|| {
+ RadrootsAppError::failure(
+ "identity_unavailable",
+ "identity",
+ true,
+ &["unlock_identity"],
+ "The active identity is unavailable.",
+ )
+ })?;
+ Ok(FfiIdentityStatusRecord {
+ schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
+ public_key,
+ host_signer_configured: self.has_host_signer,
+ })
+ }
+
+ pub fn sdk_capabilities(&self) -> Vec<FfiCapabilityRecord> {
+ self.inner
+ .sdk_capabilities()
+ .into_iter()
+ .map(Into::into)
+ .collect()
+ }
+
+ pub async fn sdk_storage_status(&self) -> Result<FfiStorageStatusRecord, RadrootsAppError> {
+ self.inner
+ .sdk_storage_status()
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn sdk_relay_status(&self) -> Result<Option<FfiRelayStatusReportRecord>, RadrootsAppError> {
+ self.inner
+ .sdk_relay_status()
+ .map(|value| value.map(Into::into))
+ .map_err(Into::into)
}
- pub async fn sdk_storage_status(&self) -> Result<SdkStorageStatusRecord, RadrootsAppError> {
- self.inner.sdk_storage_status().await.map_err(Into::into)
+ pub fn sdk_blossom_profile(&self) -> Result<Option<String>, RadrootsAppError> {
+ self.inner.sdk_blossom_profile().map_err(Into::into)
}
- pub fn sdk_relay_status(&self) -> Result<Option<SdkRelayStatusReportRecord>, RadrootsAppError> {
- self.inner.sdk_relay_status().map_err(Into::into)
+ pub fn subscribe_changes(
+ &self,
+ observer: Box<dyn RadrootsRuntimeObserver>,
+ ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> {
+ self.subscriptions.subscribe(observer)
}
pub fn configure_public_relays(
@@ -101,7 +179,9 @@ impl RadrootsRuntime {
) -> Result<(), RadrootsAppError> {
self.inner
.configure_public_relays(writable_relays)
- .map_err(Into::into)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
+ Ok(())
}
pub fn configure_simulator_relays(
@@ -110,7 +190,9 @@ impl RadrootsRuntime {
) -> Result<(), RadrootsAppError> {
self.inner
.configure_simulator_relays(loopback_relays)
- .map_err(Into::into)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
+ Ok(())
}
pub fn configure_device_relays(
@@ -119,39 +201,356 @@ impl RadrootsRuntime {
) -> Result<(), RadrootsAppError> {
self.inner
.configure_device_relays(writable_relays)
- .map_err(Into::into)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Relay, None);
+ Ok(())
}
- pub fn phase1_card_types(&self) -> Vec<TodayCardType> {
- self.inner.phase1_card_types()
+ pub fn configure_public_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_public_blossom(origins)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(())
}
- pub fn phase1_add_command_types(&self) -> Vec<AddCommandType> {
- self.inner.phase1_add_command_types()
+ pub fn configure_simulator_blossom(
+ &self,
+ origins: Vec<String>,
+ ) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_simulator_blossom(origins)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(())
+ }
+
+ pub fn configure_device_blossom(&self, origins: Vec<String>) -> Result<(), RadrootsAppError> {
+ self.inner
+ .configure_device_blossom(origins)
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Media, None);
+ Ok(())
+ }
+
+ pub fn phase1_card_add_parity(&self) -> Vec<FfiCardAddParityRecord> {
+ self.inner
+ .phase1_card_add_parity()
+ .into_iter()
+ .map(|value| FfiCardAddParityRecord {
+ schema_version: crate::MOBILE_FFI_SCHEMA_VERSION,
+ card_type: value.card_type.into(),
+ command_type: value.add_command_type.into(),
+ })
+ .collect()
}
- pub fn phase1_card_add_parity(&self) -> Vec<CardAddParity> {
- self.inner.phase1_card_add_parity()
+ pub fn phase1_add_schemas(&self) -> Vec<FfiAddSchemaRecord> {
+ add_schemas()
}
pub fn phase1_local_network(
&self,
- id: String,
- label: String,
- relay_urls: Vec<String>,
- locality: Option<String>,
- followed_authors: Vec<String>,
- generation: u64,
- ) -> Result<LocalNetwork, RadrootsAppError> {
- self.inner
- .phase1_local_network(
- id,
- label,
- relay_urls,
- locality,
- followed_authors,
- generation,
+ context: FfiLocalNetworkRecord,
+ ) -> Result<FfiLocalNetworkRecord, RadrootsAppError> {
+ LocalNetworkRecordConversion::round_trip(context)
+ }
+
+ pub async fn phase1_today_page(
+ &self,
+ context: FfiLocalNetworkRecord,
+ limit: u16,
+ as_of_unix_s: Option<u64>,
+ cursor: Option<String>,
+ ) -> Result<FfiTodayPageRecord, RadrootsAppError> {
+ if as_of_unix_s.is_some() == cursor.is_some() {
+ return Err(RadrootsAppError::invalid_argument(
+ "invalid_today_page_request",
+ ));
+ }
+ let context = context.try_into()?;
+ let request = match cursor {
+ Some(cursor) => TodayPageRequest::after(limit, cursor),
+ None => TodayPageRequest::first(
+ limit,
+ as_of_unix_s
+ .ok_or_else(|| RadrootsAppError::invalid_argument("today_as_of_required"))?,
+ ),
+ };
+ self.inner
+ .phase1_today_page(&context, request)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_refresh_today(
+ &self,
+ context: FfiLocalNetworkRecord,
+ now_unix_s: u64,
+ update: FfiTodayProjectionUpdate,
+ ) -> Result<FfiTodayRefreshRecord, RadrootsAppError> {
+ let context = context.try_into()?;
+ let receipt = self
+ .inner
+ .phase1_refresh_today(&context, now_unix_s, update.into())
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions.notify(FfiRuntimeChangeKind::Today, None);
+ Ok(receipt.into())
+ }
+
+ pub async fn phase1_search(
+ &self,
+ context: FfiLocalNetworkRecord,
+ query: String,
+ limit: u16,
+ as_of_unix_s: u64,
+ ) -> Result<Vec<FfiSearchResultRecord>, RadrootsAppError> {
+ let context = context.try_into()?;
+ self.inner
+ .phase1_search(&context, &query, limit, as_of_unix_s)
+ .await
+ .map(|results| results.into_iter().map(Into::into).collect())
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_me(
+ &self,
+ context: FfiLocalNetworkRecord,
+ as_of_unix_s: u64,
+ ) -> Result<FfiMeRecord, RadrootsAppError> {
+ let context = context.try_into()?;
+ let public_key = self
+ .inner
+ .authenticated_store_public_key_hex()
+ .ok_or_else(|| {
+ RadrootsAppError::failure(
+ "identity_unavailable",
+ "identity",
+ true,
+ &["unlock_identity"],
+ "The active identity is unavailable.",
+ )
+ })?;
+ self.inner
+ .phase1_me(&context, &public_key, as_of_unix_s)
+ .await
+ .map(Into::into)
+ .map_err(Into::into)
+ }
+
+ pub fn phase1_validate_add_draft(
+ &self,
+ input: FfiAddDraftInput,
+ authored_at_unix_s: u64,
+ ) -> Result<(), RadrootsAppError> {
+ input.command_and_media(authored_at_unix_s).map(|_| ())
+ }
+
+ #[allow(clippy::too_many_arguments)]
+ pub async fn phase1_save_draft(
+ &self,
+ draft_id: String,
+ input: FfiAddDraftInput,
+ authored_at_unix_s: u64,
+ expected_revision: Option<u64>,
+ persisted_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let (command, media) = input.command_and_media(authored_at_unix_s)?;
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_save_draft(
+ decoded_id,
+ command,
+ authored_at_unix_s,
+ media,
+ expected_revision,
+ 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,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ self.inner
+ .phase1_draft_status(decode_id(&draft_id, "invalid_draft_id")?)
+ .await
+ .map(Into::into)
.map_err(Into::into)
}
+
+ pub async fn phase1_draft_heads(
+ &self,
+ limit: u16,
+ ) -> Result<Vec<FfiDraftStatusRecord>, RadrootsAppError> {
+ self.inner
+ .phase1_draft_heads(limit)
+ .await
+ .map(|values| values.into_iter().map(Into::into).collect())
+ .map_err(Into::into)
+ }
+
+ pub async fn phase1_queue_draft(
+ &self,
+ draft_id: String,
+ expected_revision: u64,
+ policy: FfiQueuePolicyRecord,
+ queued_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_queue_draft(
+ decoded_id,
+ expected_revision,
+ policy.try_into()?,
+ queued_at_unix_ms,
+ )
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_recover_draft_queue(
+ &self,
+ draft_id: String,
+ recovered_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_recover_draft_queue(decoded_id, recovered_at_unix_ms)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+
+ pub async fn phase1_sign_queued_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_sign_queued_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,
+ ) -> Result<FfiDraftStatusRecord, 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 operation_id = decode_id(&input.operation_id, "invalid_operation_id")?;
+ let artifact_id = decode_id(&input.artifact_id, "invalid_artifact_id")?;
+ let media = PreparedMedia::try_from(input.media)?;
+ let request = media.upload_request(input.verified_at_unix_ms)?;
+ let content = radroots_blossom::authorization::AuthorizationContent::parse(
+ &input.authorization_content,
+ )
+ .map_err(|_| RadrootsAppError::invalid_argument("invalid_blossom_authorization"))?;
+ let status = self
+ .inner
+ .phase1_upload_draft_media(
+ draft_id,
+ input.expected_revision,
+ request,
+ content,
+ input.authorization_created_at_unix_s,
+ input.authorization_lifetime_seconds,
+ operation_id,
+ artifact_id,
+ input.signing_deadline_unix_ms,
+ input.signing_cancellation.core(),
+ radroots_sdk::transport::BlossomCancellation::default(),
+ input.updated_at_unix_ms,
+ )
+ .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,
+ expected_revision: u64,
+ cancelled_at_unix_ms: u64,
+ ) -> Result<FfiDraftStatusRecord, RadrootsAppError> {
+ let decoded_id = decode_id(&draft_id, "invalid_draft_id")?;
+ let status = self
+ .inner
+ .phase1_cancel_draft(decoded_id, expected_revision, cancelled_at_unix_ms)
+ .await
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Drafts, Some(draft_id));
+ Ok(status.into())
+ }
+}
+
+async fn build_runtime(
+ application_support_directory: String,
+ public_key_hex: String,
+ source_generation_hex: String,
+ source_generation_created_at_unix_ms: u64,
+ protected_data: ProtectedDataAvailability,
+ host_signer: Option<Box<dyn RadrootsHostSigner>>,
+) -> Result<RadrootsRuntime, RadrootsAppError> {
+ let store = radroots_mobile_core::runtime::store::MobileUserStoreConfig::from_encoded(
+ application_support_directory,
+ public_key_hex.as_str(),
+ source_generation_hex.as_str(),
+ source_generation_created_at_unix_ms,
+ protected_data.into(),
+ )?;
+ let has_host_signer = host_signer.is_some();
+ let mut builder = radroots_mobile_core::runtime::builder::RuntimeBuilder::new(store);
+ if let Some(host_signer) = host_signer {
+ builder = builder.signer(Arc::new(HostSignerAdapter::new(host_signer)));
+ }
+ builder
+ .build()
+ .await
+ .map(|inner| RadrootsRuntime {
+ inner,
+ has_host_signer,
+ subscriptions: SubscriptionHub::new(),
+ })
+ .map_err(Into::into)
+}
+
+struct LocalNetworkRecordConversion;
+
+impl LocalNetworkRecordConversion {
+ fn round_trip(value: FfiLocalNetworkRecord) -> Result<FfiLocalNetworkRecord, RadrootsAppError> {
+ let context: radroots_mobile_core::runtime::product_surface::LocalNetwork =
+ value.try_into()?;
+ Ok(context.into())
+ }
}
diff --git a/crates/mobile_ffi/src/signer.rs b/crates/mobile_ffi/src/signer.rs
@@ -0,0 +1,404 @@
+//! Opaque, secret-free host signing bridge.
+
+use std::sync::Arc;
+
+use radroots_event::{SignedEvent, wire::v1::Nip01EventWire};
+use radroots_signing::{
+ Error, SignReceipt, SignRequest, Signer, SignerStatus,
+ capability::{CancellationSupport, SignerCapability, SignerKind},
+ error::Kind,
+ recovery::ReplayCapability,
+ signer::BoxFuture,
+ status::SignerAvailability,
+};
+
+use crate::MOBILE_FFI_SCHEMA_VERSION;
+
+const SIGNED_EVENT_MAX_BYTES: usize = 1_048_576;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum SignerAvailabilityRecord {
+ Ready,
+ Busy,
+ Locked,
+ Unavailable,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct SignerStatusRecord {
+ pub schema_version: u16,
+ pub availability: SignerAvailabilityRecord,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum HostSigningPurpose {
+ NostrEvent,
+ BlossomUpload,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct HostSigningRequest {
+ pub schema_version: u16,
+ pub operation_kind: String,
+ pub operation_id: String,
+ pub artifact_id: String,
+ pub signer_request_id: String,
+ pub public_key: String,
+ pub purpose: HostSigningPurpose,
+ pub deadline_unix_ms: u64,
+ pub event_id_digest: Vec<u8>,
+ pub expected_event_id: String,
+ pub created_at_unix_s: u64,
+ pub kind: u32,
+ pub tags: Vec<Vec<String>>,
+ pub content: String,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum HostSigningOutcome {
+ Signed,
+ Locked,
+ Cancelled,
+ Rejected,
+ TimedOut,
+ Unavailable,
+ Invalidated,
+ Failed,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct HostSigningResult {
+ pub schema_version: u16,
+ pub outcome: HostSigningOutcome,
+ pub operation_id: String,
+ pub signer_request_id: String,
+ pub public_key: String,
+ pub purpose: HostSigningPurpose,
+ pub signature_hex: Option<String>,
+ pub completed_at_unix_ms: u64,
+}
+
+#[uniffi::export(callback_interface)]
+#[async_trait::async_trait]
+pub trait RadrootsHostSigner: Send + Sync {
+ async fn signer_status(&self) -> SignerStatusRecord;
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult;
+}
+
+pub(crate) struct HostSignerAdapter {
+ host: Arc<dyn RadrootsHostSigner>,
+}
+
+impl HostSignerAdapter {
+ pub(crate) fn new(host: Box<dyn RadrootsHostSigner>) -> Self {
+ Self {
+ host: Arc::from(host),
+ }
+ }
+}
+
+impl Signer for HostSignerAdapter {
+ fn status(&self) -> BoxFuture<'_, Result<SignerStatus, Error>> {
+ Box::pin(async move {
+ let status = self.host.signer_status().await;
+ if status.schema_version != MOBILE_FFI_SCHEMA_VERSION {
+ return Err(Error::new(Kind::SignerOutputInvalid));
+ }
+ let availability = match status.availability {
+ SignerAvailabilityRecord::Ready => SignerAvailability::Ready,
+ SignerAvailabilityRecord::Busy => SignerAvailability::Busy,
+ SignerAvailabilityRecord::Locked => SignerAvailability::AwaitingAuthentication,
+ SignerAvailabilityRecord::Unavailable => SignerAvailability::Unavailable,
+ };
+ Ok(SignerStatus::new(
+ availability,
+ vec![SignerCapability::new(
+ SignerKind::HostMediated,
+ ReplayCapability::ExactReplayByRequestId,
+ CancellationSupport::BeforeAndAfterPublication,
+ false,
+ true,
+ )],
+ None,
+ ))
+ })
+ }
+
+ fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, Error>> {
+ Box::pin(async move {
+ request.ensure_active(now_unix_ms())?;
+ let ffi_request = HostSigningRequest::from_request(&request)?;
+ let result = self.host.sign(ffi_request.clone()).await;
+ request.ensure_active(now_unix_ms())?;
+ request.ensure_active(result.completed_at_unix_ms)?;
+ validate_result_binding(&ffi_request, &result)?;
+ match result.outcome {
+ HostSigningOutcome::Signed => signed_receipt(&request, result),
+ HostSigningOutcome::Locked | HostSigningOutcome::Unavailable => {
+ Err(Error::new(Kind::SignerUnavailable))
+ }
+ HostSigningOutcome::Cancelled => Err(Error::new(Kind::SignerCancelled)),
+ HostSigningOutcome::Rejected => Err(Error::new(Kind::SignerRejected)),
+ HostSigningOutcome::TimedOut => Err(Error::new(Kind::SignerTimeout)),
+ HostSigningOutcome::Invalidated => Err(Error::new(Kind::SignerOutputInvalid)),
+ HostSigningOutcome::Failed => Err(Error::new(Kind::InternalError)),
+ }
+ })
+ }
+}
+
+impl HostSigningRequest {
+ fn from_request(request: &SignRequest) -> Result<Self, Error> {
+ let purpose = match request.purpose() {
+ radroots_signing::SigningPurpose::AuthoredEvent => HostSigningPurpose::NostrEvent,
+ radroots_signing::SigningPurpose::BlossomUploadAuthorization => {
+ HostSigningPurpose::BlossomUpload
+ }
+ _ => return Err(Error::new(Kind::SignerOutputInvalid)),
+ };
+ Ok(Self {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ operation_kind: request.operation_kind().as_str().to_owned(),
+ operation_id: uuid_string(request.intent_id().operation_id().as_bytes()),
+ artifact_id: hex::encode(request.intent_id().artifact_id().as_bytes()),
+ signer_request_id: request.signer_request_id().to_hex(),
+ public_key: request.expected_author().to_hex(),
+ purpose,
+ deadline_unix_ms: request.policy().deadline_unix_ms(),
+ event_id_digest: request.expected_event_id().as_bytes().to_vec(),
+ expected_event_id: request.expected_event_id().to_hex(),
+ created_at_unix_s: request.created_at(),
+ kind: request.kind(),
+ tags: request.tags().to_vec(),
+ content: request.content().to_owned(),
+ })
+ }
+}
+
+fn validate_result_binding(
+ request: &HostSigningRequest,
+ result: &HostSigningResult,
+) -> Result<(), Error> {
+ if result.schema_version != MOBILE_FFI_SCHEMA_VERSION
+ || result.operation_id != request.operation_id
+ || result.signer_request_id != request.signer_request_id
+ || result.public_key != request.public_key
+ || result.purpose != request.purpose
+ || result.completed_at_unix_ms == 0
+ || (result.outcome == HostSigningOutcome::Signed) != result.signature_hex.is_some()
+ {
+ return Err(Error::new(Kind::SignerOutputInvalid));
+ }
+ Ok(())
+}
+
+fn signed_receipt(request: &SignRequest, result: HostSigningResult) -> Result<SignReceipt, Error> {
+ let signature = result
+ .signature_hex
+ .filter(|value| {
+ value.len() == 128
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+ })
+ .ok_or_else(|| Error::new(Kind::SignerOutputInvalid))?;
+ let wire = Nip01EventWire {
+ id: request.expected_event_id().to_hex(),
+ pubkey: request.expected_author().to_hex(),
+ created_at: request.created_at(),
+ kind: request.kind(),
+ tags: request.tags().to_vec(),
+ content: request.content().to_owned(),
+ sig: signature,
+ extra: Default::default(),
+ };
+ let raw_json = serde_json::to_string(&wire).map_err(|_| Error::new(Kind::InternalError))?;
+ if raw_json.len() > SIGNED_EVENT_MAX_BYTES {
+ return Err(Error::new(Kind::SignerOutputInvalid));
+ }
+ let signed = SignedEvent::from_wire_verified_id(wire, raw_json)
+ .map_err(|_| Error::new(Kind::SignerOutputInvalid))?;
+ SignReceipt::from_signed_event(request, signed, result.completed_at_unix_ms)
+}
+
+fn uuid_string(bytes: &[u8; 16]) -> String {
+ let hex = hex::encode(bytes);
+ format!(
+ "{}-{}-{}-{}-{}",
+ &hex[0..8],
+ &hex[8..12],
+ &hex[12..16],
+ &hex[16..20],
+ &hex[20..32]
+ )
+}
+
+fn now_unix_ms() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |duration| {
+ duration.as_millis().try_into().unwrap_or(u64::MAX)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ struct StatusHost {
+ schema_version: u16,
+ availability: SignerAvailabilityRecord,
+ }
+
+ #[async_trait::async_trait]
+ impl RadrootsHostSigner for StatusHost {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: self.schema_version,
+ availability: self.availability,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome: HostSigningOutcome::Failed,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex: None,
+ completed_at_unix_ms: 1,
+ }
+ }
+ }
+
+ #[test]
+ fn opaque_operation_identity_has_canonical_uuid_shape() {
+ assert_eq!(
+ uuid_string(&[0xabu8; 16]),
+ "abababab-abab-abab-abab-abababababab"
+ );
+ }
+
+ #[test]
+ fn result_binding_rejects_signature_on_failure() {
+ let request = HostSigningRequest {
+ schema_version: 1,
+ operation_kind: "sync.push".to_owned(),
+ operation_id: "11111111-1111-1111-1111-111111111111".to_owned(),
+ artifact_id: "22".repeat(16),
+ signer_request_id: "33".repeat(32),
+ public_key: "44".repeat(32),
+ purpose: HostSigningPurpose::NostrEvent,
+ deadline_unix_ms: 10,
+ event_id_digest: vec![5; 32],
+ expected_event_id: "55".repeat(32),
+ created_at_unix_s: 1,
+ kind: 1,
+ tags: Vec::new(),
+ content: "test".to_owned(),
+ };
+ let result = HostSigningResult {
+ schema_version: 1,
+ outcome: HostSigningOutcome::Failed,
+ operation_id: request.operation_id.clone(),
+ signer_request_id: request.signer_request_id.clone(),
+ public_key: request.public_key.clone(),
+ purpose: request.purpose,
+ signature_hex: Some("66".repeat(64)),
+ completed_at_unix_ms: 2,
+ };
+ assert_eq!(
+ validate_result_binding(&request, &result)
+ .expect_err("failure cannot carry signature")
+ .kind(),
+ Kind::SignerOutputInvalid
+ );
+
+ let valid = HostSigningResult {
+ signature_hex: None,
+ ..result.clone()
+ };
+ assert!(validate_result_binding(&request, &valid).is_ok());
+ let invalid_results = [
+ HostSigningResult {
+ schema_version: 2,
+ ..valid.clone()
+ },
+ HostSigningResult {
+ operation_id: "different".to_owned(),
+ ..valid.clone()
+ },
+ HostSigningResult {
+ signer_request_id: "different".to_owned(),
+ ..valid.clone()
+ },
+ HostSigningResult {
+ public_key: "different".to_owned(),
+ ..valid.clone()
+ },
+ HostSigningResult {
+ purpose: HostSigningPurpose::BlossomUpload,
+ ..valid.clone()
+ },
+ HostSigningResult {
+ completed_at_unix_ms: 0,
+ ..valid.clone()
+ },
+ HostSigningResult {
+ outcome: HostSigningOutcome::Signed,
+ signature_hex: None,
+ ..valid
+ },
+ ];
+ for invalid in invalid_results {
+ assert_eq!(
+ validate_result_binding(&request, &invalid)
+ .expect_err("binding mismatch")
+ .kind(),
+ Kind::SignerOutputInvalid
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn host_status_maps_every_availability_and_rejects_schema_drift() {
+ for (ffi, expected) in [
+ (SignerAvailabilityRecord::Ready, SignerAvailability::Ready),
+ (SignerAvailabilityRecord::Busy, SignerAvailability::Busy),
+ (
+ SignerAvailabilityRecord::Locked,
+ SignerAvailability::AwaitingAuthentication,
+ ),
+ (
+ SignerAvailabilityRecord::Unavailable,
+ SignerAvailability::Unavailable,
+ ),
+ ] {
+ let adapter = HostSignerAdapter::new(Box::new(StatusHost {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: ffi,
+ }));
+ assert_eq!(
+ Signer::status(&adapter)
+ .await
+ .expect("mapped host status")
+ .availability(),
+ expected
+ );
+ }
+
+ let adapter = HostSignerAdapter::new(Box::new(StatusHost {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
+ availability: SignerAvailabilityRecord::Ready,
+ }));
+ assert_eq!(
+ Signer::status(&adapter)
+ .await
+ .expect_err("schema drift")
+ .kind(),
+ Kind::SignerOutputInvalid
+ );
+ }
+}
diff --git a/crates/mobile_ffi/src/subscription.rs b/crates/mobile_ffi/src/subscription.rs
@@ -0,0 +1,319 @@
+//! Bounded, independent host subscriptions for focused runtime invalidation signals.
+
+use std::collections::BTreeMap;
+use std::panic::{AssertUnwindSafe, catch_unwind};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::mpsc::{SyncSender, TrySendError, sync_channel};
+use std::sync::{Arc, Mutex, Weak};
+
+use crate::{MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError};
+
+const MAX_SUBSCRIPTIONS: usize = 32;
+const CHANGE_BUFFER_CAPACITY: usize = 16;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum FfiRuntimeChangeKind {
+ Initial,
+ Identity,
+ Today,
+ Drafts,
+ Relay,
+ Media,
+ Lifecycle,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
+pub struct FfiRuntimeChangeRecord {
+ pub schema_version: u16,
+ pub generation: u64,
+ pub kind: FfiRuntimeChangeKind,
+ pub entity_id: Option<String>,
+}
+
+#[uniffi::export(callback_interface)]
+pub trait RadrootsRuntimeObserver: Send + Sync {
+ fn on_change(&self, change: FfiRuntimeChangeRecord);
+}
+
+pub(crate) struct SubscriptionHub {
+ next_id: AtomicU64,
+ generation: AtomicU64,
+ closed: AtomicBool,
+ subscriptions: Mutex<BTreeMap<u64, SyncSender<FfiRuntimeChangeRecord>>>,
+}
+
+impl SubscriptionHub {
+ pub(crate) fn new() -> Arc<Self> {
+ Arc::new(Self {
+ next_id: AtomicU64::new(1),
+ generation: AtomicU64::new(1),
+ closed: AtomicBool::new(false),
+ subscriptions: Mutex::new(BTreeMap::new()),
+ })
+ }
+
+ pub(crate) fn subscribe(
+ self: &Arc<Self>,
+ observer: Box<dyn RadrootsRuntimeObserver>,
+ ) -> Result<Arc<FfiSubscriptionHandle>, RadrootsAppError> {
+ if self.closed.load(Ordering::Acquire) {
+ return Err(subscription_error("runtime_closed", false));
+ }
+ let id = self.next_id.fetch_add(1, Ordering::AcqRel);
+ let (sender, receiver) = sync_channel(CHANGE_BUFFER_CAPACITY);
+ let observer: Arc<dyn RadrootsRuntimeObserver> = Arc::from(observer);
+ let hub = Arc::downgrade(self);
+ std::thread::Builder::new()
+ .name(format!("radroots-ffi-observer-{id}"))
+ .spawn(move || {
+ while let Ok(change) = receiver.recv() {
+ if catch_unwind(AssertUnwindSafe(|| observer.on_change(change))).is_err() {
+ break;
+ }
+ }
+ if let Some(hub) = hub.upgrade() {
+ hub.remove(id);
+ }
+ })
+ .map_err(|_| subscription_error("subscription_worker_unavailable", true))?;
+
+ {
+ let mut subscriptions = self
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if self.closed.load(Ordering::Acquire) || subscriptions.len() >= MAX_SUBSCRIPTIONS {
+ return Err(subscription_error("subscription_limit_reached", true));
+ }
+ subscriptions.insert(id, sender.clone());
+ }
+
+ let initial = FfiRuntimeChangeRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ generation: self.generation.load(Ordering::Acquire),
+ kind: FfiRuntimeChangeKind::Initial,
+ entity_id: None,
+ };
+ let _ = sender.try_send(initial);
+ Ok(Arc::new(FfiSubscriptionHandle {
+ hub: Arc::downgrade(self),
+ id: Mutex::new(Some(id)),
+ }))
+ }
+
+ pub(crate) fn notify(&self, kind: FfiRuntimeChangeKind, entity_id: Option<String>) {
+ if self.closed.load(Ordering::Acquire) {
+ return;
+ }
+ let change = FfiRuntimeChangeRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ generation: self.generation.fetch_add(1, Ordering::AcqRel) + 1,
+ kind,
+ entity_id,
+ };
+ let senders = self
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .iter()
+ .map(|(id, sender)| (*id, sender.clone()))
+ .collect::<Vec<_>>();
+ let mut disconnected = Vec::new();
+ for (id, sender) in senders {
+ match sender.try_send(change.clone()) {
+ Ok(()) | Err(TrySendError::Full(_)) => {}
+ Err(TrySendError::Disconnected(_)) => disconnected.push(id),
+ }
+ }
+ if !disconnected.is_empty() {
+ let mut subscriptions = self
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ for id in disconnected {
+ subscriptions.remove(&id);
+ }
+ }
+ }
+
+ pub(crate) fn close(&self) {
+ if !self.closed.swap(true, Ordering::AcqRel) {
+ self.generation.fetch_add(1, Ordering::AcqRel);
+ self.subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .clear();
+ }
+ }
+
+ fn remove(&self, id: u64) {
+ self.subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .remove(&id);
+ }
+}
+
+#[derive(uniffi::Object)]
+pub struct FfiSubscriptionHandle {
+ hub: Weak<SubscriptionHub>,
+ id: Mutex<Option<u64>>,
+}
+
+#[uniffi::export]
+impl FfiSubscriptionHandle {
+ pub fn unsubscribe(&self) {
+ let id = self
+ .id
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take();
+ if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) {
+ hub.remove(id);
+ }
+ }
+
+ pub fn is_active(&self) -> bool {
+ let id = *self
+ .id
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let (Some(hub), Some(id)) = (self.hub.upgrade(), id) else {
+ return false;
+ };
+ !hub.closed.load(Ordering::Acquire)
+ && hub
+ .subscriptions
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .contains_key(&id)
+ }
+}
+
+impl Drop for FfiSubscriptionHandle {
+ fn drop(&mut self) {
+ let id = self
+ .id
+ .get_mut()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .take();
+ if let (Some(hub), Some(id)) = (self.hub.upgrade(), id) {
+ hub.remove(id);
+ }
+ }
+}
+
+fn subscription_error(code: &str, retryable: bool) -> RadrootsAppError {
+ RadrootsAppError::failure(
+ code,
+ "subscription",
+ retryable,
+ if retryable { &["retry"] } else { &[] },
+ "The runtime change subscription is unavailable.",
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{Arc, Condvar};
+ use std::time::{Duration, Instant};
+
+ use super::*;
+
+ struct NoopObserver;
+
+ impl RadrootsRuntimeObserver for NoopObserver {
+ fn on_change(&self, _change: FfiRuntimeChangeRecord) {}
+ }
+
+ struct PanicObserver;
+
+ impl RadrootsRuntimeObserver for PanicObserver {
+ fn on_change(&self, _change: FfiRuntimeChangeRecord) {
+ panic!("observer panic is isolated");
+ }
+ }
+
+ struct BlockingObserver(Arc<(Mutex<bool>, Condvar)>);
+
+ impl RadrootsRuntimeObserver for BlockingObserver {
+ fn on_change(&self, change: FfiRuntimeChangeRecord) {
+ if change.kind == FfiRuntimeChangeKind::Initial {
+ let (released, wake) = &*self.0;
+ let guard = released
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let _guard = wake
+ .wait_while(guard, |released| !*released)
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ }
+ }
+ }
+
+ #[test]
+ fn closed_limit_and_detached_handle_paths_are_typed_and_idempotent() {
+ let closed = SubscriptionHub::new();
+ closed.close();
+ closed.close();
+ closed.notify(FfiRuntimeChangeKind::Today, None);
+ let error = closed
+ .subscribe(Box::new(NoopObserver))
+ .err()
+ .expect("closed hub");
+ assert_eq!(error.report().code, "runtime_closed");
+ assert!(!error.report().retryable);
+
+ let hub = SubscriptionHub::new();
+ let handles = (0..MAX_SUBSCRIPTIONS)
+ .map(|_| hub.subscribe(Box::new(NoopObserver)).expect("subscription"))
+ .collect::<Vec<_>>();
+ let error = hub
+ .subscribe(Box::new(NoopObserver))
+ .err()
+ .expect("bounded subscription limit");
+ assert_eq!(error.report().code, "subscription_limit_reached");
+ assert!(error.report().retryable);
+ drop(handles);
+
+ let detached_hub = SubscriptionHub::new();
+ let detached = detached_hub
+ .subscribe(Box::new(NoopObserver))
+ .expect("detached subscription");
+ drop(detached_hub);
+ assert!(!detached.is_active());
+ detached.unsubscribe();
+ detached.unsubscribe();
+ }
+
+ #[test]
+ fn callback_panics_and_full_buffers_never_escape_or_block_publishers() {
+ let hub = SubscriptionHub::new();
+ let panicking = hub
+ .subscribe(Box::new(PanicObserver))
+ .expect("panicking subscription");
+ let deadline = Instant::now() + Duration::from_secs(1);
+ while panicking.is_active() && Instant::now() < deadline {
+ std::thread::yield_now();
+ }
+ assert!(!panicking.is_active());
+
+ let release = Arc::new((Mutex::new(false), Condvar::new()));
+ let blocked = hub
+ .subscribe(Box::new(BlockingObserver(Arc::clone(&release))))
+ .expect("blocked subscription");
+ for generation in 0..=CHANGE_BUFFER_CAPACITY {
+ hub.notify(
+ FfiRuntimeChangeKind::Drafts,
+ Some(format!("draft-{generation}")),
+ );
+ }
+ assert!(blocked.is_active());
+ let (released, wake) = &*release;
+ *released
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
+ wake.notify_all();
+ hub.close();
+ assert!(!blocked.is_active());
+ }
+}
diff --git a/crates/mobile_ffi/tests/logging_error.rs b/crates/mobile_ffi/tests/logging_error.rs
@@ -5,5 +5,9 @@ use radroots_mobile_ffi::logging;
fn init_logging_stdout_maps_global_subscriber_error() {
let _ = tracing_subscriber::fmt().try_init();
let err = logging::init_logging_stdout();
- assert!(matches!(err, Err(RadrootsAppError::Initialization(_))));
+ assert!(matches!(
+ err,
+ Err(RadrootsAppError::Failure { report })
+ if report.code == "initialization_failed"
+ ));
}
diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs
@@ -1,5 +1,9 @@
-use radroots_mobile_core::runtime::product_surface::{AddCommandType, TodayCardType};
-use radroots_mobile_ffi::RadrootsAppError;
+use radroots_mobile_ffi::{
+ FfiAddCommandType, FfiAddDraftInput, FfiBlossomUploadInput, FfiCancellationPolicy,
+ FfiLocalNetworkRecord, FfiOutboxState, FfiPreparedMediaInput, FfiQueuePolicyRecord,
+ FfiRelaySatisfaction, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION,
+ RadrootsAppError,
+};
mod support;
@@ -84,70 +88,286 @@ async fn native_boundary_delegates_the_complete_core_surface() {
.is_err()
);
+ runtime
+ .configure_public_blossom(vec!["https://media.example".to_owned()])
+ .expect("public Blossom");
+ assert_eq!(
+ runtime.sdk_blossom_profile().expect("Blossom profile"),
+ Some("public".to_owned())
+ );
+ runtime
+ .configure_simulator_blossom(vec!["http://127.0.0.1:3100".to_owned()])
+ .expect("simulator Blossom");
+ runtime
+ .configure_device_blossom(vec!["https://10.0.0.5:3100".to_owned()])
+ .expect("device Blossom");
+
assert_eq!(
- runtime.phase1_card_types(),
+ runtime
+ .phase1_card_add_parity()
+ .into_iter()
+ .map(|item| item.card_type)
+ .collect::<Vec<_>>(),
vec![
- TodayCardType::Update,
- TodayCardType::PhotoUpdate,
- TodayCardType::Ask,
- TodayCardType::Event,
- TodayCardType::FoodAvailability,
+ FfiTodayCardType::Update,
+ FfiTodayCardType::PhotoUpdate,
+ FfiTodayCardType::Ask,
+ FfiTodayCardType::Event,
+ FfiTodayCardType::FoodAvailability,
]
);
assert_eq!(
- runtime.phase1_add_command_types(),
+ runtime
+ .phase1_add_schemas()
+ .into_iter()
+ .map(|schema| schema.command_type)
+ .collect::<Vec<_>>(),
vec![
- AddCommandType::CreateUpdate,
- AddCommandType::CreatePhotoUpdate,
- AddCommandType::CreateAsk,
- AddCommandType::CreateEvent,
- AddCommandType::CreateFoodAvailability,
+ FfiAddCommandType::CreateUpdate,
+ FfiAddCommandType::CreatePhotoUpdate,
+ FfiAddCommandType::CreateAsk,
+ FfiAddCommandType::CreateEvent,
+ FfiAddCommandType::CreateFoodAvailability,
]
);
let parity = runtime.phase1_card_add_parity();
+ let schemas = runtime.phase1_add_schemas();
assert_eq!(parity.len(), 5);
for (index, item) in parity.iter().enumerate() {
- assert_eq!(item.card_type, runtime.phase1_card_types()[index]);
- assert_eq!(
- item.add_command_type,
- runtime.phase1_add_command_types()[index]
- );
+ assert_eq!(item.command_type, schemas[index].command_type);
}
let local_network = runtime
- .phase1_local_network(
- "nearby".to_owned(),
- "Near me".to_owned(),
- vec!["wss://relay.example".to_owned()],
- Some("u10h".to_owned()),
- vec!["a".repeat(64)],
- 1,
- )
+ .phase1_local_network(FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "nearby".to_owned(),
+ label: "Near me".to_owned(),
+ relay_urls: vec!["wss://relay.example".to_owned()],
+ locality: Some("u10h".to_owned()),
+ followed_authors: vec!["a".repeat(64)],
+ generation: 1,
+ })
.expect("valid local network");
assert_eq!(local_network.id, "nearby");
+ let refresh = runtime
+ .phase1_refresh_today(
+ local_network.clone(),
+ 1_800_000_001,
+ FfiTodayProjectionUpdate::Incremental,
+ )
+ .await
+ .expect("empty Today refresh");
+ assert_eq!(refresh.update, FfiTodayProjectionUpdate::Incremental);
+ assert_eq!(refresh.source_events, 0);
+ assert_eq!(refresh.visible_cards, 0);
+ assert!(refresh.content_generation > 0);
+ let today = runtime
+ .phase1_today_page(local_network.clone(), 20, Some(1_800_000_001), None)
+ .await
+ .expect("empty Today page");
+ assert!(today.items.is_empty());
+ assert!(today.next_cursor.is_none());
+ assert!(
+ runtime
+ .phase1_today_page(local_network.clone(), 20, None, None)
+ .await
+ .is_err()
+ );
+ assert!(
+ runtime
+ .phase1_today_page(
+ local_network.clone(),
+ 20,
+ Some(1_800_000_001),
+ Some("opaque".to_owned()),
+ )
+ .await
+ .is_err()
+ );
assert!(
runtime
- .phase1_local_network(
- "nearby".to_owned(),
- "Near me".to_owned(),
- Vec::new(),
- None,
- Vec::new(),
- 1,
+ .phase1_search(
+ FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "nearby".to_owned(),
+ label: "Near me".to_owned(),
+ relay_urls: vec!["wss://relay.example".to_owned()],
+ locality: Some("u10h".to_owned()),
+ followed_authors: vec!["a".repeat(64)],
+ generation: 1,
+ },
+ "carrots".to_owned(),
+ 20,
+ 1_800_000_001,
)
+ .await
+ .expect("empty search")
+ .is_empty()
+ );
+ let me = runtime
+ .phase1_me(
+ FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "nearby".to_owned(),
+ label: "Near me".to_owned(),
+ relay_urls: vec!["wss://relay.example".to_owned()],
+ locality: Some("u10h".to_owned()),
+ followed_authors: vec!["a".repeat(64)],
+ generation: 1,
+ },
+ 1_800_000_001,
+ )
+ .await
+ .expect("Me snapshot");
+ assert_eq!(me.public_key, support::PUBLIC_KEY);
+
+ let add = FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: "Farm stand opens at noon".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(),
+ };
+ runtime
+ .phase1_validate_add_draft(add.clone(), 1_800_000_001)
+ .expect("valid draft");
+ let draft_id = "07".repeat(16);
+ let saved = runtime
+ .phase1_save_draft(
+ draft_id.clone(),
+ add,
+ 1_800_000_001,
+ None,
+ 1_800_000_001_000,
+ )
+ .await
+ .expect("saved draft");
+ assert_eq!(saved.state, FfiOutboxState::Draft);
+ assert_eq!(
+ runtime
+ .phase1_draft_status(draft_id.clone())
+ .await
+ .expect("draft status")
+ .revision,
+ saved.revision
+ );
+ assert_eq!(
+ runtime
+ .phase1_draft_heads(10)
+ .await
+ .expect("draft heads")
+ .len(),
+ 1
+ );
+ let queued = runtime
+ .phase1_queue_draft(
+ draft_id.clone(),
+ saved.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_002_000,
+ )
+ .await
+ .expect("queued draft");
+ assert_eq!(queued.state, FfiOutboxState::Queued);
+ let recovered = runtime
+ .phase1_recover_draft_queue(draft_id.clone(), 1_800_000_003_000)
+ .await
+ .expect("recovered queue");
+ assert_eq!(recovered.revision, queued.revision);
+ let cancelled = runtime
+ .phase1_cancel_draft(draft_id.clone(), recovered.revision, 1_800_000_004_000)
+ .await
+ .expect("cancelled draft");
+ assert_eq!(cancelled.state, FfiOutboxState::Cancelled);
+
+ let upload = FfiBlossomUploadInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
+ draft_id,
+ expected_revision: cancelled.revision,
+ media: FfiPreparedMediaInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ opaque_reference: "media:unused".to_owned(),
+ file_descriptor: 0,
+ url: "https://media.example/unused.png".to_owned(),
+ sha256: "00".repeat(32),
+ media_type: "image/png".to_owned(),
+ byte_size: 1,
+ width: 1,
+ height: 1,
+ alt: "unused".to_owned(),
+ prepared_at_unix_s: 1_800_000_000,
+ },
+ authorization_content: "Upload exact image".to_owned(),
+ authorization_created_at_unix_s: 1_800_000_000,
+ authorization_lifetime_seconds: 60,
+ operation_id: "08".repeat(16),
+ artifact_id: "09".repeat(16),
+ signing_deadline_unix_ms: 1_800_000_100_000,
+ signing_cancellation: FfiCancellationPolicy::LocalCooperative,
+ verified_at_unix_ms: 1_800_000_000_000,
+ updated_at_unix_ms: 1_800_000_005_000,
+ };
+ let upload_error = runtime
+ .phase1_upload_draft_media(upload.clone())
+ .await
+ .expect_err("unsupported upload schema");
+ assert_eq!(upload_error.report().code, "unsupported_schema_version");
+ let mut invalid_id_upload = upload;
+ invalid_id_upload.schema_version = MOBILE_FFI_SCHEMA_VERSION;
+ invalid_id_upload.draft_id = "not-a-draft-id".to_owned();
+ assert_eq!(
+ runtime
+ .phase1_upload_draft_media(invalid_id_upload)
+ .await
+ .expect_err("invalid draft id")
+ .report()
+ .code,
+ "invalid_draft_id"
+ );
+ assert!(
+ runtime
+ .phase1_local_network(FfiLocalNetworkRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ id: "nearby".to_owned(),
+ label: "Near me".to_owned(),
+ relay_urls: Vec::new(),
+ locality: None,
+ followed_authors: Vec::new(),
+ generation: 1,
+ })
.is_err()
);
runtime.shutdown().await.expect("shutdown");
assert!(matches!(
runtime.sdk_storage_status().await,
- Err(RadrootsAppError::Sdk { .. })
+ Err(RadrootsAppError::Failure { .. })
));
assert!(matches!(
runtime.sdk_relay_status(),
- Err(RadrootsAppError::Sdk { .. })
+ Err(RadrootsAppError::Failure { .. })
));
assert!(matches!(
runtime.configure_public_relays(Vec::new()),
- Err(RadrootsAppError::Sdk { .. })
+ Err(RadrootsAppError::Failure { .. })
));
}
diff --git a/crates/mobile_ffi/tests/runtime_lifecycle.rs b/crates/mobile_ffi/tests/runtime_lifecycle.rs
@@ -34,7 +34,7 @@ async fn concurrent_host_references_converge_and_repeated_close_is_idempotent()
outcome.is_ok()
|| matches!(
outcome,
- Err(RadrootsAppError::Sdk { report })
+ Err(RadrootsAppError::Failure { report })
if report.code == "client_close_in_progress"
)
);
diff --git a/crates/mobile_ffi/tests/subscription_contract.rs b/crates/mobile_ffi/tests/subscription_contract.rs
@@ -0,0 +1,67 @@
+use std::sync::mpsc::{Receiver, Sender, channel};
+use std::time::Duration;
+
+use radroots_mobile_ffi::{FfiRuntimeChangeKind, FfiRuntimeChangeRecord, RadrootsRuntimeObserver};
+
+mod support;
+
+struct Observer(Sender<FfiRuntimeChangeRecord>);
+
+impl RadrootsRuntimeObserver for Observer {
+ fn on_change(&self, change: FfiRuntimeChangeRecord) {
+ let _ = self.0.send(change);
+ }
+}
+
+fn observer() -> (
+ Box<dyn RadrootsRuntimeObserver>,
+ Receiver<FfiRuntimeChangeRecord>,
+) {
+ let (sender, receiver) = channel();
+ (Box::new(Observer(sender)), receiver)
+}
+
+fn receive(receiver: &Receiver<FfiRuntimeChangeRecord>) -> FfiRuntimeChangeRecord {
+ receiver
+ .recv_timeout(Duration::from_secs(1))
+ .expect("bounded observer delivery")
+}
+
+#[tokio::test]
+async fn subscriptions_are_independent_bounded_handles_and_stop_individually() {
+ let (_root, runtime) = support::runtime().await;
+ let (first_observer, first_receiver) = observer();
+ let (second_observer, second_receiver) = observer();
+ let first = runtime
+ .subscribe_changes(first_observer)
+ .expect("first subscription");
+ let second = runtime
+ .subscribe_changes(second_observer)
+ .expect("second subscription");
+
+ assert_eq!(receive(&first_receiver).kind, FfiRuntimeChangeKind::Initial);
+ assert_eq!(
+ receive(&second_receiver).kind,
+ FfiRuntimeChangeKind::Initial
+ );
+ first.unsubscribe();
+ assert!(!first.is_active());
+ assert!(second.is_active());
+
+ runtime
+ .configure_public_relays(vec!["wss://write.example".to_owned()])
+ .expect("relay configuration");
+ assert_eq!(receive(&second_receiver).kind, FfiRuntimeChangeKind::Relay);
+ assert!(
+ first_receiver
+ .recv_timeout(Duration::from_millis(50))
+ .is_err()
+ );
+
+ runtime.shutdown().await.expect("shutdown");
+ assert_eq!(
+ receive(&second_receiver).kind,
+ FfiRuntimeChangeKind::Lifecycle
+ );
+ assert!(!second.is_active());
+}
diff --git a/crates/mobile_ffi/tests/uniffi_contract.rs b/crates/mobile_ffi/tests/uniffi_contract.rs
@@ -1,9 +1,72 @@
use radroots_mobile_ffi::{
- ProtectedDataAvailability, RadrootsAppError, RadrootsRuntime, SdkErrorRecord,
+ FfiAddCommandType, FfiAddDraftInput, FfiCancellationPolicy, FfiQueuePolicyRecord,
+ FfiRelaySatisfaction, HostSigningOutcome, HostSigningRequest, HostSigningResult,
+ MOBILE_FFI_SCHEMA_VERSION, ProtectedDataAvailability, RadrootsAppError, RadrootsHostSigner,
+ RadrootsRuntime, SignerAvailabilityRecord, SignerStatusRecord,
};
+use secp256k1::{Keypair, Message, Secp256k1, SecretKey};
+use std::sync::{Arc, Mutex};
mod support;
+struct TestHostSigner(Arc<Mutex<HostSigningOutcome>>);
+
+#[async_trait::async_trait]
+impl RadrootsHostSigner for TestHostSigner {
+ async fn signer_status(&self) -> SignerStatusRecord {
+ SignerStatusRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ availability: SignerAvailabilityRecord::Ready,
+ }
+ }
+
+ async fn sign(&self, request: HostSigningRequest) -> HostSigningResult {
+ let outcome = *self
+ .0
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ let signature_hex = (outcome == HostSigningOutcome::Signed).then(|| {
+ let mut secret_bytes = [0; 32];
+ secret_bytes[31] = 1;
+ let secret = SecretKey::from_slice(&secret_bytes).expect("fixture secret key");
+ let keypair = Keypair::from_secret_key(&Secp256k1::new(), &secret);
+ let digest: [u8; 32] = request
+ .event_id_digest
+ .clone()
+ .try_into()
+ .expect("32-byte event ID digest");
+ assert_eq!(hex::encode(digest), request.expected_event_id);
+ assert_eq!(
+ keypair.x_only_public_key().0.to_string(),
+ request.public_key
+ );
+ let message = Message::from_digest(digest);
+ let signature = Secp256k1::new().sign_schnorr_no_aux_rand(&message, &keypair);
+ Secp256k1::new()
+ .verify_schnorr(&signature, &message, &keypair.x_only_public_key().0)
+ .expect("fixture host signature verifies");
+ signature.to_string()
+ });
+ let completed_at_unix_ms = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_millis()
+ .try_into()
+ .expect("current time fits u64");
+ assert!(completed_at_unix_ms < request.deadline_unix_ms);
+ HostSigningResult {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ outcome,
+ operation_id: request.operation_id,
+ signer_request_id: request.signer_request_id,
+ public_key: request.public_key,
+ purpose: request.purpose,
+ signature_hex,
+ completed_at_unix_ms,
+ }
+ }
+}
+
#[test]
fn swift_module_names_preserve_the_host_contract() {
let config = include_str!("../uniffi.toml");
@@ -25,7 +88,7 @@ async fn protected_data_failure_is_typed_and_opens_no_store() {
ProtectedDataAvailability::Unavailable,
)
.await;
- let Err(RadrootsAppError::Store { report }) = result else {
+ let Err(RadrootsAppError::Failure { report }) = result else {
panic!("protected data failure must remain typed across UniFFI");
};
assert_eq!(report.code, "protected_data_unavailable");
@@ -51,23 +114,109 @@ async fn final_mobile_abi_uses_async_sdk_dtos_and_versioned_errors() {
.sdk_storage_status()
.await
.expect_err("closed client must reject operations");
- let RadrootsAppError::Sdk {
- report:
- SdkErrorRecord {
- schema_version,
- code,
- class,
- retryable,
- message,
- ..
- },
- } = error
- else {
- panic!("expected versioned SDK error record");
- };
- assert_eq!(schema_version, 1);
- assert_eq!(code, "client_closed");
- assert_eq!(class, "runtime");
- assert!(!retryable);
- assert_eq!(message, "SDK client is closed");
+ let RadrootsAppError::Failure { report } = error;
+ assert_eq!(report.schema_version, 1);
+ assert_eq!(report.code, "client_closed");
+ assert_eq!(report.category, "runtime");
+ assert!(!report.retryable);
+ assert_eq!(report.safe_message, "SDK client is closed");
+}
+
+#[tokio::test]
+async fn host_signer_constructor_exposes_only_an_opaque_configured_boundary() {
+ let root = tempfile::tempdir().expect("tempdir");
+ support::prepare(root.path());
+ let outcome = Arc::new(Mutex::new(HostSigningOutcome::Rejected));
+ let runtime = RadrootsRuntime::with_host_signer(
+ root.path().to_string_lossy().into_owned(),
+ support::PUBLIC_KEY.to_owned(),
+ support::GENERATION.to_owned(),
+ 1_800_000_000_000,
+ ProtectedDataAvailability::Available,
+ Box::new(TestHostSigner(Arc::clone(&outcome))),
+ )
+ .await
+ .expect("runtime with host signer");
+
+ let identity = runtime.identity_status().expect("identity status");
+ assert_eq!(identity.public_key, support::PUBLIC_KEY);
+ assert!(identity.host_signer_configured);
+
+ for (index, host_outcome) in [
+ HostSigningOutcome::Signed,
+ HostSigningOutcome::Locked,
+ HostSigningOutcome::Cancelled,
+ HostSigningOutcome::Rejected,
+ HostSigningOutcome::TimedOut,
+ HostSigningOutcome::Unavailable,
+ HostSigningOutcome::Invalidated,
+ HostSigningOutcome::Failed,
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ *outcome
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner) = host_outcome;
+ let draft_id = format!("{:02x}", index + 17).repeat(16);
+ let saved = runtime
+ .phase1_save_draft(
+ draft_id.clone(),
+ FfiAddDraftInput {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ command_type: FfiAddCommandType::CreateUpdate,
+ content: format!("Signer boundary test {index}"),
+ 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(),
+ },
+ 1_800_000_000 + index as u64,
+ None,
+ 1_800_000_000_000 + index as u64,
+ )
+ .await
+ .expect("saved draft");
+ let queued = runtime
+ .phase1_queue_draft(
+ draft_id.clone(),
+ saved.revision,
+ FfiQueuePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ relay_urls: vec!["wss://relay.example".to_owned()],
+ satisfaction: FfiRelaySatisfaction::AnyAccepted,
+ delivery_deadline_unix_ms: u64::MAX,
+ cancellation: FfiCancellationPolicy::PreservePublishedRequest,
+ },
+ 1_800_000_001_000 + index as u64,
+ )
+ .await
+ .expect("queued draft");
+ let signing_result = runtime
+ .phase1_sign_queued_draft(draft_id, queued.revision)
+ .await;
+ if host_outcome == HostSigningOutcome::Signed {
+ assert_eq!(
+ signing_result.expect("valid host signature").state,
+ radroots_mobile_ffi::FfiOutboxState::Signed
+ );
+ } else {
+ let signing_error = signing_result.expect_err("host failure remains typed");
+ assert_eq!(signing_error.report().category, "authoring");
+ assert!(!signing_error.report().safe_message.contains("signature"));
+ }
+ }
+ runtime.shutdown().await.expect("shutdown");
}