dto.rs (107414B)
1 //! Focused, versioned value types owned by the native boundary. 2 3 use crate::media_file::MEDIA_FILE_MAX_BYTES; 4 use crate::{FfiDraftStatusRecord, FfiMediaFile}; 5 6 use radroots_blossom::{BlobDescriptor, MediaType, Sha256}; 7 use radroots_event::{ 8 calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, 9 food::availability::{ 10 FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityImage, 11 FoodAvailabilityStatus, FoodContent, FoodCurrency, FoodIdentifier, FoodImageDimensions, 12 FoodPrice, FoodPublishedAt, FoodQuantity, FoodText, FoodUnit, 13 }, 14 media::AuthoredImage, 15 post::{AuthoredPostImage, PostImageDimensions}, 16 }; 17 use tera_core::runtime::{ 18 app_info::AppInfoPlatform, 19 info::{AppInfo, RuntimeBuildInfo, RuntimeInfo}, 20 product_surface::{ 21 AddCommandType, CardLifecycleState, CreateAsk, CreateEvent, CreateFoodAvailability, 22 CreatePhotoUpdate, CreateUpdate, LocalNetwork, LocalNetworkRelayPolicy, MeSnapshot, 23 MediaReference, Phase1AddCommand, Phase1CancellationPolicy, Phase1DraftEventTiming, 24 Phase1DraftFormSnapshot, Phase1DraftKind, Phase1DraftMediaSnapshot, 25 Phase1InboundMediaState, Phase1MediaPrerequisite, Phase1MediaStage, Phase1OutboxState, 26 Phase1QueuePolicy, Phase1RelaySatisfaction, Phase1UploadIntent, ProfileSummary, 27 SearchResult, SearchResultType, SupportingProfile, ThreadEntry, TodayCard, TodayCardType, 28 TodayPage, TodayProjectionUpdate, TodayRefreshReceipt, 29 }, 30 sdk::{ 31 SdkBlossomConfigurationRecord, SdkBlossomEvidenceRecord, SdkCapabilityRecord, 32 SdkRelayAccessRecord, SdkRelayStatusRecord, SdkRelayStatusReportRecord, SdkShutdownRecord, 33 SdkStorageStatusRecord, 34 }, 35 }; 36 37 use crate::TeraAppError; 38 39 mod viewer_calendar; 40 pub use viewer_calendar::{FfiViewerCalendarContext, TODAY_PAGE_FFI_SCHEMA_VERSION}; 41 mod calendar; 42 pub use calendar::{FfiCalendarTiming, FfiCivilDate, TODAY_CARD_FFI_SCHEMA_VERSION}; 43 44 mod today_sync; 45 pub use today_sync::{ 46 FfiTodayDiscoveryRecord, FfiTodayRelaySyncState, FfiTodaySyncRecord, FfiTodaySyncTermination, 47 FfiTodayTargetPageSummary, FfiTodayTargetSyncRecord, FfiTodayTargetSyncState, 48 }; 49 50 pub const MOBILE_FFI_SCHEMA_VERSION: u16 = 1; 51 pub const PREPARED_MEDIA_FFI_SCHEMA_VERSION: u16 = 2; 52 /// Upload outputs separate canonical blob references from transport destinations. 53 pub const UPLOAD_OUTPUT_FFI_SCHEMA_VERSION: u16 = 2; 54 const MEDIA_REFERENCE_MAX_BYTES: usize = 256; 55 56 /// Final four-state trade-evidence coverage vocabulary. 57 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 58 pub enum FfiTradeEvidenceCoverage { 59 Missing, 60 Partial, 61 ScopeSatisfied, 62 Unsupported, 63 } 64 65 /// Final three-state trade-evidence outcome vocabulary. 66 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 67 pub enum FfiTradeEvidenceOutcome { 68 Valid, 69 Invalid, 70 Indeterminate, 71 } 72 73 /// Secret-free projection of one canonical evidence manifest. 74 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 75 pub struct FfiTradeEvidenceManifestRecord { 76 pub schema_version: u16, 77 pub contract_id: String, 78 pub contract_version: u16, 79 pub trade_id: String, 80 pub trade_generation: String, 81 pub observed_at_unix_s: String, 82 pub coverage: FfiTradeEvidenceCoverage, 83 pub evidence_policy_digest: String, 84 pub manifest_digest: String, 85 pub canonical_bytes_hex: String, 86 } 87 88 /// Secret-free projection of one canonical RHI evidence report. 89 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 90 pub struct FfiRhiEvidenceReportRecord { 91 pub schema_version: u16, 92 pub contract_id: String, 93 pub contract_version: u16, 94 pub issuer_pubkey: String, 95 pub trade_id: String, 96 pub claim_mutation_id: String, 97 pub outcome: FfiTradeEvidenceOutcome, 98 pub reason_codes: Vec<String>, 99 pub projection_digest: String, 100 pub evidence_manifest_digest: String, 101 pub evidence_policy_digest: String, 102 pub observed_at_unix_s: String, 103 pub trade_generation: String, 104 pub statement_digest: String, 105 pub supersedes_report_id: Option<String>, 106 pub supersedes_event_id: Option<String>, 107 pub canonical_content: String, 108 } 109 110 /// Unsigned typed event plan ready for host-owned signing. 111 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 112 pub struct FfiTypedEvidenceEventPlanRecord { 113 pub schema_version: u16, 114 pub contract_id: String, 115 pub kind: u32, 116 pub author_pubkey: String, 117 pub created_at_unix_s: String, 118 pub expected_event_id: String, 119 pub tags: Vec<Vec<String>>, 120 pub content: String, 121 } 122 123 /// Signed NIP-01 event input for verified attestation admission. 124 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 125 pub struct FfiSignedEvidenceEventRecord { 126 pub id: String, 127 pub author_pubkey: String, 128 pub created_at_unix_s: u64, 129 pub kind: u32, 130 pub tags: Vec<Vec<String>>, 131 pub content: String, 132 pub signature: String, 133 } 134 135 /// Secret-free supersession projection from one verified attestation. 136 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 137 pub struct FfiRhiEvidenceAttestationSupersessionRecord { 138 pub report_id: String, 139 pub event_id: String, 140 } 141 142 /// Verified final RHI evidence attestation. 143 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 144 pub struct FfiRhiEvidenceAttestationRecord { 145 pub schema_version: u16, 146 pub issuer_pubkey: String, 147 pub trade_id: String, 148 pub claim_mutation_id: String, 149 pub outcome: FfiTradeEvidenceOutcome, 150 pub observed_at_unix_s: String, 151 pub trade_generation: String, 152 pub statement_digest: String, 153 pub supersession: Option<FfiRhiEvidenceAttestationSupersessionRecord>, 154 pub canonical_content: String, 155 } 156 157 #[uniffi::export] 158 pub fn parse_trade_evidence_manifest( 159 canonical_bytes: Vec<u8>, 160 ) -> Result<FfiTradeEvidenceManifestRecord, TeraAppError> { 161 let manifest = radroots_sdk::trade::parse_evidence_manifest(&canonical_bytes) 162 .map_err(|_| TeraAppError::invalid_argument("invalid_evidence_manifest"))?; 163 Ok(FfiTradeEvidenceManifestRecord { 164 schema_version: MOBILE_FFI_SCHEMA_VERSION, 165 contract_id: manifest.contract_id().to_owned(), 166 contract_version: manifest.contract_version(), 167 trade_id: manifest.trade_id().to_string(), 168 trade_generation: manifest.trade_generation().get().to_string(), 169 observed_at_unix_s: manifest.observed_at_unix_s().to_string(), 170 coverage: manifest.coverage().into(), 171 evidence_policy_digest: manifest.evidence_policy_digest().to_hex(), 172 manifest_digest: manifest.digest().to_hex(), 173 canonical_bytes_hex: hex::encode(manifest.canonical_bytes()), 174 }) 175 } 176 177 #[uniffi::export] 178 pub fn parse_rhi_evidence_report( 179 canonical_content: String, 180 ) -> Result<FfiRhiEvidenceReportRecord, TeraAppError> { 181 let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) 182 .map_err(|_| TeraAppError::invalid_argument("invalid_evidence_report"))?; 183 let supersession = report.supersession(); 184 Ok(FfiRhiEvidenceReportRecord { 185 schema_version: MOBILE_FFI_SCHEMA_VERSION, 186 contract_id: report.contract_id().to_owned(), 187 contract_version: report.contract_version(), 188 issuer_pubkey: report.issuer_public_key().to_hex(), 189 trade_id: report.trade_id().to_string(), 190 claim_mutation_id: report.claim_mutation_id().to_string(), 191 outcome: report.outcome().into(), 192 reason_codes: report 193 .reason_codes() 194 .iter() 195 .map(|code| code.as_str().to_owned()) 196 .collect(), 197 projection_digest: report.projection_digest().to_hex(), 198 evidence_manifest_digest: report.evidence_manifest_digest().to_hex(), 199 evidence_policy_digest: report.evidence_policy_digest().to_hex(), 200 observed_at_unix_s: report.observed_at_unix_s().to_string(), 201 trade_generation: report.trade_generation().get().to_string(), 202 statement_digest: report.statement_digest().to_hex(), 203 supersedes_report_id: supersession.map(|value| value.report_id().to_hex()), 204 supersedes_event_id: supersession.map(|value| value.event_id().to_hex()), 205 canonical_content: report.canonical_content().to_owned(), 206 }) 207 } 208 209 #[uniffi::export] 210 pub fn prepare_rhi_evidence_attestation( 211 canonical_content: String, 212 created_at_unix_s: u64, 213 ) -> Result<FfiTypedEvidenceEventPlanRecord, TeraAppError> { 214 let report = radroots_sdk::trade::parse_rhi_evidence_report(canonical_content.as_bytes()) 215 .map_err(|_| TeraAppError::invalid_argument("invalid_evidence_report"))?; 216 let plan = radroots_sdk::trade::prepare_rhi_evidence_attestation(&report, created_at_unix_s) 217 .map_err(|_| TeraAppError::invalid_argument("invalid_evidence_attestation_plan"))?; 218 Ok(FfiTypedEvidenceEventPlanRecord { 219 schema_version: MOBILE_FFI_SCHEMA_VERSION, 220 contract_id: plan.body().contract().contract_id().as_str().to_owned(), 221 kind: plan.body().kind(), 222 author_pubkey: plan.author().to_hex(), 223 created_at_unix_s: plan.created_at().to_string(), 224 expected_event_id: plan.expected_event_id().to_hex(), 225 tags: plan.body().tags().to_vec(), 226 content: plan.body().content().to_owned(), 227 }) 228 } 229 230 #[uniffi::export] 231 pub fn validate_rhi_evidence_attestation( 232 event: FfiSignedEvidenceEventRecord, 233 ) -> Result<FfiRhiEvidenceAttestationRecord, TeraAppError> { 234 let event = radroots_event::envelope::EventEnvelope::new( 235 radroots_event::envelope::EventEnvelopeParts { 236 id: event.id, 237 author: event.author_pubkey, 238 created_at: event.created_at_unix_s, 239 kind: event.kind, 240 tags: event.tags, 241 content: event.content, 242 sig: event.signature, 243 }, 244 ) 245 .map_err(|_| TeraAppError::invalid_argument("invalid_signed_event"))?; 246 let attestation = radroots_sdk::trade::validate_rhi_evidence_attestation(event).map_err( 247 |error| match error { 248 radroots_sdk::trade::EvidenceAttestationValidationError::Signature => { 249 TeraAppError::invalid_argument("invalid_event_signature") 250 } 251 radroots_sdk::trade::EvidenceAttestationValidationError::Contract => { 252 TeraAppError::invalid_argument("invalid_evidence_attestation") 253 } 254 }, 255 )?; 256 Ok(FfiRhiEvidenceAttestationRecord { 257 schema_version: MOBILE_FFI_SCHEMA_VERSION, 258 issuer_pubkey: attestation.issuer().to_hex(), 259 trade_id: attestation.trade_id().to_string(), 260 claim_mutation_id: attestation.claim_mutation_id().to_string(), 261 outcome: match attestation.outcome() { 262 radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Valid => { 263 FfiTradeEvidenceOutcome::Valid 264 } 265 radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Invalid => { 266 FfiTradeEvidenceOutcome::Invalid 267 } 268 radroots_sdk::trade::RadrootsRhiEvidenceAttestationOutcomeV1::Indeterminate => { 269 FfiTradeEvidenceOutcome::Indeterminate 270 } 271 }, 272 observed_at_unix_s: attestation.observed_at_unix_s().to_string(), 273 trade_generation: attestation.trade_generation().get().to_string(), 274 statement_digest: hex::encode(attestation.statement_digest()), 275 supersession: attestation.supersession().map(|value| { 276 FfiRhiEvidenceAttestationSupersessionRecord { 277 report_id: hex::encode(value.report_id()), 278 event_id: value.event_id().to_hex(), 279 } 280 }), 281 canonical_content: attestation.canonical_content().to_owned(), 282 }) 283 } 284 285 impl From<radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1> for FfiTradeEvidenceCoverage { 286 fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1) -> Self { 287 match value { 288 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing => Self::Missing, 289 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial => Self::Partial, 290 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied => { 291 Self::ScopeSatisfied 292 } 293 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported => Self::Unsupported, 294 } 295 } 296 } 297 298 impl From<radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1> for FfiTradeEvidenceOutcome { 299 fn from(value: radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1) -> Self { 300 match value { 301 radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid => Self::Valid, 302 radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid => Self::Invalid, 303 radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate => { 304 Self::Indeterminate 305 } 306 } 307 } 308 } 309 310 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 311 pub struct FfiBuildInfoRecord { 312 pub schema_version: u16, 313 pub crate_name: String, 314 pub crate_version: String, 315 pub rustc: Option<String>, 316 pub profile: Option<String>, 317 pub lib_revision: Option<String>, 318 pub consumer_revision: Option<String>, 319 pub build_time_unix: Option<u64>, 320 } 321 322 // These exhaustive field-for-field adapters are verified by the generated API 323 // snapshot and Swift compilation. Excluding the mechanical projection glue 324 // keeps the coverage gate focused on validation and behavior. 325 #[cfg_attr(coverage_nightly, coverage(off))] 326 impl From<RuntimeBuildInfo> for FfiBuildInfoRecord { 327 fn from(value: RuntimeBuildInfo) -> Self { 328 Self { 329 schema_version: MOBILE_FFI_SCHEMA_VERSION, 330 crate_name: value.crate_name, 331 crate_version: value.crate_version, 332 rustc: value.rustc, 333 profile: value.profile, 334 lib_revision: value.lib_revision, 335 consumer_revision: value.consumer_revision, 336 build_time_unix: value.build_time_unix, 337 } 338 } 339 } 340 341 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 342 pub struct FfiAppPlatformRecord { 343 pub schema_version: u16, 344 pub platform: Option<String>, 345 pub bundle_id: Option<String>, 346 pub version: Option<String>, 347 pub build_number: Option<String>, 348 pub build_sha: Option<String>, 349 } 350 351 #[cfg_attr(coverage_nightly, coverage(off))] 352 impl From<AppInfoPlatform> for FfiAppPlatformRecord { 353 fn from(value: AppInfoPlatform) -> Self { 354 Self { 355 schema_version: MOBILE_FFI_SCHEMA_VERSION, 356 platform: value.platform, 357 bundle_id: value.bundle_id, 358 version: value.version, 359 build_number: value.build_number, 360 build_sha: value.build_sha, 361 } 362 } 363 } 364 365 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 366 pub struct FfiAppInfoRecord { 367 pub schema_version: u16, 368 pub build: FfiBuildInfoRecord, 369 pub started_unix_ms: i64, 370 pub uptime_millis: i64, 371 pub shutting_down: bool, 372 pub platform: Option<FfiAppPlatformRecord>, 373 } 374 375 #[cfg_attr(coverage_nightly, coverage(off))] 376 impl From<AppInfo> for FfiAppInfoRecord { 377 fn from(value: AppInfo) -> Self { 378 Self { 379 schema_version: MOBILE_FFI_SCHEMA_VERSION, 380 build: value.build.into(), 381 started_unix_ms: value.started_unix_ms, 382 uptime_millis: value.uptime_millis, 383 shutting_down: value.shutting_down, 384 platform: value.platform.map(Into::into), 385 } 386 } 387 } 388 389 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 390 pub struct FfiRuntimeInfoRecord { 391 pub schema_version: u16, 392 pub app: FfiAppInfoRecord, 393 pub sdk: FfiBuildInfoRecord, 394 pub sdk_closed: bool, 395 } 396 397 #[cfg_attr(coverage_nightly, coverage(off))] 398 impl From<RuntimeInfo> for FfiRuntimeInfoRecord { 399 fn from(value: RuntimeInfo) -> Self { 400 Self { 401 schema_version: MOBILE_FFI_SCHEMA_VERSION, 402 app: value.app.into(), 403 sdk: value.sdk.into(), 404 sdk_closed: value.sdk_closed, 405 } 406 } 407 } 408 409 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 410 pub struct FfiIdentityStatusRecord { 411 pub schema_version: u16, 412 pub public_key: String, 413 pub host_signer_configured: bool, 414 } 415 416 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 417 pub enum FfiTodayCardType { 418 Update, 419 PhotoUpdate, 420 Ask, 421 Event, 422 FoodAvailability, 423 } 424 425 #[cfg_attr(coverage_nightly, coverage(off))] 426 impl From<TodayCardType> for FfiTodayCardType { 427 fn from(value: TodayCardType) -> Self { 428 match value { 429 TodayCardType::Update => Self::Update, 430 TodayCardType::PhotoUpdate => Self::PhotoUpdate, 431 TodayCardType::Ask => Self::Ask, 432 TodayCardType::Event => Self::Event, 433 TodayCardType::FoodAvailability => Self::FoodAvailability, 434 } 435 } 436 } 437 438 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 439 pub enum FfiAddCommandType { 440 CreateUpdate, 441 CreatePhotoUpdate, 442 CreateAsk, 443 CreateEvent, 444 CreateFoodAvailability, 445 } 446 447 #[cfg_attr(coverage_nightly, coverage(off))] 448 impl From<AddCommandType> for FfiAddCommandType { 449 fn from(value: AddCommandType) -> Self { 450 match value { 451 AddCommandType::CreateUpdate => Self::CreateUpdate, 452 AddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate, 453 AddCommandType::CreateAsk => Self::CreateAsk, 454 AddCommandType::CreateEvent => Self::CreateEvent, 455 AddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability, 456 } 457 } 458 } 459 460 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 461 pub struct FfiCardAddParityRecord { 462 pub schema_version: u16, 463 pub card_type: FfiTodayCardType, 464 pub command_type: FfiAddCommandType, 465 } 466 467 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 468 pub struct FfiLocalNetworkRecord { 469 pub schema_version: u16, 470 pub id: String, 471 pub label: String, 472 pub relay_urls: Vec<String>, 473 pub locality: Option<String>, 474 pub followed_authors: Vec<String>, 475 pub generation: u64, 476 } 477 478 impl TryFrom<FfiLocalNetworkRecord> for LocalNetwork { 479 type Error = TeraAppError; 480 481 fn try_from(value: FfiLocalNetworkRecord) -> Result<Self, Self::Error> { 482 value.try_into_with_relay_policy(LocalNetworkRelayPolicy::Public) 483 } 484 } 485 486 impl FfiLocalNetworkRecord { 487 pub(crate) fn try_into_with_relay_policy( 488 self, 489 relay_policy: LocalNetworkRelayPolicy, 490 ) -> Result<LocalNetwork, TeraAppError> { 491 require_schema(self.schema_version)?; 492 LocalNetwork::new_for_relay_policy( 493 self.id, 494 self.label, 495 self.relay_urls, 496 self.locality, 497 self.followed_authors, 498 self.generation, 499 relay_policy, 500 ) 501 .map_err(|_| TeraAppError::invalid_argument("invalid_local_network")) 502 } 503 } 504 505 #[cfg_attr(coverage_nightly, coverage(off))] 506 impl From<LocalNetwork> for FfiLocalNetworkRecord { 507 fn from(value: LocalNetwork) -> Self { 508 Self { 509 schema_version: MOBILE_FFI_SCHEMA_VERSION, 510 id: value.id.into(), 511 label: value.label, 512 relay_urls: value.relay_urls, 513 locality: value.locality, 514 followed_authors: value.followed_authors, 515 generation: value.generation, 516 } 517 } 518 } 519 520 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 521 pub enum FfiMediaVerificationState { 522 Pending, 523 Verified, 524 Failed, 525 Unavailable, 526 } 527 528 #[cfg_attr(coverage_nightly, coverage(off))] 529 impl From<&Phase1InboundMediaState> for FfiMediaVerificationState { 530 fn from(value: &Phase1InboundMediaState) -> Self { 531 match value { 532 Phase1InboundMediaState::Pending(_) => Self::Pending, 533 Phase1InboundMediaState::Verified(_) => Self::Verified, 534 Phase1InboundMediaState::Failed(_) => Self::Failed, 535 Phase1InboundMediaState::Unavailable => Self::Unavailable, 536 } 537 } 538 } 539 540 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 541 pub struct FfiMediaReferenceRecord { 542 pub schema_version: u16, 543 pub reference_fingerprint: String, 544 pub url: String, 545 pub sha256: Option<String>, 546 pub media_type: Option<String>, 547 pub width: Option<u32>, 548 pub height: Option<u32>, 549 pub byte_size: Option<u64>, 550 pub alt: Option<String>, 551 pub verification: FfiMediaVerificationState, 552 } 553 554 #[cfg_attr(coverage_nightly, coverage(off))] 555 impl From<MediaReference> for FfiMediaReferenceRecord { 556 fn from(value: MediaReference) -> Self { 557 let structural = value.structural(); 558 Self { 559 schema_version: MOBILE_FFI_SCHEMA_VERSION, 560 reference_fingerprint: hex::encode(structural.fingerprint()), 561 url: structural.source_url().to_owned(), 562 sha256: structural.expected_sha256().map(str::to_owned), 563 media_type: structural.expected_media_type().map(str::to_owned), 564 width: structural.expected_width(), 565 height: structural.expected_height(), 566 byte_size: structural.expected_byte_size(), 567 alt: structural.alt().map(str::to_owned), 568 verification: value.retrieval().into(), 569 } 570 } 571 } 572 573 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 574 pub struct FfiProfileRecord { 575 pub schema_version: u16, 576 pub author_public_key: String, 577 pub name: Option<String>, 578 pub display_name: Option<String>, 579 pub about: Option<String>, 580 pub picture: Option<FfiMediaReferenceRecord>, 581 pub banner: Option<FfiMediaReferenceRecord>, 582 pub nip05: Option<String>, 583 pub website: Option<String>, 584 pub lightning_address: Option<String>, 585 } 586 587 #[cfg_attr(coverage_nightly, coverage(off))] 588 impl From<ProfileSummary> for FfiProfileRecord { 589 fn from(value: ProfileSummary) -> Self { 590 Self { 591 schema_version: MOBILE_FFI_SCHEMA_VERSION, 592 author_public_key: value.author_pubkey, 593 name: value.name, 594 display_name: value.display_name, 595 about: value.about, 596 picture: value.picture.map(Into::into), 597 banner: value.banner.map(Into::into), 598 nip05: value.nip05, 599 website: value.website, 600 lightning_address: value.lightning_address, 601 } 602 } 603 } 604 605 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 606 pub enum FfiThreadProfile { 607 Profile, 608 Reply, 609 Comment, 610 Deletion, 611 } 612 613 #[cfg_attr(coverage_nightly, coverage(off))] 614 impl From<SupportingProfile> for FfiThreadProfile { 615 fn from(value: SupportingProfile) -> Self { 616 match value { 617 SupportingProfile::Profile => Self::Profile, 618 SupportingProfile::Reply => Self::Reply, 619 SupportingProfile::Comment => Self::Comment, 620 SupportingProfile::Deletion => Self::Deletion, 621 } 622 } 623 } 624 625 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 626 pub struct FfiThreadEntryRecord { 627 pub schema_version: u16, 628 pub event_id: String, 629 pub author_public_key: String, 630 pub content: String, 631 pub authored_at_unix_s: u64, 632 pub profile: FfiThreadProfile, 633 pub root: String, 634 pub parent_event_id: String, 635 pub author_profile: Option<FfiProfileRecord>, 636 } 637 638 #[cfg_attr(coverage_nightly, coverage(off))] 639 impl From<ThreadEntry> for FfiThreadEntryRecord { 640 fn from(value: ThreadEntry) -> Self { 641 Self { 642 schema_version: MOBILE_FFI_SCHEMA_VERSION, 643 event_id: value.event_id, 644 author_public_key: value.author_pubkey, 645 content: value.content, 646 authored_at_unix_s: value.authored_at, 647 profile: value.reference.profile.into(), 648 root: value.reference.root, 649 parent_event_id: value.reference.parent_event_id, 650 author_profile: value.author_profile.map(Into::into), 651 } 652 } 653 } 654 655 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 656 pub enum FfiCardLifecycleState { 657 Active, 658 Sold, 659 Past, 660 } 661 662 #[cfg_attr(coverage_nightly, coverage(off))] 663 impl From<CardLifecycleState> for FfiCardLifecycleState { 664 fn from(value: CardLifecycleState) -> Self { 665 match value { 666 CardLifecycleState::Active => Self::Active, 667 CardLifecycleState::Sold => Self::Sold, 668 CardLifecycleState::Past => Self::Past, 669 } 670 } 671 } 672 673 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 674 pub struct FfiTodayCardRecord { 675 pub schema_version: u16, 676 pub card_id: String, 677 pub card_type: FfiTodayCardType, 678 pub source_event_id: String, 679 pub source_address: Option<String>, 680 pub author_public_key: String, 681 pub contract_id: String, 682 pub title: Option<String>, 683 pub content: String, 684 pub authored_at_unix_s: u64, 685 pub effective_at_unix_s: u64, 686 pub calendar_timing: Option<FfiCalendarTiming>, 687 pub location: Option<String>, 688 pub price_amount: Option<String>, 689 pub price_currency: Option<String>, 690 pub price_unit: Option<String>, 691 pub quantity: Option<String>, 692 pub food_summary: Option<String>, 693 pub food_published_at_unix_s: Option<u64>, 694 pub food_status: Option<String>, 695 pub context_rank: u8, 696 pub inclusion_reason: String, 697 pub media: Vec<FfiMediaReferenceRecord>, 698 pub lifecycle: FfiCardLifecycleState, 699 pub rank_digest: Option<String>, 700 pub author_profile: Option<FfiProfileRecord>, 701 pub thread: Vec<FfiThreadEntryRecord>, 702 pub local_source_draft_id: Option<String>, 703 pub local_operation_id: Option<String>, 704 pub local_operation_state: Option<String>, 705 } 706 707 #[cfg_attr(coverage_nightly, coverage(off))] 708 impl From<TodayCard> for FfiTodayCardRecord { 709 fn from(value: TodayCard) -> Self { 710 let card = value.card; 711 Self { 712 schema_version: TODAY_CARD_FFI_SCHEMA_VERSION, 713 card_id: card.card_id.to_hex(), 714 card_type: card.card_type.into(), 715 source_event_id: card.source_event_id, 716 source_address: card.source_address, 717 author_public_key: card.author_pubkey, 718 contract_id: card.contract_id, 719 title: card.title, 720 content: card.content, 721 authored_at_unix_s: card.authored_at, 722 effective_at_unix_s: card.effective_at, 723 calendar_timing: card.calendar_timing.map(Into::into), 724 location: card.location, 725 price_amount: card.price_amount, 726 price_currency: card.price_currency, 727 price_unit: card.price_unit, 728 quantity: card.quantity, 729 food_summary: card.food_summary, 730 food_published_at_unix_s: card.food_published_at, 731 food_status: card.food_status, 732 context_rank: card.context_rank.value(), 733 inclusion_reason: card.inclusion_reason, 734 media: card.media.into_iter().map(Into::into).collect(), 735 lifecycle: card.lifecycle.into(), 736 rank_digest: card.rank.map(|rank| rank.digest_hex()), 737 author_profile: value.author_profile.map(Into::into), 738 thread: value.thread.into_iter().map(Into::into).collect(), 739 local_source_draft_id: value 740 .local_overlay 741 .as_ref() 742 .and_then(|overlay| overlay.source_draft_id.clone()), 743 local_operation_id: value 744 .local_overlay 745 .as_ref() 746 .map(|overlay| overlay.operation_id.clone()), 747 local_operation_state: value.local_overlay.map(|overlay| overlay.state), 748 } 749 } 750 } 751 752 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 753 pub struct FfiTodayPageRecord { 754 pub calendar: FfiViewerCalendarContext, 755 pub projection_generation: u64, 756 pub schema_version: u16, 757 pub as_of_unix_s: u64, 758 pub items: Vec<FfiTodayCardRecord>, 759 pub next_cursor: Option<String>, 760 } 761 762 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 763 pub enum FfiTodayProjectionUpdate { 764 Incremental, 765 Rebuild, 766 } 767 768 #[cfg_attr(coverage_nightly, coverage(off))] 769 impl From<FfiTodayProjectionUpdate> for TodayProjectionUpdate { 770 fn from(value: FfiTodayProjectionUpdate) -> Self { 771 match value { 772 FfiTodayProjectionUpdate::Incremental => Self::Incremental, 773 FfiTodayProjectionUpdate::Rebuild => Self::Rebuild, 774 } 775 } 776 } 777 778 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 779 pub struct FfiTodayRefreshRecord { 780 pub schema_version: u16, 781 pub update: FfiTodayProjectionUpdate, 782 pub source_events: u64, 783 pub visible_cards: u64, 784 pub profiles: u64, 785 pub thread_entries: u64, 786 pub content_generation: u64, 787 pub changed: bool, 788 } 789 790 #[cfg_attr(coverage_nightly, coverage(off))] 791 impl From<TodayRefreshReceipt> for FfiTodayRefreshRecord { 792 fn from(value: TodayRefreshReceipt) -> Self { 793 Self { 794 schema_version: MOBILE_FFI_SCHEMA_VERSION, 795 update: match value.update { 796 TodayProjectionUpdate::Incremental => FfiTodayProjectionUpdate::Incremental, 797 TodayProjectionUpdate::Rebuild => FfiTodayProjectionUpdate::Rebuild, 798 }, 799 source_events: value.source_events, 800 visible_cards: value.visible_cards, 801 profiles: value.profiles, 802 thread_entries: value.thread_entries, 803 content_generation: value.content_generation, 804 changed: value.changed, 805 } 806 } 807 } 808 809 #[cfg_attr(coverage_nightly, coverage(off))] 810 impl From<TodayPage> for FfiTodayPageRecord { 811 fn from(value: TodayPage) -> Self { 812 Self { 813 calendar: value.calendar.into(), 814 schema_version: TODAY_PAGE_FFI_SCHEMA_VERSION, 815 projection_generation: value.projection_generation, 816 as_of_unix_s: value.as_of, 817 items: value.items.into_iter().map(Into::into).collect(), 818 next_cursor: value.next_cursor, 819 } 820 } 821 } 822 823 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 824 pub enum FfiSearchResultType { 825 Card, 826 Profile, 827 } 828 829 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 830 pub struct FfiSearchResultRecord { 831 pub schema_version: u16, 832 pub result_type: FfiSearchResultType, 833 pub stable_id: String, 834 pub card: Option<FfiTodayCardRecord>, 835 pub profile: Option<FfiProfileRecord>, 836 } 837 838 #[cfg_attr(coverage_nightly, coverage(off))] 839 impl From<SearchResult> for FfiSearchResultRecord { 840 fn from(value: SearchResult) -> Self { 841 Self { 842 schema_version: MOBILE_FFI_SCHEMA_VERSION, 843 result_type: match value.result_type { 844 SearchResultType::Card => FfiSearchResultType::Card, 845 SearchResultType::Profile => FfiSearchResultType::Profile, 846 }, 847 stable_id: value.stable_id, 848 card: value.card.map(Into::into), 849 profile: value.profile.map(Into::into), 850 } 851 } 852 } 853 854 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 855 pub struct FfiMeRecord { 856 pub schema_version: u16, 857 pub public_key: String, 858 pub profile: Option<FfiProfileRecord>, 859 pub cards: Vec<FfiTodayCardRecord>, 860 } 861 862 #[cfg_attr(coverage_nightly, coverage(off))] 863 impl From<MeSnapshot> for FfiMeRecord { 864 fn from(value: MeSnapshot) -> Self { 865 Self { 866 schema_version: MOBILE_FFI_SCHEMA_VERSION, 867 public_key: value.public_key, 868 profile: value.profile.map(Into::into), 869 cards: value.cards.into_iter().map(Into::into).collect(), 870 } 871 } 872 } 873 874 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 875 pub enum FfiAddFieldKind { 876 Text, 877 MultilineText, 878 Date, 879 DateTime, 880 Decimal, 881 Choice, 882 Location, 883 Media, 884 } 885 886 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 887 pub struct FfiAddFieldRecord { 888 pub schema_version: u16, 889 pub id: String, 890 pub label: String, 891 pub kind: FfiAddFieldKind, 892 pub required: bool, 893 pub choices: Vec<String>, 894 pub max_bytes: Option<u64>, 895 pub max_items: Option<u16>, 896 } 897 898 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 899 pub struct FfiAddSchemaRecord { 900 pub schema_version: u16, 901 pub command_type: FfiAddCommandType, 902 pub label: String, 903 pub fields: Vec<FfiAddFieldRecord>, 904 } 905 906 pub fn add_schemas() -> Vec<FfiAddSchemaRecord> { 907 use FfiAddCommandType as Command; 908 use FfiAddFieldKind as Kind; 909 910 let field = 911 |id: &str, label: &str, kind, required, choices: &[&str], max_bytes| FfiAddFieldRecord { 912 schema_version: MOBILE_FFI_SCHEMA_VERSION, 913 id: id.to_owned(), 914 label: label.to_owned(), 915 kind, 916 required, 917 choices: choices.iter().map(|value| (*value).to_owned()).collect(), 918 max_bytes, 919 max_items: None, 920 }; 921 let media_field = |label: &str, required: bool, max_items| FfiAddFieldRecord { 922 max_items: Some(max_items), 923 ..field( 924 "media", 925 label, 926 Kind::Media, 927 required, 928 &[], 929 Some(MEDIA_FILE_MAX_BYTES), 930 ) 931 }; 932 vec![ 933 FfiAddSchemaRecord { 934 schema_version: MOBILE_FFI_SCHEMA_VERSION, 935 command_type: Command::CreateUpdate, 936 label: "Update".to_owned(), 937 fields: vec![field( 938 "content", 939 "Update", 940 Kind::MultilineText, 941 true, 942 &[], 943 Some(65_535), 944 )], 945 }, 946 FfiAddSchemaRecord { 947 schema_version: MOBILE_FFI_SCHEMA_VERSION, 948 command_type: Command::CreatePhotoUpdate, 949 label: "Photo update".to_owned(), 950 fields: vec![ 951 field( 952 "content", 953 "Update", 954 Kind::MultilineText, 955 true, 956 &[], 957 Some(65_535), 958 ), 959 media_field("Photos", true, 20), 960 ], 961 }, 962 FfiAddSchemaRecord { 963 schema_version: MOBILE_FFI_SCHEMA_VERSION, 964 command_type: Command::CreateAsk, 965 label: "Ask".to_owned(), 966 fields: vec![ 967 field( 968 "content", 969 "Question", 970 Kind::MultilineText, 971 true, 972 &[], 973 Some(65_535), 974 ), 975 media_field("Photos", false, 20), 976 ], 977 }, 978 FfiAddSchemaRecord { 979 schema_version: MOBILE_FFI_SCHEMA_VERSION, 980 command_type: Command::CreateEvent, 981 label: "Event".to_owned(), 982 fields: vec![ 983 field("identifier", "Identifier", Kind::Text, true, &[], Some(256)), 984 field("title", "Title", Kind::Text, true, &[], Some(256)), 985 field( 986 "content", 987 "Description", 988 Kind::MultilineText, 989 false, 990 &[], 991 Some(65_535), 992 ), 993 field("event_start", "Starts", Kind::DateTime, true, &[], None), 994 field("event_end", "Ends", Kind::DateTime, false, &[], None), 995 field( 996 "location", 997 "Location", 998 Kind::Location, 999 false, 1000 &[], 1001 Some(256), 1002 ), 1003 media_field("Photo", false, 1), 1004 ], 1005 }, 1006 FfiAddSchemaRecord { 1007 schema_version: MOBILE_FFI_SCHEMA_VERSION, 1008 command_type: Command::CreateFoodAvailability, 1009 label: "Food availability".to_owned(), 1010 fields: vec![ 1011 field("identifier", "Identifier", Kind::Text, true, &[], Some(256)), 1012 field("title", "Food", Kind::Text, true, &[], Some(256)), 1013 field("summary", "Summary", Kind::Text, true, &[], Some(256)), 1014 field( 1015 "content", 1016 "Details", 1017 Kind::MultilineText, 1018 true, 1019 &[], 1020 Some(65_535), 1021 ), 1022 field( 1023 "location", 1024 "Pickup location", 1025 Kind::Location, 1026 true, 1027 &[], 1028 Some(256), 1029 ), 1030 field("price_amount", "Price", Kind::Decimal, true, &[], Some(64)), 1031 field("currency", "Currency", Kind::Choice, true, &[], Some(3)), 1032 field( 1033 "unit", 1034 "Unit", 1035 Kind::Choice, 1036 true, 1037 &[ 1038 "g", "kg", "lb", "oz", "each", "dozen", "bunch", "punnet", "bag", "basket", 1039 ], 1040 None, 1041 ), 1042 field( 1043 "quantity", 1044 "Available quantity", 1045 Kind::Decimal, 1046 false, 1047 &[], 1048 Some(64), 1049 ), 1050 media_field("Photos", false, 20), 1051 ], 1052 }, 1053 ] 1054 } 1055 1056 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 1057 pub enum FfiEventTimingKind { 1058 AllDay, 1059 Timed, 1060 } 1061 1062 #[derive(Clone, Debug, uniffi::Record)] 1063 pub struct FfiPreparedMediaInput { 1064 pub schema_version: u16, 1065 pub opaque_reference: String, 1066 pub file: std::sync::Arc<FfiMediaFile>, 1067 pub sha256: String, 1068 pub media_type: String, 1069 pub byte_size: u64, 1070 pub width: u32, 1071 pub height: u32, 1072 pub alt: String, 1073 pub prepared_at_unix_s: u64, 1074 } 1075 1076 #[derive(Clone, Debug, uniffi::Record)] 1077 pub struct FfiAddDraftInput { 1078 pub schema_version: u16, 1079 pub command_type: FfiAddCommandType, 1080 pub content: String, 1081 pub identifier: Option<String>, 1082 pub title: Option<String>, 1083 pub summary: Option<String>, 1084 pub location: Option<String>, 1085 pub event_timing: Option<FfiEventTimingKind>, 1086 pub event_start_date: Option<String>, 1087 pub event_end_date: Option<String>, 1088 pub event_start_unix_s: Option<u64>, 1089 pub event_end_unix_s: Option<u64>, 1090 pub event_timezone: Option<String>, 1091 pub price_amount: Option<String>, 1092 pub currency: Option<String>, 1093 pub unit: Option<String>, 1094 pub quantity: Option<String>, 1095 pub food_published_at_unix_s: Option<u64>, 1096 pub food_status: Option<String>, 1097 pub media: Vec<FfiPreparedMediaInput>, 1098 } 1099 1100 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1101 pub struct FfiRetractionDraftInput { 1102 pub schema_version: u16, 1103 pub command_type: FfiAddCommandType, 1104 pub target_card_id: String, 1105 pub target_event_id: String, 1106 pub target_kind: u32, 1107 pub target_address: Option<String>, 1108 pub reason: String, 1109 } 1110 1111 #[derive(Clone, Debug, uniffi::Record)] 1112 pub struct FfiBlossomUploadInput { 1113 pub schema_version: u16, 1114 pub draft_id: String, 1115 pub expected_revision: u64, 1116 pub media: FfiPreparedMediaInput, 1117 pub authorization_content: String, 1118 pub authorization_created_at_unix_s: u64, 1119 pub authorization_lifetime_seconds: u64, 1120 pub operation_id: String, 1121 pub artifact_id: String, 1122 pub signing_deadline_unix_ms: u64, 1123 pub signing_cancellation: FfiCancellationPolicy, 1124 pub verified_at_unix_ms: u64, 1125 pub updated_at_unix_ms: u64, 1126 } 1127 1128 /// Minimal host input for a Rust-planned exact-byte upload attempt. 1129 #[derive(Clone, Debug, uniffi::Record)] 1130 pub struct FfiBlossomUploadIntent { 1131 pub schema_version: u16, 1132 pub draft_id: String, 1133 pub expected_revision: u64, 1134 pub media: FfiPreparedMediaInput, 1135 } 1136 1137 /// Secret-bearing native job. Hosts may use the authorization header for the 1138 /// immediate OS request but must not persist it in application metadata. 1139 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1140 pub struct FfiNativeUploadJobRecord { 1141 pub schema_version: u16, 1142 pub operation_id: String, 1143 pub draft: FfiDraftStatusRecord, 1144 pub remote_url: String, 1145 pub upload_url: String, 1146 pub authorization_header: String, 1147 pub expected_sha256: String, 1148 pub media_type: String, 1149 pub byte_size: u64, 1150 } 1151 1152 #[derive(Clone, Debug, uniffi::Record)] 1153 pub struct FfiNativeUploadCompletionInput { 1154 pub schema_version: u16, 1155 pub draft_id: String, 1156 pub expected_revision: u64, 1157 pub media: FfiPreparedMediaInput, 1158 pub status_code: u16, 1159 pub response_media_type: Option<String>, 1160 pub response_content_encoding: Option<String>, 1161 pub response_body: Vec<u8>, 1162 } 1163 1164 impl FfiAddDraftInput { 1165 pub(crate) fn command_and_media( 1166 self, 1167 authored_at_unix_s: u64, 1168 blossom: Option<&radroots_sdk::transport::BlossomSlot>, 1169 ) -> Result<(Phase1AddCommand, Vec<Phase1MediaPrerequisite>), TeraAppError> { 1170 self.command_media_and_form(authored_at_unix_s, blossom) 1171 .map(|(command, media, _)| (command, media)) 1172 } 1173 1174 pub(crate) fn command_media_and_form( 1175 self, 1176 authored_at_unix_s: u64, 1177 blossom: Option<&radroots_sdk::transport::BlossomSlot>, 1178 ) -> Result< 1179 ( 1180 Phase1AddCommand, 1181 Vec<Phase1MediaPrerequisite>, 1182 Phase1DraftFormSnapshot, 1183 ), 1184 TeraAppError, 1185 > { 1186 require_schema(self.schema_version)?; 1187 if authored_at_unix_s == 0 || self.media.len() > 20 { 1188 return Err(TeraAppError::invalid_argument("invalid_add_draft")); 1189 } 1190 let prepared = self 1191 .media 1192 .iter() 1193 .cloned() 1194 .map(PreparedMedia::try_from) 1195 .map(|media| { 1196 media.and_then(|media| { 1197 let blossom = blossom 1198 .ok_or_else(|| TeraAppError::invalid_argument("blossom_not_configured"))?; 1199 media.bind(blossom) 1200 }) 1201 }) 1202 .collect::<Result<Vec<BoundPreparedMedia>, _>>()?; 1203 let prerequisites = prepared 1204 .iter() 1205 .map(|value| { 1206 Phase1MediaPrerequisite::new( 1207 value.media.opaque_reference.clone(), 1208 &value.descriptor, 1209 ) 1210 }) 1211 .collect::<Result<Vec<_>, _>>() 1212 .map_err(|_| TeraAppError::invalid_argument("invalid_media_reference"))?; 1213 let post_images = prepared 1214 .iter() 1215 .map(BoundPreparedMedia::post_image) 1216 .collect::<Result<Vec<_>, _>>()?; 1217 let form = self.form_snapshot(&prepared); 1218 let command = match self.command_type { 1219 FfiAddCommandType::CreateUpdate => { 1220 reject_media(&prepared)?; 1221 Phase1AddCommand::CreateUpdate( 1222 CreateUpdate::new(self.content) 1223 .map_err(|_| TeraAppError::invalid_argument("invalid_update"))?, 1224 ) 1225 } 1226 FfiAddCommandType::CreatePhotoUpdate => Phase1AddCommand::CreatePhotoUpdate( 1227 CreatePhotoUpdate::new( 1228 content_with_media_references(self.content, &prepared)?, 1229 post_images, 1230 ) 1231 .map_err(|_| TeraAppError::invalid_argument("invalid_photo_update"))?, 1232 ), 1233 FfiAddCommandType::CreateAsk => Phase1AddCommand::CreateAsk( 1234 CreateAsk::new( 1235 content_with_media_references(self.content, &prepared)?, 1236 post_images, 1237 ) 1238 .map_err(|_| TeraAppError::invalid_argument("invalid_ask"))?, 1239 ), 1240 FfiAddCommandType::CreateEvent => { 1241 Phase1AddCommand::CreateEvent(event_command(&self, prepared.first())?) 1242 } 1243 FfiAddCommandType::CreateFoodAvailability => Phase1AddCommand::CreateFoodAvailability( 1244 food_command(self, authored_at_unix_s, &prepared)?, 1245 ), 1246 }; 1247 Ok((command, prerequisites, form)) 1248 } 1249 1250 fn form_snapshot(&self, prepared: &[BoundPreparedMedia]) -> Phase1DraftFormSnapshot { 1251 Phase1DraftFormSnapshot { 1252 command_type: match self.command_type { 1253 FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate, 1254 FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate, 1255 FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk, 1256 FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent, 1257 FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability, 1258 }, 1259 content: self.content.clone(), 1260 identifier: self.identifier.clone(), 1261 title: self.title.clone(), 1262 summary: self.summary.clone(), 1263 location: self.location.clone(), 1264 event_timing: self.event_timing.map(|value| match value { 1265 FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay, 1266 FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed, 1267 }), 1268 event_start_date: self.event_start_date.clone(), 1269 event_end_date: self.event_end_date.clone(), 1270 event_start_unix_s: self.event_start_unix_s, 1271 event_end_unix_s: self.event_end_unix_s, 1272 event_timezone: self.event_timezone.clone(), 1273 price_amount: self.price_amount.clone(), 1274 currency: self.currency.clone(), 1275 unit: self.unit.clone(), 1276 quantity: self.quantity.clone(), 1277 food_published_at_unix_s: self.food_published_at_unix_s, 1278 food_status: self.food_status.clone(), 1279 media: prepared 1280 .iter() 1281 .map(|value| Phase1DraftMediaSnapshot { 1282 opaque_reference: value.media.opaque_reference.clone(), 1283 url: value.descriptor.url().as_str().to_owned(), 1284 sha256: value.media.sha256.to_hex(), 1285 media_type: value.media.media_type.as_str().to_owned(), 1286 byte_size: value.media.byte_size, 1287 width: value.media.width, 1288 height: value.media.height, 1289 alt: value.media.alt.clone(), 1290 prepared_at_unix_s: value.media.prepared_at_unix_s, 1291 }) 1292 .collect(), 1293 } 1294 } 1295 } 1296 1297 pub(crate) struct PreparedMedia { 1298 opaque_reference: String, 1299 sha256: Sha256, 1300 byte_size: u64, 1301 prepared_at_unix_s: u64, 1302 bytes: std::sync::Arc<[u8]>, 1303 media_type: MediaType, 1304 width: u32, 1305 height: u32, 1306 alt: String, 1307 } 1308 1309 struct BoundPreparedMedia { 1310 media: PreparedMedia, 1311 descriptor: radroots_blossom::ByteVerifiedDescriptor, 1312 } 1313 1314 impl TryFrom<FfiPreparedMediaInput> for PreparedMedia { 1315 type Error = TeraAppError; 1316 1317 fn try_from(value: FfiPreparedMediaInput) -> Result<Self, Self::Error> { 1318 if value.schema_version != PREPARED_MEDIA_FFI_SCHEMA_VERSION { 1319 return Err(TeraAppError::invalid_argument("unsupported_schema_version")); 1320 } 1321 if !opaque_media_reference_is_valid(&value.opaque_reference) 1322 || value.byte_size == 0 1323 || value.byte_size > MEDIA_FILE_MAX_BYTES 1324 || value.width == 0 1325 || value.height == 0 1326 || value.prepared_at_unix_s == 0 1327 || value.alt.trim().is_empty() 1328 || value.alt.len() > 1_024 1329 { 1330 return Err(TeraAppError::invalid_argument("invalid_media_reference")); 1331 } 1332 let bytes = value.file.read(value.byte_size)?; 1333 let media_type = MediaType::parse(&value.media_type) 1334 .map_err(|_| TeraAppError::invalid_argument("invalid_media_type"))?; 1335 let sha256 = Sha256::from_hex(&value.sha256) 1336 .map_err(|_| TeraAppError::invalid_argument("invalid_media_digest"))?; 1337 if Sha256::digest(&bytes) != sha256 { 1338 return Err(TeraAppError::invalid_argument("media_verification_failed")); 1339 } 1340 let dimensions = 1341 radroots_sdk::transport::BlossomImageDimensions::new(value.width, value.height) 1342 .map_err(|_| TeraAppError::invalid_argument("invalid_image_dimensions"))?; 1343 let verified_at_unix_ms = value 1344 .prepared_at_unix_s 1345 .checked_mul(1_000) 1346 .ok_or_else(|| TeraAppError::invalid_argument("invalid_media_reference"))?; 1347 radroots_sdk::transport::BlossomUploadRequest::new( 1348 bytes.clone().into(), 1349 media_type.clone(), 1350 dimensions, 1351 verified_at_unix_ms, 1352 ) 1353 .map_err(|_| TeraAppError::invalid_argument("media_verification_failed"))?; 1354 Ok(Self { 1355 opaque_reference: value.opaque_reference, 1356 sha256, 1357 byte_size: value.byte_size, 1358 prepared_at_unix_s: value.prepared_at_unix_s, 1359 bytes: bytes.into(), 1360 media_type, 1361 width: value.width, 1362 height: value.height, 1363 alt: value.alt, 1364 }) 1365 } 1366 } 1367 1368 impl PreparedMedia { 1369 pub(crate) fn into_recovery_media( 1370 self, 1371 ) -> Result<tera_core::runtime::product_surface::recovery_completion::RecoveryMedia, TeraAppError> 1372 { 1373 tera_core::runtime::product_surface::recovery_completion::RecoveryMedia::new( 1374 self.opaque_reference, 1375 self.bytes, 1376 self.media_type, 1377 self.width, 1378 self.height, 1379 ) 1380 .map_err(Into::into) 1381 } 1382 1383 pub(crate) fn into_submission_bytes(self) -> std::sync::Arc<[u8]> { 1384 self.bytes 1385 } 1386 1387 pub(crate) fn into_submission_media( 1388 self, 1389 request: tera_core::runtime::product_surface::SubmissionReservationRequest, 1390 expected_revision: u64, 1391 ) -> Result<tera_core::runtime::product_surface::SubmissionMediaRequest, TeraAppError> { 1392 tera_core::runtime::product_surface::SubmissionMediaRequest::new( 1393 request, 1394 expected_revision, 1395 self.opaque_reference, 1396 self.bytes, 1397 ) 1398 .map_err(Into::into) 1399 } 1400 1401 pub(crate) fn into_authored_image( 1402 self, 1403 blossom: &radroots_sdk::transport::BlossomSlot, 1404 ) -> Result<AuthoredImage, TeraAppError> { 1405 self.bind(blossom)?.authored_image() 1406 } 1407 1408 pub(crate) fn into_upload_intent( 1409 self, 1410 draft_id: [u8; 16], 1411 expected_revision: u64, 1412 ) -> Result<Phase1UploadIntent, TeraAppError> { 1413 Phase1UploadIntent::new( 1414 draft_id, 1415 expected_revision, 1416 self.bytes, 1417 self.media_type, 1418 self.width, 1419 self.height, 1420 ) 1421 .map_err(|_| TeraAppError::invalid_argument("invalid_blossom_upload")) 1422 } 1423 1424 pub(crate) fn upload_request( 1425 &self, 1426 verified_at_unix_ms: u64, 1427 ) -> Result<radroots_sdk::transport::BlossomUploadRequest, TeraAppError> { 1428 let dimensions = 1429 radroots_sdk::transport::BlossomImageDimensions::new(self.width, self.height) 1430 .map_err(|_| TeraAppError::invalid_argument("invalid_image_dimensions"))?; 1431 radroots_sdk::transport::BlossomUploadRequest::new( 1432 std::sync::Arc::clone(&self.bytes), 1433 self.media_type.clone(), 1434 dimensions, 1435 verified_at_unix_ms, 1436 ) 1437 .map_err(|_| TeraAppError::invalid_argument("invalid_blossom_upload")) 1438 } 1439 1440 fn bind( 1441 self, 1442 blossom: &radroots_sdk::transport::BlossomSlot, 1443 ) -> Result<BoundPreparedMedia, TeraAppError> { 1444 let verified_at_unix_ms = self 1445 .prepared_at_unix_s 1446 .checked_mul(1_000) 1447 .ok_or_else(|| TeraAppError::invalid_argument("invalid_media_reference"))?; 1448 let transaction = blossom 1449 .prepare_upload(self.upload_request(verified_at_unix_ms)?) 1450 .map_err(|error| TeraAppError::invalid_argument(error.code()))?; 1451 let descriptor = BlobDescriptor::new( 1452 transaction.expected_url().clone(), 1453 self.sha256, 1454 self.byte_size, 1455 self.media_type.clone(), 1456 self.prepared_at_unix_s, 1457 ) 1458 .and_then(BlobDescriptor::approve_reference) 1459 .and_then(|descriptor| descriptor.verify_bytes(&self.bytes, &self.media_type)) 1460 .map_err(|_| TeraAppError::invalid_argument("media_verification_failed"))?; 1461 Ok(BoundPreparedMedia { 1462 media: self, 1463 descriptor, 1464 }) 1465 } 1466 } 1467 1468 impl BoundPreparedMedia { 1469 fn authored_image(&self) -> Result<AuthoredImage, TeraAppError> { 1470 AuthoredImage::try_from_verified_descriptor(self.descriptor.clone()) 1471 .map_err(|_| TeraAppError::invalid_argument("invalid_image_media")) 1472 } 1473 1474 fn post_image(&self) -> Result<AuthoredPostImage, TeraAppError> { 1475 AuthoredPostImage::new( 1476 self.authored_image()?, 1477 PostImageDimensions::new(self.media.width, self.media.height) 1478 .map_err(|_| TeraAppError::invalid_argument("invalid_image_dimensions"))?, 1479 self.media.alt.clone(), 1480 ) 1481 .map_err(|_| TeraAppError::invalid_argument("invalid_image")) 1482 } 1483 } 1484 1485 fn event_command( 1486 input: &FfiAddDraftInput, 1487 image: Option<&BoundPreparedMedia>, 1488 ) -> Result<CreateEvent, TeraAppError> { 1489 if input.media.len() > 1 { 1490 return Err(TeraAppError::invalid_argument("event_image_limit")); 1491 } 1492 let identifier = required(input.identifier.as_deref(), "event_identifier_required")?; 1493 let title = required(input.title.as_deref(), "event_title_required")?; 1494 let timing = input 1495 .event_timing 1496 .ok_or_else(|| TeraAppError::invalid_argument("event_timing_required"))?; 1497 match timing { 1498 FfiEventTimingKind::AllDay => { 1499 let start = CalendarDate::parse(required( 1500 input.event_start_date.as_deref(), 1501 "event_start_date_required", 1502 )?) 1503 .map_err(|_| TeraAppError::invalid_argument("invalid_event_start_date"))?; 1504 let mut event = AuthoredCalendarDateEvent::new(identifier, title, start) 1505 .map_err(|_| TeraAppError::invalid_argument("invalid_event"))?; 1506 if let Some(end) = input.event_end_date.as_deref() { 1507 event = 1508 event 1509 .with_end(CalendarDate::parse(end).map_err(|_| { 1510 TeraAppError::invalid_argument("invalid_event_end_date") 1511 })?) 1512 .map_err(|_| TeraAppError::invalid_argument("invalid_event_range"))?; 1513 } 1514 if !input.content.is_empty() { 1515 event = event 1516 .with_description(input.content.clone()) 1517 .map_err(|_| TeraAppError::invalid_argument("invalid_event_description"))?; 1518 } 1519 if let Some(location) = input.location.clone() { 1520 event = event 1521 .with_locations(vec![location]) 1522 .map_err(|_| TeraAppError::invalid_argument("invalid_event_location"))?; 1523 } 1524 if let Some(image) = image { 1525 event = event 1526 .with_image(image.authored_image()?) 1527 .map_err(|_| TeraAppError::invalid_argument("invalid_event_image"))?; 1528 } 1529 Ok(CreateEvent::date(event)) 1530 } 1531 FfiEventTimingKind::Timed => { 1532 let start = input 1533 .event_start_unix_s 1534 .filter(|value| *value != 0) 1535 .ok_or_else(|| TeraAppError::invalid_argument("event_start_required"))?; 1536 let mut event = AuthoredCalendarTimeEvent::new(identifier, title, start) 1537 .map_err(|_| TeraAppError::invalid_argument("invalid_event"))?; 1538 if let Some(end) = input.event_end_unix_s { 1539 event = event 1540 .with_end(end) 1541 .map_err(|_| TeraAppError::invalid_argument("invalid_event_range"))?; 1542 } 1543 if let Some(timezone) = input.event_timezone.as_deref() { 1544 event = event 1545 .with_start_tzid(timezone) 1546 .map_err(|_| TeraAppError::invalid_argument("invalid_event_timezone"))?; 1547 } 1548 if !input.content.is_empty() { 1549 event = event 1550 .with_description(input.content.clone()) 1551 .map_err(|_| TeraAppError::invalid_argument("invalid_event_description"))?; 1552 } 1553 if let Some(location) = input.location.clone() { 1554 event = event 1555 .with_locations(vec![location]) 1556 .map_err(|_| TeraAppError::invalid_argument("invalid_event_location"))?; 1557 } 1558 if let Some(image) = image { 1559 event = event 1560 .with_image(image.authored_image()?) 1561 .map_err(|_| TeraAppError::invalid_argument("invalid_event_image"))?; 1562 } 1563 Ok(CreateEvent::time(event)) 1564 } 1565 } 1566 } 1567 1568 fn food_command( 1569 input: FfiAddDraftInput, 1570 authored_at_unix_s: u64, 1571 media: &[BoundPreparedMedia], 1572 ) -> Result<CreateFoodAvailability, TeraAppError> { 1573 let unit = FoodUnit::parse(required(input.unit.as_deref(), "food_unit_required")?) 1574 .map_err(|_| TeraAppError::invalid_argument("invalid_food_unit"))?; 1575 let images = media 1576 .iter() 1577 .map(|image| { 1578 Ok(FoodAvailabilityImage::new( 1579 image.authored_image()?, 1580 FoodImageDimensions::new(image.media.width, image.media.height) 1581 .map_err(|_| TeraAppError::invalid_argument("invalid_image_dimensions"))?, 1582 )) 1583 }) 1584 .collect::<Result<Vec<_>, TeraAppError>>()?; 1585 let status = input.food_status.as_deref().unwrap_or("active"); 1586 let details = FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { 1587 content: FoodContent::new(input.content) 1588 .map_err(|_| TeraAppError::invalid_argument("invalid_food_content"))?, 1589 identifier: FoodIdentifier::parse(required( 1590 input.identifier.as_deref(), 1591 "food_identifier_required", 1592 )?) 1593 .map_err(|_| TeraAppError::invalid_argument("invalid_food_identifier"))?, 1594 title: FoodText::new(required(input.title, "food_title_required")?) 1595 .map_err(|_| TeraAppError::invalid_argument("invalid_food_title"))?, 1596 summary: FoodText::new(required(input.summary, "food_summary_required")?) 1597 .map_err(|_| TeraAppError::invalid_argument("invalid_food_summary"))?, 1598 published_at: FoodPublishedAt::new( 1599 input.food_published_at_unix_s.unwrap_or(authored_at_unix_s), 1600 ) 1601 .map_err(|_| TeraAppError::invalid_argument("invalid_food_published_at"))?, 1602 location: FoodText::new(required(input.location, "food_location_required")?) 1603 .map_err(|_| TeraAppError::invalid_argument("invalid_food_location"))?, 1604 price: FoodPrice::new( 1605 required(input.price_amount, "food_price_required")?, 1606 FoodCurrency::parse(required(input.currency, "food_currency_required")?) 1607 .map_err(|_| TeraAppError::invalid_argument("invalid_food_currency"))?, 1608 unit, 1609 ) 1610 .map_err(|_| TeraAppError::invalid_argument("invalid_food_price"))?, 1611 quantity: input 1612 .quantity 1613 .map(|quantity| FoodQuantity::new(quantity, unit)) 1614 .transpose() 1615 .map_err(|_| TeraAppError::invalid_argument("invalid_food_quantity"))?, 1616 status: FoodAvailabilityStatus::parse(status) 1617 .map_err(|_| TeraAppError::invalid_argument("invalid_food_status"))?, 1618 images, 1619 }) 1620 .map_err(|_| TeraAppError::invalid_argument("invalid_food_availability"))?; 1621 Ok(CreateFoodAvailability::new(details)) 1622 } 1623 1624 fn reject_media(media: &[BoundPreparedMedia]) -> Result<(), TeraAppError> { 1625 if media.is_empty() { 1626 Ok(()) 1627 } else { 1628 Err(TeraAppError::invalid_argument("media_not_allowed")) 1629 } 1630 } 1631 1632 fn content_with_media_references( 1633 mut content: String, 1634 media: &[BoundPreparedMedia], 1635 ) -> Result<String, TeraAppError> { 1636 if content.trim().is_empty() { 1637 return Err(TeraAppError::invalid_argument("content_required")); 1638 } 1639 for item in media { 1640 let url = item.descriptor.url().as_str(); 1641 match content.match_indices(url).count() { 1642 0 => { 1643 if !content.ends_with('\n') { 1644 content.push('\n'); 1645 } 1646 content.push_str(url); 1647 } 1648 1 => {} 1649 _ => { 1650 return Err(TeraAppError::invalid_argument("duplicate_media_reference")); 1651 } 1652 } 1653 } 1654 Ok(content) 1655 } 1656 1657 fn required<T>(value: Option<T>, code: &'static str) -> Result<T, TeraAppError> { 1658 value.ok_or_else(|| TeraAppError::invalid_argument(code)) 1659 } 1660 1661 fn opaque_media_reference_is_valid(value: &str) -> bool { 1662 value.len() > "media:".len() 1663 && value.len() <= MEDIA_REFERENCE_MAX_BYTES 1664 && value.starts_with("media:") 1665 && value["media:".len()..].bytes().all(|byte| { 1666 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') 1667 }) 1668 } 1669 1670 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 1671 pub enum FfiMediaStage { 1672 Pending, 1673 Preparing, 1674 Uploading, 1675 Verified, 1676 Failed, 1677 Orphaned, 1678 } 1679 1680 #[cfg_attr(coverage_nightly, coverage(off))] 1681 impl From<Phase1MediaStage> for FfiMediaStage { 1682 fn from(value: Phase1MediaStage) -> Self { 1683 match value { 1684 Phase1MediaStage::Pending => Self::Pending, 1685 Phase1MediaStage::Preparing => Self::Preparing, 1686 Phase1MediaStage::Uploading => Self::Uploading, 1687 Phase1MediaStage::Verified => Self::Verified, 1688 Phase1MediaStage::Failed => Self::Failed, 1689 Phase1MediaStage::Orphaned => Self::Orphaned, 1690 } 1691 } 1692 } 1693 1694 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1695 pub struct FfiDraftMediaRecord { 1696 pub schema_version: u16, 1697 pub url: String, 1698 pub upload_url: Option<String>, 1699 pub stage: FfiMediaStage, 1700 pub upload_attempts: u8, 1701 pub verified_at_unix_ms: Option<u64>, 1702 pub possible_orphan: bool, 1703 pub orphan_reason_code: Option<String>, 1704 pub orphan_recorded_at_unix_ms: Option<u64>, 1705 } 1706 1707 #[cfg_attr(coverage_nightly, coverage(off))] 1708 impl From<&Phase1MediaPrerequisite> for FfiDraftMediaRecord { 1709 fn from(value: &Phase1MediaPrerequisite) -> Self { 1710 let orphan = value.orphan(); 1711 Self { 1712 schema_version: UPLOAD_OUTPUT_FFI_SCHEMA_VERSION, 1713 url: value.url().to_owned(), 1714 upload_url: radroots_blossom::BlobUrl::parse(value.url()) 1715 .ok() 1716 .map(|url| url.upload_url()), 1717 stage: value.stage().into(), 1718 upload_attempts: value.upload_attempts(), 1719 verified_at_unix_ms: value.verified_at_unix_ms(), 1720 possible_orphan: orphan.is_some(), 1721 orphan_reason_code: orphan.map(|value| value.reason_code().to_owned()), 1722 orphan_recorded_at_unix_ms: orphan.map(|value| value.recorded_at_unix_ms()), 1723 } 1724 } 1725 } 1726 1727 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 1728 pub enum FfiOutboxState { 1729 Draft, 1730 MediaPreparing, 1731 MediaUploading, 1732 ReadyToSign, 1733 Signing, 1734 Signed, 1735 Queued, 1736 Delivering, 1737 PartiallyDelivered, 1738 Retryable, 1739 Terminal, 1740 Cancelled, 1741 Complete, 1742 } 1743 1744 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 1745 pub enum FfiDraftKind { 1746 Add, 1747 Retraction, 1748 } 1749 1750 #[cfg_attr(coverage_nightly, coverage(off))] 1751 impl From<Phase1DraftKind> for FfiDraftKind { 1752 fn from(value: Phase1DraftKind) -> Self { 1753 match value { 1754 Phase1DraftKind::Add => Self::Add, 1755 Phase1DraftKind::Retraction => Self::Retraction, 1756 } 1757 } 1758 } 1759 1760 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1761 pub struct FfiDraftFormMediaRecord { 1762 pub schema_version: u16, 1763 pub opaque_reference: String, 1764 pub url: String, 1765 pub sha256: String, 1766 pub media_type: String, 1767 pub byte_size: u64, 1768 pub width: u32, 1769 pub height: u32, 1770 pub alt: String, 1771 pub prepared_at_unix_s: u64, 1772 } 1773 1774 #[cfg_attr(coverage_nightly, coverage(off))] 1775 impl From<&Phase1DraftMediaSnapshot> for FfiDraftFormMediaRecord { 1776 fn from(value: &Phase1DraftMediaSnapshot) -> Self { 1777 Self { 1778 schema_version: MOBILE_FFI_SCHEMA_VERSION, 1779 opaque_reference: value.opaque_reference.clone(), 1780 url: value.url.clone(), 1781 sha256: value.sha256.clone(), 1782 media_type: value.media_type.clone(), 1783 byte_size: value.byte_size, 1784 width: value.width, 1785 height: value.height, 1786 alt: value.alt.clone(), 1787 prepared_at_unix_s: value.prepared_at_unix_s, 1788 } 1789 } 1790 } 1791 1792 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1793 pub struct FfiDraftFormRecord { 1794 pub schema_version: u16, 1795 pub command_type: FfiAddCommandType, 1796 pub content: String, 1797 pub identifier: Option<String>, 1798 pub title: Option<String>, 1799 pub summary: Option<String>, 1800 pub location: Option<String>, 1801 pub event_timing: Option<FfiEventTimingKind>, 1802 pub event_start_date: Option<String>, 1803 pub event_end_date: Option<String>, 1804 pub event_start_unix_s: Option<u64>, 1805 pub event_end_unix_s: Option<u64>, 1806 pub event_timezone: Option<String>, 1807 pub price_amount: Option<String>, 1808 pub currency: Option<String>, 1809 pub unit: Option<String>, 1810 pub quantity: Option<String>, 1811 pub food_published_at_unix_s: Option<u64>, 1812 pub food_status: Option<String>, 1813 pub media: Vec<FfiDraftFormMediaRecord>, 1814 } 1815 1816 #[cfg_attr(coverage_nightly, coverage(off))] 1817 impl From<&Phase1DraftFormSnapshot> for FfiDraftFormRecord { 1818 fn from(value: &Phase1DraftFormSnapshot) -> Self { 1819 Self { 1820 schema_version: MOBILE_FFI_SCHEMA_VERSION, 1821 command_type: value.command_type.into(), 1822 content: value.content.clone(), 1823 identifier: value.identifier.clone(), 1824 title: value.title.clone(), 1825 summary: value.summary.clone(), 1826 location: value.location.clone(), 1827 event_timing: value.event_timing.map(|value| match value { 1828 Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay, 1829 Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed, 1830 }), 1831 event_start_date: value.event_start_date.clone(), 1832 event_end_date: value.event_end_date.clone(), 1833 event_start_unix_s: value.event_start_unix_s, 1834 event_end_unix_s: value.event_end_unix_s, 1835 event_timezone: value.event_timezone.clone(), 1836 price_amount: value.price_amount.clone(), 1837 currency: value.currency.clone(), 1838 unit: value.unit.clone(), 1839 quantity: value.quantity.clone(), 1840 food_published_at_unix_s: value.food_published_at_unix_s, 1841 food_status: value.food_status.clone(), 1842 media: value.media.iter().map(Into::into).collect(), 1843 } 1844 } 1845 } 1846 1847 #[cfg_attr(coverage_nightly, coverage(off))] 1848 impl From<Phase1OutboxState> for FfiOutboxState { 1849 fn from(value: Phase1OutboxState) -> Self { 1850 match value { 1851 Phase1OutboxState::Draft => Self::Draft, 1852 Phase1OutboxState::MediaPreparing => Self::MediaPreparing, 1853 Phase1OutboxState::MediaUploading => Self::MediaUploading, 1854 Phase1OutboxState::ReadyToSign => Self::ReadyToSign, 1855 Phase1OutboxState::Signing => Self::Signing, 1856 Phase1OutboxState::Signed => Self::Signed, 1857 Phase1OutboxState::Queued => Self::Queued, 1858 Phase1OutboxState::Delivering => Self::Delivering, 1859 Phase1OutboxState::PartiallyDelivered => Self::PartiallyDelivered, 1860 Phase1OutboxState::Retryable => Self::Retryable, 1861 Phase1OutboxState::Terminal => Self::Terminal, 1862 Phase1OutboxState::Cancelled => Self::Cancelled, 1863 Phase1OutboxState::Complete => Self::Complete, 1864 } 1865 } 1866 } 1867 1868 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)] 1869 pub struct FfiOperationSettlementRecord { 1870 pub schema_version: u16, 1871 pub artifacts: u16, 1872 pub signed: u16, 1873 pub admitted: u16, 1874 pub pending: u16, 1875 pub retryable: u16, 1876 pub indeterminate: u16, 1877 pub failed_terminal: u16, 1878 pub cancelled: u16, 1879 pub delivery_plans: u16, 1880 pub delivery_satisfied: u16, 1881 pub delivery_pending: u16, 1882 pub delivery_retryable: u16, 1883 pub delivery_exhausted: u16, 1884 pub delivery_failed_terminal: u16, 1885 pub delivery_cancelled: u16, 1886 } 1887 1888 #[cfg_attr(coverage_nightly, coverage(off))] 1889 impl From<radroots_storage::authored::OperationSettlement> for FfiOperationSettlementRecord { 1890 fn from(value: radroots_storage::authored::OperationSettlement) -> Self { 1891 Self { 1892 schema_version: MOBILE_FFI_SCHEMA_VERSION, 1893 artifacts: value.artifacts(), 1894 signed: value.signed(), 1895 admitted: value.admitted(), 1896 pending: value.pending(), 1897 retryable: value.retryable(), 1898 indeterminate: value.indeterminate(), 1899 failed_terminal: value.failed_terminal(), 1900 cancelled: value.cancelled(), 1901 delivery_plans: value.delivery_plans(), 1902 delivery_satisfied: value.delivery_satisfied(), 1903 delivery_pending: value.delivery_pending(), 1904 delivery_retryable: value.delivery_retryable(), 1905 delivery_exhausted: value.delivery_exhausted(), 1906 delivery_failed_terminal: value.delivery_failed_terminal(), 1907 delivery_cancelled: value.delivery_cancelled(), 1908 } 1909 } 1910 } 1911 1912 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 1913 pub enum FfiRelaySatisfaction { 1914 AnyAccepted, 1915 AllAccepted, 1916 AnyDelivered, 1917 AllDelivered, 1918 } 1919 1920 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 1921 pub enum FfiCancellationPolicy { 1922 PreservePublishedRequest, 1923 LocalCooperative, 1924 } 1925 1926 impl FfiCancellationPolicy { 1927 pub(crate) const fn core(self) -> Phase1CancellationPolicy { 1928 match self { 1929 Self::PreservePublishedRequest => Phase1CancellationPolicy::PreservePublishedRequest, 1930 Self::LocalCooperative => Phase1CancellationPolicy::LocalCooperative, 1931 } 1932 } 1933 } 1934 1935 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1936 pub struct FfiQueuePolicyRecord { 1937 pub schema_version: u16, 1938 pub relay_urls: Vec<String>, 1939 pub satisfaction: FfiRelaySatisfaction, 1940 pub delivery_deadline_unix_ms: u64, 1941 pub cancellation: FfiCancellationPolicy, 1942 } 1943 1944 impl TryFrom<FfiQueuePolicyRecord> for Phase1QueuePolicy { 1945 type Error = TeraAppError; 1946 1947 fn try_from(value: FfiQueuePolicyRecord) -> Result<Self, Self::Error> { 1948 require_schema(value.schema_version)?; 1949 Phase1QueuePolicy::new( 1950 value.relay_urls, 1951 match value.satisfaction { 1952 FfiRelaySatisfaction::AnyAccepted => Phase1RelaySatisfaction::AnyAccepted, 1953 FfiRelaySatisfaction::AllAccepted => Phase1RelaySatisfaction::AllAccepted, 1954 FfiRelaySatisfaction::AnyDelivered => Phase1RelaySatisfaction::AnyDelivered, 1955 FfiRelaySatisfaction::AllDelivered => Phase1RelaySatisfaction::AllDelivered, 1956 }, 1957 value.delivery_deadline_unix_ms, 1958 match value.cancellation { 1959 FfiCancellationPolicy::PreservePublishedRequest => { 1960 Phase1CancellationPolicy::PreservePublishedRequest 1961 } 1962 FfiCancellationPolicy::LocalCooperative => { 1963 Phase1CancellationPolicy::LocalCooperative 1964 } 1965 }, 1966 ) 1967 .map_err(|_| TeraAppError::invalid_argument("invalid_queue_policy")) 1968 } 1969 } 1970 1971 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1972 pub struct FfiCapabilityRecord { 1973 pub schema_version: u16, 1974 pub id: String, 1975 pub compiled: bool, 1976 pub configured: bool, 1977 pub availability: String, 1978 pub maturity: String, 1979 } 1980 1981 #[cfg_attr(coverage_nightly, coverage(off))] 1982 impl From<SdkCapabilityRecord> for FfiCapabilityRecord { 1983 fn from(value: SdkCapabilityRecord) -> Self { 1984 Self { 1985 schema_version: MOBILE_FFI_SCHEMA_VERSION, 1986 id: value.id, 1987 compiled: value.compiled, 1988 configured: value.configured, 1989 availability: value.availability, 1990 maturity: value.maturity, 1991 } 1992 } 1993 } 1994 1995 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 1996 pub struct FfiStorageStatusRecord { 1997 pub schema_version: u16, 1998 pub backend: String, 1999 pub open_mode: String, 2000 pub shutdown: String, 2001 pub integrity: String, 2002 } 2003 2004 #[cfg_attr(coverage_nightly, coverage(off))] 2005 impl From<SdkStorageStatusRecord> for FfiStorageStatusRecord { 2006 fn from(value: SdkStorageStatusRecord) -> Self { 2007 Self { 2008 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2009 backend: value.backend, 2010 open_mode: value.open_mode, 2011 shutdown: value.shutdown, 2012 integrity: value.integrity, 2013 } 2014 } 2015 } 2016 2017 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 2018 pub enum FfiRelayAccessRecord { 2019 ReadOnly, 2020 ReadWrite, 2021 } 2022 2023 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 2024 pub struct FfiRelayStatusRecord { 2025 pub schema_version: u16, 2026 pub relay_url: String, 2027 pub access: FfiRelayAccessRecord, 2028 pub read_state: String, 2029 pub write_state: String, 2030 pub read_last_attempt_unix_ms: Option<u64>, 2031 pub write_last_attempt_unix_ms: Option<u64>, 2032 pub read_next_attempt_unix_ms: Option<u64>, 2033 pub write_next_attempt_unix_ms: Option<u64>, 2034 } 2035 2036 #[cfg_attr(coverage_nightly, coverage(off))] 2037 impl From<SdkRelayStatusRecord> for FfiRelayStatusRecord { 2038 fn from(value: SdkRelayStatusRecord) -> Self { 2039 Self { 2040 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2041 relay_url: value.relay_url, 2042 access: match value.access { 2043 SdkRelayAccessRecord::ReadOnly => FfiRelayAccessRecord::ReadOnly, 2044 SdkRelayAccessRecord::ReadWrite => FfiRelayAccessRecord::ReadWrite, 2045 }, 2046 read_state: value.read_state, 2047 write_state: value.write_state, 2048 read_last_attempt_unix_ms: value.read_last_attempt_unix_ms, 2049 write_last_attempt_unix_ms: value.write_last_attempt_unix_ms, 2050 read_next_attempt_unix_ms: value.read_next_attempt_unix_ms, 2051 write_next_attempt_unix_ms: value.write_next_attempt_unix_ms, 2052 } 2053 } 2054 } 2055 2056 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 2057 pub struct FfiRelayStatusReportRecord { 2058 pub schema_version: u16, 2059 pub profile: String, 2060 pub state: String, 2061 pub read_availability: String, 2062 pub write_availability: String, 2063 pub relays: Vec<FfiRelayStatusRecord>, 2064 } 2065 2066 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 2067 pub enum FfiBlossomHostKind { 2068 Native, 2069 Simulator, 2070 PhysicalDevice, 2071 } 2072 2073 impl From<FfiBlossomHostKind> for radroots_sdk::transport::BlossomHostKind { 2074 fn from(value: FfiBlossomHostKind) -> Self { 2075 match value { 2076 FfiBlossomHostKind::Native => Self::Native, 2077 FfiBlossomHostKind::Simulator => Self::Simulator, 2078 FfiBlossomHostKind::PhysicalDevice => Self::PhysicalDevice, 2079 } 2080 } 2081 } 2082 2083 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] 2084 pub enum FfiBlossomEndpointAuthority { 2085 PublicWebPki, 2086 LoopbackDevelopment, 2087 PrivateNetworkDevelopment, 2088 } 2089 2090 impl From<FfiBlossomEndpointAuthority> for radroots_sdk::transport::BlossomEndpointAuthority { 2091 fn from(value: FfiBlossomEndpointAuthority) -> Self { 2092 match value { 2093 FfiBlossomEndpointAuthority::PublicWebPki => Self::PublicWebPki, 2094 FfiBlossomEndpointAuthority::LoopbackDevelopment => Self::LoopbackDevelopment, 2095 FfiBlossomEndpointAuthority::PrivateNetworkDevelopment => { 2096 Self::PrivateNetworkDevelopment 2097 } 2098 } 2099 } 2100 } 2101 2102 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 2103 pub struct FfiBlossomConfigurationRecord { 2104 pub schema_version: u16, 2105 pub host_kind: String, 2106 pub endpoint_authority: String, 2107 pub primary_origin: String, 2108 pub fallback_origins: Vec<String>, 2109 pub config_fingerprint: String, 2110 } 2111 2112 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 2113 pub struct FfiBlossomEvidenceRecord { 2114 pub schema_version: u16, 2115 pub origin: String, 2116 pub config_fingerprint: String, 2117 pub state: String, 2118 pub last_successful_state: String, 2119 pub transport_security: String, 2120 pub observed_at_unix_ms: Option<u64>, 2121 pub http_status: Option<u16>, 2122 pub error_code: Option<String>, 2123 pub server_error_code: Option<String>, 2124 pub error_phase: Option<String>, 2125 pub retryable: bool, 2126 pub possible_orphan: bool, 2127 pub attempts: u8, 2128 } 2129 2130 #[cfg_attr(coverage_nightly, coverage(off))] 2131 impl From<SdkBlossomConfigurationRecord> for FfiBlossomConfigurationRecord { 2132 fn from(value: SdkBlossomConfigurationRecord) -> Self { 2133 Self { 2134 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2135 host_kind: value.host_kind, 2136 endpoint_authority: value.endpoint_authority, 2137 primary_origin: value.primary_origin, 2138 fallback_origins: value.fallback_origins, 2139 config_fingerprint: value.config_fingerprint, 2140 } 2141 } 2142 } 2143 2144 #[cfg_attr(coverage_nightly, coverage(off))] 2145 impl From<SdkBlossomEvidenceRecord> for FfiBlossomEvidenceRecord { 2146 fn from(value: SdkBlossomEvidenceRecord) -> Self { 2147 Self { 2148 schema_version: value.schema_version, 2149 origin: value.origin, 2150 config_fingerprint: value.config_fingerprint, 2151 state: value.state, 2152 last_successful_state: value.last_successful_state, 2153 transport_security: value.transport_security, 2154 observed_at_unix_ms: value.observed_at_unix_ms, 2155 http_status: value.http_status, 2156 error_code: value.error_code, 2157 server_error_code: value.server_error_code, 2158 error_phase: value.error_phase, 2159 retryable: value.retryable, 2160 possible_orphan: value.possible_orphan, 2161 attempts: value.attempts, 2162 } 2163 } 2164 } 2165 2166 #[cfg_attr(coverage_nightly, coverage(off))] 2167 impl From<SdkRelayStatusReportRecord> for FfiRelayStatusReportRecord { 2168 fn from(value: SdkRelayStatusReportRecord) -> Self { 2169 Self { 2170 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2171 profile: value.profile, 2172 state: value.state, 2173 read_availability: value.read_availability, 2174 write_availability: value.write_availability, 2175 relays: value.relays.into_iter().map(Into::into).collect(), 2176 } 2177 } 2178 } 2179 2180 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 2181 pub struct FfiShutdownRecord { 2182 pub schema_version: u16, 2183 pub state: String, 2184 pub already_closed: bool, 2185 } 2186 2187 #[cfg_attr(coverage_nightly, coverage(off))] 2188 impl From<SdkShutdownRecord> for FfiShutdownRecord { 2189 fn from(value: SdkShutdownRecord) -> Self { 2190 Self { 2191 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2192 state: value.state, 2193 already_closed: value.already_closed, 2194 } 2195 } 2196 } 2197 2198 pub(crate) fn decode_id(value: &str, code: &'static str) -> Result<[u8; 16], TeraAppError> { 2199 if value.len() != 32 { 2200 return Err(TeraAppError::invalid_argument(code)); 2201 } 2202 let bytes = hex::decode(value).map_err(|_| TeraAppError::invalid_argument(code))?; 2203 bytes 2204 .try_into() 2205 .map_err(|_| TeraAppError::invalid_argument(code)) 2206 } 2207 2208 fn require_schema(schema_version: u16) -> Result<(), TeraAppError> { 2209 if schema_version == MOBILE_FFI_SCHEMA_VERSION { 2210 Ok(()) 2211 } else { 2212 Err(TeraAppError::invalid_argument("unsupported_schema_version")) 2213 } 2214 } 2215 2216 #[cfg(test)] 2217 mod tests { 2218 use core::num::NonZeroU64; 2219 use std::io::Write; 2220 use std::os::fd::AsRawFd; 2221 2222 use radroots_event::id::TradeId; 2223 2224 use super::*; 2225 2226 fn rhi_attestation_fixture() -> serde_json::Value { 2227 let fixture: serde_json::Value = serde_json::from_str(include_str!( 2228 "../../../test-fixtures/tera_ffi/authored_operations.v1.json" 2229 )) 2230 .expect("authored corpus"); 2231 fixture["vectors"] 2232 .as_array() 2233 .expect("operations") 2234 .iter() 2235 .find(|entry| entry["id"] == "typed_rhi_evidence_attestation_017") 2236 .expect("RHI operation") 2237 .get("expected") 2238 .expect("expected") 2239 .clone() 2240 } 2241 2242 #[test] 2243 fn evidence_report_plan_and_verified_event_use_final_mobile_vocabulary() { 2244 let expected = rhi_attestation_fixture(); 2245 let content = expected["content"].as_str().expect("content").to_owned(); 2246 let report = parse_rhi_evidence_report(content.clone()).expect("report"); 2247 assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Indeterminate); 2248 assert_eq!(report.trade_generation, "7"); 2249 assert_eq!(report.observed_at_unix_s, "1800000000"); 2250 2251 let plan = prepare_rhi_evidence_attestation(content, 1_784_347_200).expect("plan"); 2252 assert_eq!(plan.kind, 3_441); 2253 assert_eq!(plan.created_at_unix_s, "1784347200"); 2254 assert_eq!( 2255 plan.expected_event_id, 2256 expected["event_id"].as_str().expect("event id") 2257 ); 2258 2259 let raw: serde_json::Value = 2260 serde_json::from_str(expected["raw_json"].as_str().expect("raw event")) 2261 .expect("raw event JSON"); 2262 let signed = FfiSignedEvidenceEventRecord { 2263 id: raw["id"].as_str().expect("id").to_owned(), 2264 author_pubkey: raw["pubkey"].as_str().expect("pubkey").to_owned(), 2265 created_at_unix_s: raw["created_at"].as_u64().expect("created_at"), 2266 kind: u32::try_from(raw["kind"].as_u64().expect("kind")).expect("u32 kind"), 2267 tags: serde_json::from_value(raw["tags"].clone()).expect("tags"), 2268 content: raw["content"].as_str().expect("content").to_owned(), 2269 signature: raw["sig"].as_str().expect("signature").to_owned(), 2270 }; 2271 let attestation = validate_rhi_evidence_attestation(signed).expect("attestation"); 2272 assert_eq!(attestation.outcome, FfiTradeEvidenceOutcome::Indeterminate); 2273 assert_eq!(attestation.trade_generation, "7"); 2274 } 2275 2276 #[test] 2277 fn evidence_manifest_and_supersession_project_the_complete_vocabulary() { 2278 use radroots_sdk::trade::{ 2279 RadrootsTradeEvidenceManifestSourceResultV1, RadrootsTradeEvidenceManifestV1, 2280 RadrootsTradeEvidencePolicyDigestV1, RadrootsTradeEvidenceScopePrerequisitesV1, 2281 RadrootsTradeEvidenceSourceCompletionV1, RadrootsTradeEvidenceSourceIdV1, 2282 RadrootsTradeEvidenceSourceRequirementV1, RadrootsTradeEvidenceSourceResultDigestV1, 2283 RadrootsTradeEvidenceSourceResultV1, 2284 }; 2285 2286 assert_eq!( 2287 FfiTradeEvidenceCoverage::from( 2288 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Missing 2289 ), 2290 FfiTradeEvidenceCoverage::Missing 2291 ); 2292 assert_eq!( 2293 FfiTradeEvidenceCoverage::from( 2294 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Partial 2295 ), 2296 FfiTradeEvidenceCoverage::Partial 2297 ); 2298 assert_eq!( 2299 FfiTradeEvidenceCoverage::from( 2300 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::ScopeSatisfied 2301 ), 2302 FfiTradeEvidenceCoverage::ScopeSatisfied 2303 ); 2304 assert_eq!( 2305 FfiTradeEvidenceCoverage::from( 2306 radroots_sdk::trade::RadrootsTradeEvidenceCoverageV1::Unsupported 2307 ), 2308 FfiTradeEvidenceCoverage::Unsupported 2309 ); 2310 assert_eq!( 2311 FfiTradeEvidenceOutcome::from( 2312 radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Valid 2313 ), 2314 FfiTradeEvidenceOutcome::Valid 2315 ); 2316 assert_eq!( 2317 FfiTradeEvidenceOutcome::from( 2318 radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Invalid 2319 ), 2320 FfiTradeEvidenceOutcome::Invalid 2321 ); 2322 assert_eq!( 2323 FfiTradeEvidenceOutcome::from( 2324 radroots_sdk::trade::RadrootsTradeEvidenceOutcomeV1::Indeterminate 2325 ), 2326 FfiTradeEvidenceOutcome::Indeterminate 2327 ); 2328 2329 let source = RadrootsTradeEvidenceManifestSourceResultV1::new( 2330 RadrootsTradeEvidenceSourceIdV1::parse("typed_source").expect("source id"), 2331 RadrootsTradeEvidenceSourceResultV1::new( 2332 RadrootsTradeEvidenceSourceRequirementV1::Required, 2333 RadrootsTradeEvidenceSourceCompletionV1::Complete, 2334 0, 2335 ) 2336 .expect("source result"), 2337 RadrootsTradeEvidenceSourceResultDigestV1::from_bytes([0x33; 32]), 2338 ); 2339 let manifest = RadrootsTradeEvidenceManifestV1::new( 2340 TradeId::from_bytes([0x11; 16]), 2341 NonZeroU64::new(1).expect("nonzero generation"), 2342 RadrootsTradeEvidencePolicyDigestV1::from_bytes([0x22; 32]), 2343 1_800_000_000, 2344 RadrootsTradeEvidenceScopePrerequisitesV1::Satisfied, 2345 [source], 2346 [], 2347 ) 2348 .expect("manifest"); 2349 let projected = parse_trade_evidence_manifest(manifest.canonical_bytes().to_vec()) 2350 .expect("manifest projection"); 2351 assert_eq!(projected.coverage, FfiTradeEvidenceCoverage::ScopeSatisfied); 2352 assert_eq!( 2353 projected.canonical_bytes_hex, 2354 hex::encode(manifest.canonical_bytes()) 2355 ); 2356 2357 let decisions: serde_json::Value = serde_json::from_str(include_str!( 2358 "../../../test-fixtures/tera_ffi/evidence_attestation_decision.v1.json" 2359 )) 2360 .expect("RHI decision corpus"); 2361 let superseding = decisions["vectors"] 2362 .as_array() 2363 .expect("RHI decision vectors") 2364 .iter() 2365 .find(|entry| entry["id"] == "rhi_evidence_attestation_superseding_002") 2366 .expect("superseding vector"); 2367 let report = parse_rhi_evidence_report( 2368 superseding["expected"]["canonical_event_content_utf8"] 2369 .as_str() 2370 .expect("canonical event content") 2371 .to_owned(), 2372 ) 2373 .expect("superseding report"); 2374 assert_eq!(report.outcome, FfiTradeEvidenceOutcome::Valid); 2375 assert_eq!( 2376 report.supersedes_report_id.as_deref(), 2377 Some("7777777777777777777777777777777777777777777777777777777777777777") 2378 ); 2379 assert_eq!( 2380 report.supersedes_event_id.as_deref(), 2381 Some("8888888888888888888888888888888888888888888888888888888888888888") 2382 ); 2383 } 2384 2385 #[test] 2386 fn transport_policy_enums_map_every_closed_variant() { 2387 assert_eq!( 2388 FfiCancellationPolicy::PreservePublishedRequest.core(), 2389 Phase1CancellationPolicy::PreservePublishedRequest 2390 ); 2391 assert_eq!( 2392 FfiCancellationPolicy::LocalCooperative.core(), 2393 Phase1CancellationPolicy::LocalCooperative 2394 ); 2395 assert_eq!( 2396 radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Native), 2397 radroots_sdk::transport::BlossomHostKind::Native 2398 ); 2399 assert_eq!( 2400 radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::Simulator), 2401 radroots_sdk::transport::BlossomHostKind::Simulator 2402 ); 2403 assert_eq!( 2404 radroots_sdk::transport::BlossomHostKind::from(FfiBlossomHostKind::PhysicalDevice), 2405 radroots_sdk::transport::BlossomHostKind::PhysicalDevice 2406 ); 2407 } 2408 2409 #[test] 2410 fn evidence_errors_do_not_render_untrusted_content() { 2411 let secret = "mobile-private-evidence"; 2412 let error = parse_rhi_evidence_report(secret.to_owned()).expect_err("malformed report"); 2413 assert!(!error.to_string().contains(secret)); 2414 assert!(!format!("{error:?}").contains(secret)); 2415 } 2416 2417 fn png(width: u32, height: u32) -> Vec<u8> { 2418 let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); 2419 bytes.extend_from_slice(&width.to_be_bytes()); 2420 bytes.extend_from_slice(&height.to_be_bytes()); 2421 bytes 2422 } 2423 2424 fn blossom_slot() -> radroots_sdk::transport::BlossomSlot { 2425 let profile = radroots_sdk::transport::BlossomProfile::new( 2426 radroots_sdk::transport::BlossomHostKind::Simulator, 2427 radroots_sdk::transport::BlossomEndpointAuthority::LoopbackDevelopment, 2428 "http://127.0.0.1:3000", 2429 std::iter::empty::<&str>(), 2430 ) 2431 .unwrap(); 2432 let slot = radroots_sdk::transport::BlossomSlot::new(); 2433 slot.configure(radroots_sdk::transport::BlossomConfig::from_profile( 2434 profile, 2435 )) 2436 .unwrap(); 2437 slot 2438 } 2439 2440 fn photo_input(file_descriptor: u64, bytes: &[u8], digest: String) -> FfiAddDraftInput { 2441 FfiAddDraftInput { 2442 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2443 command_type: FfiAddCommandType::CreatePhotoUpdate, 2444 content: "Fresh carrots from this morning.".to_owned(), 2445 identifier: None, 2446 title: None, 2447 summary: None, 2448 location: None, 2449 event_timing: None, 2450 event_start_date: None, 2451 event_end_date: None, 2452 event_start_unix_s: None, 2453 event_end_unix_s: None, 2454 event_timezone: None, 2455 price_amount: None, 2456 currency: None, 2457 unit: None, 2458 quantity: None, 2459 food_published_at_unix_s: None, 2460 food_status: None, 2461 media: vec![FfiPreparedMediaInput { 2462 schema_version: PREPARED_MEDIA_FFI_SCHEMA_VERSION, 2463 opaque_reference: "media:carrots-01".to_owned(), 2464 file: std::sync::Arc::new( 2465 FfiMediaFile::new(file_descriptor, bytes.len() as u64).expect("admitted file"), 2466 ), 2467 sha256: digest, 2468 media_type: "image/png".to_owned(), 2469 byte_size: bytes.len() as u64, 2470 width: 2, 2471 height: 2, 2472 alt: "A basket of carrots".to_owned(), 2473 prepared_at_unix_s: 1_800_000_000, 2474 }], 2475 } 2476 } 2477 2478 fn text_input(command_type: FfiAddCommandType) -> FfiAddDraftInput { 2479 FfiAddDraftInput { 2480 schema_version: MOBILE_FFI_SCHEMA_VERSION, 2481 command_type, 2482 content: "Fresh from the farm".to_owned(), 2483 identifier: None, 2484 title: None, 2485 summary: None, 2486 location: None, 2487 event_timing: None, 2488 event_start_date: None, 2489 event_end_date: None, 2490 event_start_unix_s: None, 2491 event_end_unix_s: None, 2492 event_timezone: None, 2493 price_amount: None, 2494 currency: None, 2495 unit: None, 2496 quantity: None, 2497 food_published_at_unix_s: None, 2498 food_status: None, 2499 media: Vec::new(), 2500 } 2501 } 2502 2503 #[test] 2504 fn exact_five_add_inputs_build_their_typed_core_commands() { 2505 let (update, media) = text_input(FfiAddCommandType::CreateUpdate) 2506 .command_and_media(1_800_000_000, None) 2507 .expect("update"); 2508 assert!(matches!(update, Phase1AddCommand::CreateUpdate(_))); 2509 assert!(media.is_empty()); 2510 2511 let (ask, media) = text_input(FfiAddCommandType::CreateAsk) 2512 .command_and_media(1_800_000_000, None) 2513 .expect("ask"); 2514 assert!(matches!(ask, Phase1AddCommand::CreateAsk(_))); 2515 assert!(media.is_empty()); 2516 2517 let mut all_day = text_input(FfiAddCommandType::CreateEvent); 2518 all_day.identifier = Some("market-day".to_owned()); 2519 all_day.title = Some("Farmers market".to_owned()); 2520 all_day.location = Some("Town square".to_owned()); 2521 all_day.event_timing = Some(FfiEventTimingKind::AllDay); 2522 all_day.event_start_date = Some("2026-08-08".to_owned()); 2523 all_day.event_end_date = Some("2026-08-09".to_owned()); 2524 let (event, media) = all_day 2525 .command_and_media(1_800_000_000, None) 2526 .expect("all-day event"); 2527 assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); 2528 assert!(media.is_empty()); 2529 2530 let mut timed = text_input(FfiAddCommandType::CreateEvent); 2531 timed.identifier = Some("harvest-tour".to_owned()); 2532 timed.title = Some("Harvest tour".to_owned()); 2533 timed.event_timing = Some(FfiEventTimingKind::Timed); 2534 timed.event_start_unix_s = Some(1_800_000_000); 2535 timed.event_end_unix_s = Some(1_800_003_600); 2536 timed.event_timezone = Some("America/Vancouver".to_owned()); 2537 let (event, media) = timed 2538 .command_and_media(1_800_000_000, None) 2539 .expect("timed event"); 2540 assert!(matches!(event, Phase1AddCommand::CreateEvent(_))); 2541 assert!(media.is_empty()); 2542 2543 let bytes = png(2, 2); 2544 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2545 file.write_all(&bytes).expect("write media"); 2546 file.flush().expect("flush media"); 2547 let digest = Sha256::digest(&bytes).to_hex(); 2548 let mut food = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); 2549 food.command_type = FfiAddCommandType::CreateFoodAvailability; 2550 food.identifier = Some("carrots-2026-08".to_owned()); 2551 food.title = Some("Carrots".to_owned()); 2552 food.summary = Some("Fresh bunches".to_owned()); 2553 food.location = Some("Victoria".to_owned()); 2554 food.price_amount = Some("4.5".to_owned()); 2555 food.currency = Some("CAD".to_owned()); 2556 food.unit = Some("bunch".to_owned()); 2557 food.quantity = Some("12".to_owned()); 2558 food.food_status = Some("active".to_owned()); 2559 let blossom = blossom_slot(); 2560 let (food, media) = food 2561 .command_and_media(1_800_000_000, Some(&blossom)) 2562 .expect("food availability"); 2563 assert!(matches!(food, Phase1AddCommand::CreateFoodAvailability(_))); 2564 assert_eq!(media.len(), 1); 2565 } 2566 2567 #[test] 2568 fn add_validation_reports_schema_shape_media_and_required_field_failures() { 2569 let mut wrong_schema = text_input(FfiAddCommandType::CreateUpdate); 2570 wrong_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION + 1; 2571 assert_eq!( 2572 wrong_schema 2573 .command_and_media(1_800_000_000, None) 2574 .expect_err("schema") 2575 .report() 2576 .code, 2577 "unsupported_schema_version" 2578 ); 2579 assert_eq!( 2580 text_input(FfiAddCommandType::CreateUpdate) 2581 .command_and_media(0, None) 2582 .expect_err("authored time") 2583 .report() 2584 .code, 2585 "invalid_add_draft" 2586 ); 2587 assert_eq!( 2588 text_input(FfiAddCommandType::CreateEvent) 2589 .command_and_media(1_800_000_000, None) 2590 .expect_err("event identity") 2591 .report() 2592 .code, 2593 "event_identifier_required" 2594 ); 2595 let mut food = text_input(FfiAddCommandType::CreateFoodAvailability); 2596 food.unit = Some("crate".to_owned()); 2597 assert_eq!( 2598 food.command_and_media(1_800_000_000, None) 2599 .expect_err("food unit") 2600 .report() 2601 .code, 2602 "invalid_food_unit" 2603 ); 2604 } 2605 2606 #[test] 2607 fn prepared_media_accepts_only_the_exact_bounded_file_descriptor_bytes() { 2608 let bytes = png(2, 2); 2609 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2610 file.write_all(&bytes).expect("write media"); 2611 file.flush().expect("flush media"); 2612 let digest = Sha256::digest(&bytes).to_hex(); 2613 let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest.clone()); 2614 let blossom = blossom_slot(); 2615 2616 let (command, media) = input 2617 .command_and_media(1_800_000_000, Some(&blossom)) 2618 .expect("verified media input"); 2619 assert!(matches!(command, Phase1AddCommand::CreatePhotoUpdate(_))); 2620 assert_eq!(media.len(), 1); 2621 assert_eq!( 2622 media[0].url(), 2623 format!("http://127.0.0.1:3000/{digest}.png") 2624 ); 2625 assert_eq!( 2626 file.as_file().metadata().expect("caller-owned media").len(), 2627 bytes.len() as u64 2628 ); 2629 } 2630 2631 #[test] 2632 fn prepared_media_rejects_file_descriptors_outside_the_platform_range() { 2633 assert_eq!( 2634 FfiMediaFile::new(u64::MAX, 1) 2635 .expect_err("out-of-range descriptor") 2636 .report() 2637 .code, 2638 "media_handle_unavailable" 2639 ); 2640 } 2641 2642 #[cfg(unix)] 2643 #[test] 2644 fn prepared_media_rejects_unavailable_in_range_file_descriptors() { 2645 assert_eq!( 2646 FfiMediaFile::new(i32::MAX as u64, 1) 2647 .expect_err("unavailable in-range descriptor") 2648 .report() 2649 .code, 2650 "media_handle_unavailable" 2651 ); 2652 } 2653 2654 #[test] 2655 fn prepared_media_rejects_digest_tamper_and_path_like_references() { 2656 let bytes = png(2, 2); 2657 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2658 file.write_all(&bytes).expect("write media"); 2659 file.flush().expect("flush media"); 2660 2661 let tampered = photo_input( 2662 file.as_file().as_raw_fd() as u64, 2663 &bytes, 2664 Sha256::digest(b"other").to_hex(), 2665 ); 2666 let blossom = blossom_slot(); 2667 assert_eq!( 2668 tampered 2669 .command_and_media(1_800_000_000, Some(&blossom)) 2670 .expect_err("digest mismatch") 2671 .report() 2672 .code, 2673 "media_verification_failed" 2674 ); 2675 2676 let mut path_like = photo_input( 2677 file.as_file().as_raw_fd() as u64, 2678 &bytes, 2679 Sha256::digest(&bytes).to_hex(), 2680 ); 2681 path_like.media[0].opaque_reference = "file:/private/media.jpg".to_owned(); 2682 assert_eq!( 2683 path_like 2684 .command_and_media(1_800_000_000, Some(&blossom)) 2685 .expect_err("path-like reference") 2686 .report() 2687 .code, 2688 "invalid_media_reference" 2689 ); 2690 } 2691 2692 #[test] 2693 fn prepared_media_constructs_a_bounded_verified_upload_request() { 2694 let bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x02"; 2695 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2696 file.write_all(bytes).expect("write media"); 2697 file.flush().expect("flush media"); 2698 let digest = Sha256::digest(bytes).to_hex(); 2699 let mut input = photo_input(file.as_file().as_raw_fd() as u64, bytes, digest.clone()); 2700 input.media[0].media_type = "image/png".to_owned(); 2701 2702 let prepared = PreparedMedia::try_from(input.media.remove(0)).expect("prepared media"); 2703 let request = prepared 2704 .upload_request(1_800_000_000_000) 2705 .expect("bounded upload request"); 2706 assert_eq!(request.sha256().to_hex(), digest); 2707 assert_eq!(request.byte_size(), bytes.len() as u64); 2708 assert_eq!(request.media_type().as_str(), "image/png"); 2709 assert_eq!(request.dimensions().width(), 2); 2710 assert_eq!(request.dimensions().height(), 2); 2711 } 2712 2713 #[test] 2714 fn media_validation_executes_every_bounded_shape_guard() { 2715 let bytes = png(2, 2); 2716 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2717 file.write_all(&bytes).expect("write media"); 2718 file.flush().expect("flush media"); 2719 let digest = Sha256::digest(&bytes).to_hex(); 2720 let valid = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest).media[0].clone(); 2721 2722 let mut old_schema = valid.clone(); 2723 old_schema.schema_version = MOBILE_FFI_SCHEMA_VERSION; 2724 assert_eq!( 2725 PreparedMedia::try_from(old_schema) 2726 .err() 2727 .expect("borrowed schema rejected") 2728 .report() 2729 .code, 2730 "unsupported_schema_version" 2731 ); 2732 2733 let mut invalid_values = Vec::new(); 2734 let mut value = valid.clone(); 2735 value.byte_size = 0; 2736 invalid_values.push(value); 2737 let mut value = valid.clone(); 2738 value.byte_size = MEDIA_FILE_MAX_BYTES + 1; 2739 invalid_values.push(value); 2740 let mut value = valid.clone(); 2741 value.width = 0; 2742 invalid_values.push(value); 2743 let mut value = valid.clone(); 2744 value.height = 0; 2745 invalid_values.push(value); 2746 let mut value = valid.clone(); 2747 value.prepared_at_unix_s = 0; 2748 invalid_values.push(value); 2749 let mut value = valid.clone(); 2750 value.alt = " ".to_owned(); 2751 invalid_values.push(value); 2752 let mut value = valid.clone(); 2753 value.alt = "a".repeat(1_025); 2754 invalid_values.push(value); 2755 2756 for value in invalid_values { 2757 assert_eq!( 2758 PreparedMedia::try_from(value) 2759 .err() 2760 .expect("invalid bounded media") 2761 .report() 2762 .code, 2763 "invalid_media_reference" 2764 ); 2765 } 2766 2767 let mut wrong_size = valid.clone(); 2768 wrong_size.byte_size += 1; 2769 assert_eq!( 2770 PreparedMedia::try_from(wrong_size) 2771 .err() 2772 .expect("descriptor size") 2773 .report() 2774 .code, 2775 "media_size_mismatch" 2776 ); 2777 for reference in ["media:", "Media:item", "media:BAD", "media:item/path"] { 2778 assert!(!opaque_media_reference_is_valid(reference)); 2779 } 2780 assert!(opaque_media_reference_is_valid("media:item_01-a")); 2781 assert!(!opaque_media_reference_is_valid(&format!( 2782 "media:{}", 2783 "a".repeat(MEDIA_REFERENCE_MAX_BYTES) 2784 ))); 2785 } 2786 2787 #[test] 2788 fn event_and_post_optional_branches_remain_strict_and_complete() { 2789 let mut minimal_date = text_input(FfiAddCommandType::CreateEvent); 2790 minimal_date.content.clear(); 2791 minimal_date.identifier = Some("minimal-date".to_owned()); 2792 minimal_date.title = Some("Minimal date".to_owned()); 2793 minimal_date.event_timing = Some(FfiEventTimingKind::AllDay); 2794 minimal_date.event_start_date = Some("2026-08-08".to_owned()); 2795 assert!(minimal_date.command_and_media(1_800_000_000, None).is_ok()); 2796 2797 let mut minimal_time = text_input(FfiAddCommandType::CreateEvent); 2798 minimal_time.content.clear(); 2799 minimal_time.identifier = Some("minimal-time".to_owned()); 2800 minimal_time.title = Some("Minimal time".to_owned()); 2801 minimal_time.event_timing = Some(FfiEventTimingKind::Timed); 2802 minimal_time.event_start_unix_s = Some(1_800_000_000); 2803 assert!(minimal_time.command_and_media(1_800_000_000, None).is_ok()); 2804 2805 let bytes = png(2, 2); 2806 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2807 file.write_all(&bytes).expect("write media"); 2808 file.flush().expect("flush media"); 2809 let digest = Sha256::digest(&bytes).to_hex(); 2810 let mut event = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); 2811 event.command_type = FfiAddCommandType::CreateEvent; 2812 event.identifier = Some("event-image".to_owned()); 2813 event.title = Some("Event image".to_owned()); 2814 event.event_timing = Some(FfiEventTimingKind::Timed); 2815 event.event_start_unix_s = Some(1_800_000_000); 2816 let blossom = blossom_slot(); 2817 assert!( 2818 event 2819 .clone() 2820 .command_and_media(1_800_000_000, Some(&blossom)) 2821 .is_ok() 2822 ); 2823 2824 let mut second = event.media[0].clone(); 2825 second.opaque_reference = "media:event-image-two".to_owned(); 2826 event.media.push(second); 2827 assert_eq!( 2828 event 2829 .command_and_media(1_800_000_000, Some(&blossom)) 2830 .expect_err("event media limit") 2831 .report() 2832 .code, 2833 "event_image_limit" 2834 ); 2835 } 2836 2837 #[test] 2838 fn content_references_and_identifier_decoding_cover_all_outcomes() { 2839 let bytes = png(2, 2); 2840 let mut file = tempfile::NamedTempFile::new().expect("media file"); 2841 file.write_all(&bytes).expect("write media"); 2842 file.flush().expect("flush media"); 2843 let digest = Sha256::digest(&bytes).to_hex(); 2844 let input = photo_input(file.as_file().as_raw_fd() as u64, &bytes, digest); 2845 let blossom = blossom_slot(); 2846 let prepared = PreparedMedia::try_from(input.media[0].clone()) 2847 .expect("prepared media") 2848 .bind(&blossom) 2849 .expect("bound media"); 2850 let url = prepared.descriptor.url().as_str().to_owned(); 2851 2852 assert_eq!( 2853 content_with_media_references(" ".to_owned(), std::slice::from_ref(&prepared)) 2854 .expect_err("blank content") 2855 .report() 2856 .code, 2857 "content_required" 2858 ); 2859 assert_eq!( 2860 content_with_media_references( 2861 format!("caption\n{url}"), 2862 std::slice::from_ref(&prepared) 2863 ) 2864 .expect("one existing reference") 2865 .match_indices(&url) 2866 .count(), 2867 1 2868 ); 2869 assert!( 2870 content_with_media_references("caption\n".to_owned(), std::slice::from_ref(&prepared)) 2871 .expect("newline append") 2872 .ends_with(&url) 2873 ); 2874 assert_eq!( 2875 content_with_media_references( 2876 format!("{url}\n{url}"), 2877 std::slice::from_ref(&prepared), 2878 ) 2879 .expect_err("duplicate reference") 2880 .report() 2881 .code, 2882 "duplicate_media_reference" 2883 ); 2884 2885 let mut update = text_input(FfiAddCommandType::CreateUpdate); 2886 update.media.push(input.media[0].clone()); 2887 assert_eq!( 2888 update 2889 .command_and_media(1_800_000_000, Some(&blossom)) 2890 .expect_err("update media") 2891 .report() 2892 .code, 2893 "media_not_allowed" 2894 ); 2895 let mut over_limit = text_input(FfiAddCommandType::CreateUpdate); 2896 over_limit.media = vec![input.media[0].clone(); 21]; 2897 assert_eq!( 2898 over_limit 2899 .command_and_media(1_800_000_000, None) 2900 .expect_err("media count") 2901 .report() 2902 .code, 2903 "invalid_add_draft" 2904 ); 2905 2906 assert_eq!(decode_id(&"01".repeat(16), "bad").expect("id"), [1; 16]); 2907 assert_eq!( 2908 decode_id("01", "bad").expect_err("short id").report().code, 2909 "bad" 2910 ); 2911 assert_eq!( 2912 decode_id(&"gg".repeat(16), "bad") 2913 .expect_err("non-hex id") 2914 .report() 2915 .code, 2916 "bad" 2917 ); 2918 } 2919 }