field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit c85bc0545b801fd9c425640c29d7d2d483cf51b6
parent fc52c6acaa08193d30c715500cc20aa3840d106e
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:
Mcore/crates/tera_ffi/Cargo.toml | 9+++++++++
Acore/crates/tera_ffi/src/dto.rs | 1997+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/src/error.rs | 340+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcore/crates/tera_ffi/src/lib.rs | 14++++++++++++--
Dcore/crates/tera_ffi/src/remote.rs | 143-------------------------------------------------------------------------------
Mcore/crates/tera_ffi/src/runtime.rs | 505++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Acore/crates/tera_ffi/src/signer.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_ffi/src/subscription.rs | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/logging_error.rs | 6+++++-
Mcore/crates/tera_ffi/tests/runtime_delegation.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcore/crates/tera_ffi/tests/runtime_lifecycle.rs | 2+-
Acore/crates/tera_ffi/tests/subscription_contract.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_ffi/tests/uniffi_contract.rs | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
13 files changed, 3985 insertions(+), 306 deletions(-)

diff --git a/core/crates/tera_ffi/Cargo.toml b/core/crates/tera_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/core/crates/tera_ffi/src/dto.rs b/core/crates/tera_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/core/crates/tera_ffi/src/error.rs b/core/crates/tera_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/core/crates/tera_ffi/src/lib.rs b/core/crates/tera_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/core/crates/tera_ffi/src/remote.rs b/core/crates/tera_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/core/crates/tera_ffi/src/runtime.rs b/core/crates/tera_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/core/crates/tera_ffi/src/signer.rs b/core/crates/tera_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/core/crates/tera_ffi/src/subscription.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/logging_error.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/runtime_delegation.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/runtime_lifecycle.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/subscription_contract.rs b/core/crates/tera_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/core/crates/tera_ffi/tests/uniffi_contract.rs b/core/crates/tera_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"); }