field_ios

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

operations.rs (34898B)


      1 //! Focused secret-safe operation records for settings, profile, revision, and inbound media.
      2 
      3 use tera_core::runtime::product_surface::{
      4     AddCommandType, BlossomEndpointAuthorityPreference, BlossomPreferences, IdentityCommand,
      5     IdentityLockState, IdentityRecord, IdentityState, LocalStoragePolicy, MediaNetworkPolicy,
      6     MobileNetworkEnvironment, MobileSettings, Phase1LocalMediaArtifact, Phase1MediaCacheStatus,
      7     Phase1ProfileStatus, Phase1RevisionTarget, ProfileMetadataCommand, RelayAccessPreference,
      8     RelayEndpointPreference, RelayPreferences, SettingsTransition, phase1_new_operation_id,
      9 };
     10 
     11 use crate::dto::PreparedMedia;
     12 use crate::{
     13     FfiAddDraftInput, FfiOperationSettlementRecord, FfiOutboxState, FfiPreparedMediaInput,
     14     MOBILE_FFI_SCHEMA_VERSION, TeraAppError,
     15 };
     16 
     17 impl From<crate::FfiAddCommandType> for AddCommandType {
     18     fn from(value: crate::FfiAddCommandType) -> Self {
     19         match value {
     20             crate::FfiAddCommandType::CreateUpdate => Self::CreateUpdate,
     21             crate::FfiAddCommandType::CreatePhotoUpdate => Self::CreatePhotoUpdate,
     22             crate::FfiAddCommandType::CreateAsk => Self::CreateAsk,
     23             crate::FfiAddCommandType::CreateEvent => Self::CreateEvent,
     24             crate::FfiAddCommandType::CreateFoodAvailability => Self::CreateFoodAvailability,
     25         }
     26     }
     27 }
     28 
     29 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
     30 pub enum FfiIdentityLockState {
     31     Locked,
     32     Unlocked,
     33 }
     34 
     35 impl From<IdentityLockState> for FfiIdentityLockState {
     36     fn from(value: IdentityLockState) -> Self {
     37         match value {
     38             IdentityLockState::Locked => Self::Locked,
     39             IdentityLockState::Unlocked => Self::Unlocked,
     40         }
     41     }
     42 }
     43 
     44 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     45 pub struct FfiSettingsIdentityRecord {
     46     pub schema_version: u16,
     47     pub id: String,
     48     pub public_key: String,
     49 }
     50 
     51 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     52 pub struct FfiIdentityStateRecord {
     53     pub schema_version: u16,
     54     pub identities: Vec<FfiSettingsIdentityRecord>,
     55     pub active_identity_id: Option<String>,
     56     pub lock_state: FfiIdentityLockState,
     57     pub pending_import_operation_id: Option<String>,
     58 }
     59 
     60 impl From<&IdentityState> for FfiIdentityStateRecord {
     61     fn from(value: &IdentityState) -> Self {
     62         Self {
     63             schema_version: MOBILE_FFI_SCHEMA_VERSION,
     64             identities: value
     65                 .identities()
     66                 .iter()
     67                 .map(|identity| FfiSettingsIdentityRecord {
     68                     schema_version: MOBILE_FFI_SCHEMA_VERSION,
     69                     id: identity.id().to_owned(),
     70                     public_key: identity.public_key_hex().to_owned(),
     71                 })
     72                 .collect(),
     73             active_identity_id: value.active_identity_id().map(str::to_owned),
     74             lock_state: value.lock_state().into(),
     75             pending_import_operation_id: value.pending_import_operation_id().map(str::to_owned),
     76         }
     77     }
     78 }
     79 
     80 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
     81 pub enum FfiIdentityCommandKind {
     82     BeginImport,
     83     CompleteImport,
     84     CancelImport,
     85     Select,
     86     Lock,
     87     Unlock,
     88     Recover,
     89 }
     90 
     91 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     92 pub struct FfiIdentityCommandRecord {
     93     pub schema_version: u16,
     94     pub kind: FfiIdentityCommandKind,
     95     pub operation_id: Option<String>,
     96     pub identity_id: Option<String>,
     97     pub public_key: Option<String>,
     98 }
     99 
    100 impl TryFrom<FfiIdentityCommandRecord> for IdentityCommand {
    101     type Error = TeraAppError;
    102 
    103     fn try_from(value: FfiIdentityCommandRecord) -> Result<Self, Self::Error> {
    104         require_schema(value.schema_version)?;
    105         let no_operation = value.operation_id.is_none();
    106         let no_identity = value.identity_id.is_none() && value.public_key.is_none();
    107         match value.kind {
    108             FfiIdentityCommandKind::BeginImport if no_identity => Ok(Self::BeginImport {
    109                 operation_id: required(value.operation_id, "identity_operation_id_required")?,
    110             }),
    111             FfiIdentityCommandKind::CompleteImport => {
    112                 let operation_id = required(value.operation_id, "identity_operation_id_required")?;
    113                 let identity_id = required(value.identity_id, "identity_id_required")?;
    114                 let public_key = required(value.public_key, "identity_public_key_required")?;
    115                 Ok(Self::CompleteImport {
    116                     operation_id,
    117                     identity: IdentityRecord::new(identity_id, &public_key)
    118                         .map_err(|error| TeraAppError::invalid_argument(error.code()))?,
    119                 })
    120             }
    121             FfiIdentityCommandKind::CancelImport if no_identity => Ok(Self::CancelImport {
    122                 operation_id: required(value.operation_id, "identity_operation_id_required")?,
    123             }),
    124             FfiIdentityCommandKind::Select if no_operation && value.public_key.is_none() => {
    125                 Ok(Self::Select {
    126                     identity_id: required(value.identity_id, "identity_id_required")?,
    127                 })
    128             }
    129             FfiIdentityCommandKind::Lock if no_operation && no_identity => Ok(Self::Lock),
    130             FfiIdentityCommandKind::Unlock if no_operation && no_identity => Ok(Self::Unlock),
    131             FfiIdentityCommandKind::Recover if no_operation && no_identity => Ok(Self::Recover),
    132             _ => Err(TeraAppError::invalid_argument("invalid_identity_command")),
    133         }
    134     }
    135 }
    136 
    137 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
    138 pub enum FfiMobileNetworkEnvironment {
    139     Public,
    140     Simulator,
    141     PhysicalDevice,
    142 }
    143 
    144 impl From<FfiMobileNetworkEnvironment> for MobileNetworkEnvironment {
    145     fn from(value: FfiMobileNetworkEnvironment) -> Self {
    146         match value {
    147             FfiMobileNetworkEnvironment::Public => Self::Public,
    148             FfiMobileNetworkEnvironment::Simulator => Self::Simulator,
    149             FfiMobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice,
    150         }
    151     }
    152 }
    153 
    154 impl From<MobileNetworkEnvironment> for FfiMobileNetworkEnvironment {
    155     fn from(value: MobileNetworkEnvironment) -> Self {
    156         match value {
    157             MobileNetworkEnvironment::Public => Self::Public,
    158             MobileNetworkEnvironment::Simulator => Self::Simulator,
    159             MobileNetworkEnvironment::PhysicalDevice => Self::PhysicalDevice,
    160         }
    161     }
    162 }
    163 
    164 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
    165 pub enum FfiRelayAccessPreference {
    166     ReadOnly,
    167     ReadWrite,
    168 }
    169 
    170 impl From<FfiRelayAccessPreference> for RelayAccessPreference {
    171     fn from(value: FfiRelayAccessPreference) -> Self {
    172         match value {
    173             FfiRelayAccessPreference::ReadOnly => Self::ReadOnly,
    174             FfiRelayAccessPreference::ReadWrite => Self::ReadWrite,
    175         }
    176     }
    177 }
    178 
    179 impl From<RelayAccessPreference> for FfiRelayAccessPreference {
    180     fn from(value: RelayAccessPreference) -> Self {
    181         match value {
    182             RelayAccessPreference::ReadOnly => Self::ReadOnly,
    183             RelayAccessPreference::ReadWrite => Self::ReadWrite,
    184         }
    185     }
    186 }
    187 
    188 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    189 pub struct FfiRelayPreferenceRecord {
    190     pub schema_version: u16,
    191     pub url: String,
    192     pub access: FfiRelayAccessPreference,
    193 }
    194 
    195 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    196 pub struct FfiRelayPreferencesRecord {
    197     pub schema_version: u16,
    198     pub environment: FfiMobileNetworkEnvironment,
    199     pub endpoints: Vec<FfiRelayPreferenceRecord>,
    200 }
    201 
    202 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
    203 pub enum FfiBlossomAuthorityPreference {
    204     PublicWebPki,
    205     LoopbackDevelopment,
    206     PrivateNetworkDevelopment,
    207 }
    208 
    209 impl From<FfiBlossomAuthorityPreference> for BlossomEndpointAuthorityPreference {
    210     fn from(value: FfiBlossomAuthorityPreference) -> Self {
    211         match value {
    212             FfiBlossomAuthorityPreference::PublicWebPki => Self::PublicWebPki,
    213             FfiBlossomAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment,
    214             FfiBlossomAuthorityPreference::PrivateNetworkDevelopment => {
    215                 Self::PrivateNetworkDevelopment
    216             }
    217         }
    218     }
    219 }
    220 
    221 impl From<BlossomEndpointAuthorityPreference> for FfiBlossomAuthorityPreference {
    222     fn from(value: BlossomEndpointAuthorityPreference) -> Self {
    223         match value {
    224             BlossomEndpointAuthorityPreference::PublicWebPki => Self::PublicWebPki,
    225             BlossomEndpointAuthorityPreference::LoopbackDevelopment => Self::LoopbackDevelopment,
    226             BlossomEndpointAuthorityPreference::PrivateNetworkDevelopment => {
    227                 Self::PrivateNetworkDevelopment
    228             }
    229         }
    230     }
    231 }
    232 
    233 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    234 pub struct FfiBlossomPreferencesRecord {
    235     pub schema_version: u16,
    236     pub environment: FfiMobileNetworkEnvironment,
    237     pub authority: FfiBlossomAuthorityPreference,
    238     pub primary_origin: String,
    239     pub fallback_origins: Vec<String>,
    240 }
    241 
    242 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
    243 pub struct FfiMediaNetworkPolicyRecord {
    244     pub schema_version: u16,
    245     pub allow_cellular_downloads: bool,
    246     pub allow_cellular_uploads: bool,
    247     pub allow_background_transfers: bool,
    248 }
    249 
    250 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Record)]
    251 pub struct FfiLocalStoragePolicyRecord {
    252     pub schema_version: u16,
    253     pub media_cache_bytes: u64,
    254     pub media_cache_artifacts: u32,
    255 }
    256 
    257 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    258 pub struct FfiMobileSettingsRecord {
    259     pub schema_version: u16,
    260     pub revision: u64,
    261     pub identity: FfiIdentityStateRecord,
    262     pub relays: FfiRelayPreferencesRecord,
    263     pub blossom: FfiBlossomPreferencesRecord,
    264     pub media_network: FfiMediaNetworkPolicyRecord,
    265     pub local_storage: FfiLocalStoragePolicyRecord,
    266 }
    267 
    268 impl From<&MobileSettings> for FfiMobileSettingsRecord {
    269     fn from(value: &MobileSettings) -> Self {
    270         Self {
    271             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    272             revision: value.revision(),
    273             identity: value.identity().into(),
    274             relays: FfiRelayPreferencesRecord {
    275                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    276                 environment: value.relays().environment().into(),
    277                 endpoints: value
    278                     .relays()
    279                     .endpoints()
    280                     .iter()
    281                     .map(|endpoint| FfiRelayPreferenceRecord {
    282                         schema_version: MOBILE_FFI_SCHEMA_VERSION,
    283                         url: endpoint.url().to_owned(),
    284                         access: endpoint.access().into(),
    285                     })
    286                     .collect(),
    287             },
    288             blossom: FfiBlossomPreferencesRecord {
    289                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    290                 environment: value.blossom().environment().into(),
    291                 authority: value.blossom().authority().into(),
    292                 primary_origin: value.blossom().primary_origin().to_owned(),
    293                 fallback_origins: value.blossom().fallback_origins().to_vec(),
    294             },
    295             media_network: FfiMediaNetworkPolicyRecord {
    296                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    297                 allow_cellular_downloads: value.media_network().allow_cellular_downloads(),
    298                 allow_cellular_uploads: value.media_network().allow_cellular_uploads(),
    299                 allow_background_transfers: value.media_network().allow_background_transfers(),
    300             },
    301             local_storage: FfiLocalStoragePolicyRecord {
    302                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    303                 media_cache_bytes: value.local_storage().media_cache_bytes(),
    304                 media_cache_artifacts: value.local_storage().media_cache_artifacts(),
    305             },
    306         }
    307     }
    308 }
    309 
    310 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    311 pub struct FfiReplaceSettingsRecord {
    312     pub schema_version: u16,
    313     pub expected_revision: u64,
    314     pub relays: FfiRelayPreferencesRecord,
    315     pub blossom: FfiBlossomPreferencesRecord,
    316     pub media_network: FfiMediaNetworkPolicyRecord,
    317     pub local_storage: FfiLocalStoragePolicyRecord,
    318 }
    319 
    320 impl FfiReplaceSettingsRecord {
    321     pub(crate) fn apply(self, current: MobileSettings) -> Result<MobileSettings, TeraAppError> {
    322         require_schema(self.schema_version)?;
    323         if current.revision() != self.expected_revision {
    324             return Err(TeraAppError::invalid_argument("settings_revision_conflict"));
    325         }
    326         require_schema(self.relays.schema_version)?;
    327         let relay_environment: MobileNetworkEnvironment = self.relays.environment.into();
    328         let relay_endpoints = self
    329             .relays
    330             .endpoints
    331             .into_iter()
    332             .map(|endpoint| {
    333                 require_schema(endpoint.schema_version)?;
    334                 RelayEndpointPreference::new(
    335                     relay_environment,
    336                     endpoint.url,
    337                     endpoint.access.into(),
    338                 )
    339                 .map_err(Into::into)
    340             })
    341             .collect::<Result<Vec<_>, TeraAppError>>()?;
    342         let relays = RelayPreferences::new(relay_environment, relay_endpoints)?;
    343 
    344         require_schema(self.blossom.schema_version)?;
    345         let blossom = BlossomPreferences::new(
    346             self.blossom.environment.into(),
    347             self.blossom.authority.into(),
    348             self.blossom.primary_origin,
    349             self.blossom.fallback_origins,
    350         )?;
    351         require_schema(self.media_network.schema_version)?;
    352         let media_network = MediaNetworkPolicy::new(
    353             self.media_network.allow_cellular_downloads,
    354             self.media_network.allow_cellular_uploads,
    355             self.media_network.allow_background_transfers,
    356         );
    357         require_schema(self.local_storage.schema_version)?;
    358         let local_storage = LocalStoragePolicy::new(
    359             self.local_storage.media_cache_bytes,
    360             self.local_storage.media_cache_artifacts,
    361         )?;
    362         Ok(current
    363             .with_relays(relays)
    364             .with_blossom(blossom)
    365             .with_media_network(media_network)
    366             .with_local_storage(local_storage))
    367     }
    368 }
    369 
    370 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    371 pub struct FfiSettingsTransitionRecord {
    372     pub schema_version: u16,
    373     pub settings: FfiMobileSettingsRecord,
    374     pub runtime_restart_required: bool,
    375     pub outbox_requeue_required: bool,
    376     pub media_cache_invalidation_required: bool,
    377 }
    378 
    379 impl From<SettingsTransition> for FfiSettingsTransitionRecord {
    380     fn from(value: SettingsTransition) -> Self {
    381         Self {
    382             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    383             settings: (&value.settings).into(),
    384             runtime_restart_required: value.runtime_restart_required,
    385             outbox_requeue_required: value.outbox_requeue_required,
    386             media_cache_invalidation_required: value.media_cache_invalidation_required,
    387         }
    388     }
    389 }
    390 
    391 #[derive(Clone, Debug, uniffi::Record)]
    392 pub struct FfiProfileMetadataInputRecord {
    393     pub schema_version: u16,
    394     pub name: String,
    395     pub display_name: Option<String>,
    396     pub about: Option<String>,
    397     pub picture: Option<FfiPreparedMediaInput>,
    398     pub banner: Option<FfiPreparedMediaInput>,
    399     pub nip05: Option<String>,
    400     pub bot: Option<bool>,
    401 }
    402 
    403 impl FfiProfileMetadataInputRecord {
    404     pub(crate) fn command(
    405         self,
    406         blossom: Option<&radroots_sdk::transport::BlossomSlot>,
    407     ) -> Result<ProfileMetadataCommand, TeraAppError> {
    408         require_schema(self.schema_version)?;
    409         let picture = self
    410             .picture
    411             .map(PreparedMedia::try_from)
    412             .transpose()?
    413             .map(|media| {
    414                 let blossom = blossom.ok_or_else(|| {
    415                     TeraAppError::failure(
    416                         "blossom_unconfigured",
    417                         "profile",
    418                         true,
    419                         &["configure_blossom"],
    420                         "Profile media configuration is unavailable.",
    421                     )
    422                 })?;
    423                 media.into_authored_image(blossom)
    424             })
    425             .transpose()?;
    426         let banner = self
    427             .banner
    428             .map(PreparedMedia::try_from)
    429             .transpose()?
    430             .map(|media| {
    431                 let blossom = blossom.ok_or_else(|| {
    432                     TeraAppError::failure(
    433                         "blossom_unconfigured",
    434                         "profile",
    435                         true,
    436                         &["configure_blossom"],
    437                         "Profile media configuration is unavailable.",
    438                     )
    439                 })?;
    440                 media.into_authored_image(blossom)
    441             })
    442             .transpose()?;
    443         ProfileMetadataCommand::new(
    444             self.name,
    445             self.display_name,
    446             self.about,
    447             picture,
    448             banner,
    449             self.nip05,
    450             self.bot,
    451         )
    452         .map_err(Into::into)
    453     }
    454 }
    455 
    456 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    457 pub struct FfiProfileStatusRecord {
    458     pub schema_version: u16,
    459     pub operation_id: String,
    460     pub revision: u64,
    461     pub author_public_key: String,
    462     pub state: FfiOutboxState,
    463     pub delivery_id: Option<String>,
    464     pub created_at_unix_ms: u64,
    465     pub updated_at_unix_ms: u64,
    466     pub settlement: Option<FfiOperationSettlementRecord>,
    467 }
    468 
    469 impl From<Phase1ProfileStatus> for FfiProfileStatusRecord {
    470     fn from(value: Phase1ProfileStatus) -> Self {
    471         let draft = value.draft();
    472         Self {
    473             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    474             operation_id: hex::encode(draft.draft_id().as_bytes()),
    475             revision: draft.revision().get(),
    476             author_public_key: hex::encode(draft.author()),
    477             state: value.state().into(),
    478             delivery_id: draft.operation_id().map(|id| hex::encode(id.as_bytes())),
    479             created_at_unix_ms: draft.created_at_unix_ms(),
    480             updated_at_unix_ms: draft.updated_at_unix_ms(),
    481             settlement: value.push().map(|push| push.settlement().into()),
    482         }
    483     }
    484 }
    485 
    486 #[derive(Clone, Debug, uniffi::Record)]
    487 pub struct FfiRevisionInputRecord {
    488     pub request_id: String,
    489     pub schema_version: u16,
    490     pub card_id: String,
    491     pub source_event_id: String,
    492     pub source_address: Option<String>,
    493     pub author_public_key: String,
    494     pub replacement: FfiAddDraftInput,
    495 }
    496 
    497 impl FfiRevisionInputRecord {
    498     pub(crate) fn target(&self) -> Result<Phase1RevisionTarget, TeraAppError> {
    499         require_schema(self.schema_version)?;
    500         Phase1RevisionTarget::from_source(
    501             self.replacement.command_type.into(),
    502             tera_core::runtime::product_surface::CardId::parse(&self.card_id)
    503                 .map_err(|_| TeraAppError::invalid_argument("invalid_card_id"))?,
    504             self.source_event_id.clone(),
    505             self.source_address.clone(),
    506             self.author_public_key.clone(),
    507         )
    508         .map_err(Into::into)
    509     }
    510 }
    511 
    512 #[derive(uniffi::Object)]
    513 pub struct FfiMediaOperation {
    514     operation_id: [u8; 16],
    515     cancellation: radroots_sdk::transport::BlossomCancellation,
    516     claimed: std::sync::atomic::AtomicBool,
    517 }
    518 
    519 #[uniffi::export]
    520 impl FfiMediaOperation {
    521     #[uniffi::constructor]
    522     pub fn new() -> Result<Self, TeraAppError> {
    523         Ok(Self {
    524             operation_id: phase1_new_operation_id().map_err(TeraAppError::from)?,
    525             cancellation: radroots_sdk::transport::BlossomCancellation::default(),
    526             claimed: std::sync::atomic::AtomicBool::new(false),
    527         })
    528     }
    529 
    530     pub fn operation_id(&self) -> String {
    531         hex::encode(self.operation_id)
    532     }
    533 
    534     pub fn cancel(&self) {
    535         self.cancellation.cancel();
    536     }
    537 
    538     pub fn is_cancelled(&self) -> bool {
    539         self.cancellation.is_cancelled()
    540     }
    541 }
    542 
    543 impl FfiMediaOperation {
    544     pub(crate) fn claim(&self) -> Result<(), TeraAppError> {
    545         self.claimed
    546             .compare_exchange(
    547                 false,
    548                 true,
    549                 std::sync::atomic::Ordering::AcqRel,
    550                 std::sync::atomic::Ordering::Acquire,
    551             )
    552             .map(|_| ())
    553             .map_err(|_| TeraAppError::invalid_argument("media_operation_already_used"))
    554     }
    555 
    556     pub(crate) const fn id(&self) -> [u8; 16] {
    557         self.operation_id
    558     }
    559 
    560     pub(crate) fn cancellation(&self) -> radroots_sdk::transport::BlossomCancellation {
    561         self.cancellation.clone()
    562     }
    563 }
    564 
    565 #[derive(Clone, Eq, PartialEq, uniffi::Record)]
    566 pub struct FfiVerifiedMediaArtifactRecord {
    567     pub schema_version: u16,
    568     pub operation_id: Option<String>,
    569     pub artifact_id: String,
    570     pub bytes: Vec<u8>,
    571     pub byte_size: u64,
    572     pub media_type: String,
    573     pub width: u32,
    574     pub height: u32,
    575 }
    576 
    577 impl std::fmt::Debug for FfiVerifiedMediaArtifactRecord {
    578     fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    579         formatter
    580             .debug_struct("FfiVerifiedMediaArtifactRecord")
    581             .field("schema_version", &self.schema_version)
    582             .field("operation_id", &self.operation_id)
    583             .field("artifact_id", &self.artifact_id)
    584             .field("bytes", &"<redacted>")
    585             .field("byte_size", &self.byte_size)
    586             .field("media_type", &self.media_type)
    587             .field("width", &self.width)
    588             .field("height", &self.height)
    589             .finish()
    590     }
    591 }
    592 
    593 impl FfiVerifiedMediaArtifactRecord {
    594     pub(crate) fn from_artifact(
    595         value: Phase1LocalMediaArtifact,
    596         operation_id: Option<String>,
    597     ) -> Self {
    598         Self {
    599             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    600             operation_id,
    601             artifact_id: value.artifact_id().to_hex(),
    602             bytes: value.bytes().to_vec(),
    603             byte_size: value.byte_size(),
    604             media_type: value.media_type().to_owned(),
    605             width: value.width(),
    606             height: value.height(),
    607         }
    608     }
    609 }
    610 
    611 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    612 pub struct FfiMediaCacheStatusRecord {
    613     pub schema_version: u16,
    614     pub artifact_count: u32,
    615     pub total_bytes: u64,
    616     pub configuration_fingerprint: Option<String>,
    617 }
    618 
    619 impl From<Phase1MediaCacheStatus> for FfiMediaCacheStatusRecord {
    620     fn from(value: Phase1MediaCacheStatus) -> Self {
    621         Self {
    622             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    623             artifact_count: value.artifacts,
    624             total_bytes: value.bytes,
    625             configuration_fingerprint: value.configuration.map(|value| value.to_hex()),
    626         }
    627     }
    628 }
    629 
    630 pub(crate) fn require_schema(schema_version: u16) -> Result<(), TeraAppError> {
    631     if schema_version == MOBILE_FFI_SCHEMA_VERSION {
    632         Ok(())
    633     } else {
    634         Err(TeraAppError::invalid_argument("unsupported_schema_version"))
    635     }
    636 }
    637 
    638 fn required(value: Option<String>, code: &'static str) -> Result<String, TeraAppError> {
    639     value.ok_or_else(|| TeraAppError::invalid_argument(code))
    640 }
    641 
    642 pub(crate) fn decode_artifact_id(
    643     value: &str,
    644 ) -> Result<tera_core::runtime::product_surface::Phase1MediaArtifactId, TeraAppError> {
    645     tera_core::runtime::product_surface::Phase1MediaArtifactId::parse(value)
    646         .map_err(|_| TeraAppError::invalid_argument("invalid_media_artifact_id"))
    647 }
    648 
    649 pub(crate) fn decode_configuration(
    650     value: &str,
    651 ) -> Result<tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint, TeraAppError>
    652 {
    653     tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint::parse(value)
    654         .map_err(|_| TeraAppError::invalid_argument("invalid_media_configuration"))
    655 }
    656 
    657 pub(crate) fn decode_reference_fingerprint(value: &str) -> Result<[u8; 32], TeraAppError> {
    658     let bytes = hex::decode(value)
    659         .map_err(|_| TeraAppError::invalid_argument("invalid_media_reference_fingerprint"))?;
    660     bytes
    661         .try_into()
    662         .map_err(|_| TeraAppError::invalid_argument("invalid_media_reference_fingerprint"))
    663 }
    664 
    665 #[cfg(test)]
    666 mod tests {
    667     use super::*;
    668     use tera_core::runtime::product_surface::Phase1MediaConfigurationFingerprint;
    669 
    670     const PUBLIC_KEY: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
    671 
    672     #[test]
    673     fn media_operation_can_be_claimed_exactly_once() {
    674         let operation = FfiMediaOperation::new().unwrap();
    675         operation.claim().unwrap();
    676         let error = operation.claim().unwrap_err();
    677         assert_eq!(error.report().code, "media_operation_already_used");
    678         assert_eq!(operation.operation_id().len(), 32);
    679     }
    680 
    681     #[test]
    682     fn verified_media_artifact_debug_never_exposes_renderable_bytes() {
    683         let artifact = FfiVerifiedMediaArtifactRecord {
    684             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    685             operation_id: None,
    686             artifact_id: "11".repeat(32),
    687             bytes: b"private farm image".to_vec(),
    688             byte_size: 18,
    689             media_type: "image/png".to_owned(),
    690             width: 1,
    691             height: 1,
    692         };
    693         let debug = format!("{artifact:?}");
    694         assert!(debug.contains("<redacted>"));
    695         assert!(!debug.contains("private farm image"));
    696     }
    697 
    698     #[test]
    699     fn identity_commands_reject_fields_outside_the_selected_variant() {
    700         let error = IdentityCommand::try_from(FfiIdentityCommandRecord {
    701             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    702             kind: FfiIdentityCommandKind::Lock,
    703             operation_id: Some("unexpected".to_owned()),
    704             identity_id: None,
    705             public_key: None,
    706         })
    707         .unwrap_err();
    708         assert_eq!(error.report().code, "invalid_identity_command");
    709     }
    710 
    711     #[test]
    712     fn boundary_enums_and_identity_commands_cover_the_closed_vocabularies() {
    713         for (ffi, core) in [
    714             (
    715                 crate::FfiAddCommandType::CreateUpdate,
    716                 AddCommandType::CreateUpdate,
    717             ),
    718             (
    719                 crate::FfiAddCommandType::CreatePhotoUpdate,
    720                 AddCommandType::CreatePhotoUpdate,
    721             ),
    722             (
    723                 crate::FfiAddCommandType::CreateAsk,
    724                 AddCommandType::CreateAsk,
    725             ),
    726             (
    727                 crate::FfiAddCommandType::CreateEvent,
    728                 AddCommandType::CreateEvent,
    729             ),
    730             (
    731                 crate::FfiAddCommandType::CreateFoodAvailability,
    732                 AddCommandType::CreateFoodAvailability,
    733             ),
    734         ] {
    735             assert_eq!(AddCommandType::from(ffi), core);
    736         }
    737         for environment in [
    738             FfiMobileNetworkEnvironment::Public,
    739             FfiMobileNetworkEnvironment::Simulator,
    740             FfiMobileNetworkEnvironment::PhysicalDevice,
    741         ] {
    742             assert_eq!(
    743                 FfiMobileNetworkEnvironment::from(MobileNetworkEnvironment::from(environment)),
    744                 environment
    745             );
    746         }
    747         for access in [
    748             FfiRelayAccessPreference::ReadOnly,
    749             FfiRelayAccessPreference::ReadWrite,
    750         ] {
    751             assert_eq!(
    752                 FfiRelayAccessPreference::from(RelayAccessPreference::from(access)),
    753                 access
    754             );
    755         }
    756         for authority in [
    757             FfiBlossomAuthorityPreference::PublicWebPki,
    758             FfiBlossomAuthorityPreference::LoopbackDevelopment,
    759             FfiBlossomAuthorityPreference::PrivateNetworkDevelopment,
    760         ] {
    761             assert_eq!(
    762                 FfiBlossomAuthorityPreference::from(BlossomEndpointAuthorityPreference::from(
    763                     authority
    764                 )),
    765                 authority
    766             );
    767         }
    768 
    769         let commands = [
    770             FfiIdentityCommandRecord {
    771                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    772                 kind: FfiIdentityCommandKind::BeginImport,
    773                 operation_id: Some("operation".to_owned()),
    774                 identity_id: None,
    775                 public_key: None,
    776             },
    777             FfiIdentityCommandRecord {
    778                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    779                 kind: FfiIdentityCommandKind::CompleteImport,
    780                 operation_id: Some("operation".to_owned()),
    781                 identity_id: Some("primary".to_owned()),
    782                 public_key: Some(PUBLIC_KEY.to_owned()),
    783             },
    784             FfiIdentityCommandRecord {
    785                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    786                 kind: FfiIdentityCommandKind::CancelImport,
    787                 operation_id: Some("operation".to_owned()),
    788                 identity_id: None,
    789                 public_key: None,
    790             },
    791             FfiIdentityCommandRecord {
    792                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    793                 kind: FfiIdentityCommandKind::Select,
    794                 operation_id: None,
    795                 identity_id: Some("primary".to_owned()),
    796                 public_key: None,
    797             },
    798             FfiIdentityCommandRecord {
    799                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    800                 kind: FfiIdentityCommandKind::Lock,
    801                 operation_id: None,
    802                 identity_id: None,
    803                 public_key: None,
    804             },
    805             FfiIdentityCommandRecord {
    806                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    807                 kind: FfiIdentityCommandKind::Unlock,
    808                 operation_id: None,
    809                 identity_id: None,
    810                 public_key: None,
    811             },
    812             FfiIdentityCommandRecord {
    813                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    814                 kind: FfiIdentityCommandKind::Recover,
    815                 operation_id: None,
    816                 identity_id: None,
    817                 public_key: None,
    818             },
    819         ];
    820         for command in commands {
    821             assert!(IdentityCommand::try_from(command).is_ok());
    822         }
    823         for command in [
    824             FfiIdentityCommandRecord {
    825                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    826                 kind: FfiIdentityCommandKind::BeginImport,
    827                 operation_id: Some("operation".to_owned()),
    828                 identity_id: Some("unexpected".to_owned()),
    829                 public_key: None,
    830             },
    831             FfiIdentityCommandRecord {
    832                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    833                 kind: FfiIdentityCommandKind::CancelImport,
    834                 operation_id: Some("operation".to_owned()),
    835                 identity_id: None,
    836                 public_key: Some(PUBLIC_KEY.to_owned()),
    837             },
    838             FfiIdentityCommandRecord {
    839                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    840                 kind: FfiIdentityCommandKind::Select,
    841                 operation_id: Some("unexpected".to_owned()),
    842                 identity_id: Some("primary".to_owned()),
    843                 public_key: None,
    844             },
    845             FfiIdentityCommandRecord {
    846                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    847                 kind: FfiIdentityCommandKind::Select,
    848                 operation_id: None,
    849                 identity_id: Some("primary".to_owned()),
    850                 public_key: Some(PUBLIC_KEY.to_owned()),
    851             },
    852             FfiIdentityCommandRecord {
    853                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    854                 kind: FfiIdentityCommandKind::Lock,
    855                 operation_id: Some("unexpected".to_owned()),
    856                 identity_id: None,
    857                 public_key: None,
    858             },
    859             FfiIdentityCommandRecord {
    860                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    861                 kind: FfiIdentityCommandKind::Unlock,
    862                 operation_id: None,
    863                 identity_id: Some("unexpected".to_owned()),
    864                 public_key: None,
    865             },
    866             FfiIdentityCommandRecord {
    867                 schema_version: MOBILE_FFI_SCHEMA_VERSION,
    868                 kind: FfiIdentityCommandKind::Recover,
    869                 operation_id: None,
    870                 identity_id: None,
    871                 public_key: Some(PUBLIC_KEY.to_owned()),
    872             },
    873         ] {
    874             assert!(IdentityCommand::try_from(command).is_err());
    875         }
    876         assert!(
    877             IdentityCommand::try_from(FfiIdentityCommandRecord {
    878                 schema_version: MOBILE_FFI_SCHEMA_VERSION + 1,
    879                 kind: FfiIdentityCommandKind::Lock,
    880                 operation_id: None,
    881                 identity_id: None,
    882                 public_key: None,
    883             })
    884             .is_err()
    885         );
    886 
    887         let identity = IdentityRecord::new("primary", PUBLIC_KEY).unwrap();
    888         for lock_state in [IdentityLockState::Locked, IdentityLockState::Unlocked] {
    889             let state = IdentityState::new(
    890                 vec![identity.clone()],
    891                 Some("primary".to_owned()),
    892                 lock_state,
    893                 None,
    894             )
    895             .unwrap();
    896             let record = FfiIdentityStateRecord::from(&state);
    897             assert_eq!(record.identities.len(), 1);
    898             assert_eq!(record.lock_state, lock_state.into());
    899         }
    900     }
    901 
    902     #[test]
    903     fn media_decoders_status_and_private_operation_accessors_are_exhaustive() {
    904         assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION).is_ok());
    905         assert!(require_schema(MOBILE_FFI_SCHEMA_VERSION + 1).is_err());
    906         for invalid in ["", "not-hex", "00"] {
    907             assert!(decode_artifact_id(invalid).is_err());
    908             assert!(decode_configuration(invalid).is_err());
    909             assert!(decode_reference_fingerprint(invalid).is_err());
    910         }
    911         let digest = "11".repeat(32);
    912         assert!(decode_artifact_id(&digest).is_ok());
    913         assert!(decode_configuration(&digest).is_ok());
    914         assert_eq!(decode_reference_fingerprint(&digest).unwrap(), [0x11; 32]);
    915 
    916         let configuration = Phase1MediaConfigurationFingerprint::new([7; 32]).unwrap();
    917         let status = FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus {
    918             artifacts: 2,
    919             bytes: 9,
    920             configuration: Some(configuration),
    921         });
    922         assert_eq!(status.artifact_count, 2);
    923         assert_eq!(status.total_bytes, 9);
    924         assert_eq!(status.configuration_fingerprint, Some("07".repeat(32)));
    925         assert!(
    926             FfiMediaCacheStatusRecord::from(Phase1MediaCacheStatus {
    927                 artifacts: 0,
    928                 bytes: 0,
    929                 configuration: None,
    930             })
    931             .configuration_fingerprint
    932             .is_none()
    933         );
    934 
    935         let operation = FfiMediaOperation::new().unwrap();
    936         assert_eq!(operation.id(), operation.operation_id);
    937         assert!(!operation.cancellation().is_cancelled());
    938         assert!(!operation.is_cancelled());
    939         operation.cancel();
    940         assert!(operation.is_cancelled());
    941         assert!(operation.cancellation().is_cancelled());
    942     }
    943 }