field_ios

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

composer.rs (14197B)


      1 //! Typed local editing boundary; no publishable plan, byte handle or raw database API.
      2 use crate::{FfiAddCommandType, FfiEventTimingKind, MOBILE_FFI_SCHEMA_VERSION, TeraAppError};
      3 use tera_core::runtime::product_surface::{
      4     AddCommandType, ComposerDraft, ComposerEditSequence, ComposerError, ComposerFormInput,
      5     ComposerId, ComposerListEntry, ComposerMediaInput, ComposerPage, ComposerPartialForm,
      6     ComposerRepairReason, ComposerRevision, ComposerSaveReceipt, ComposerScope, LocalNetworkId,
      7     Phase1DraftEventTiming,
      8 };
      9 
     10 mod error;
     11 
     12 /// Reserves only a random editing identity, without writing or acknowledging a save.
     13 #[cfg_attr(not(coverage_nightly), uniffi::export)]
     14 pub fn composer_reserve_id() -> Result<FfiComposerIdRecord, TeraAppError> {
     15     Ok(FfiComposerIdRecord {
     16         schema_version: MOBILE_FFI_SCHEMA_VERSION,
     17         id: hex::encode(ComposerId::generate()?.as_bytes()),
     18     })
     19 }
     20 
     21 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     22 pub struct FfiComposerIdRecord {
     23     pub schema_version: u16,
     24     pub id: String,
     25 }
     26 
     27 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
     28 pub struct FfiComposerScopeRecord {
     29     pub schema_version: u16,
     30     pub author_public_key: String,
     31     pub local_network_id: String,
     32 }
     33 
     34 impl TryFrom<FfiComposerScopeRecord> for ComposerScope {
     35     type Error = TeraAppError;
     36     fn try_from(value: FfiComposerScopeRecord) -> Result<Self, Self::Error> {
     37         version(value.schema_version)?;
     38         if value.author_public_key.len() != 64 || !canonical_hex(&value.author_public_key) {
     39             return Err(TeraAppError::invalid_argument("composer_scope_invalid"));
     40         }
     41         let author = value
     42             .author_public_key
     43             .parse()
     44             .map_err(|_| TeraAppError::invalid_argument("composer_scope_invalid"))?;
     45         let context = LocalNetworkId::new(value.local_network_id)
     46             .map_err(|_| TeraAppError::invalid_argument("composer_scope_invalid"))?;
     47         Ok(Self::new(author, context))
     48     }
     49 }
     50 
     51 impl From<&ComposerScope> for FfiComposerScopeRecord {
     52     fn from(value: &ComposerScope) -> Self {
     53         Self {
     54             schema_version: MOBILE_FFI_SCHEMA_VERSION,
     55             author_public_key: value.author().to_hex(),
     56             local_network_id: value.local_network().as_str().to_owned(),
     57         }
     58     }
     59 }
     60 
     61 /// Reference metadata only; neither a file handle nor proof of upload readiness.
     62 #[derive(Clone, Eq, PartialEq, uniffi::Record)]
     63 pub struct FfiComposerMediaRecord {
     64     pub schema_version: u16,
     65     pub opaque_reference: String,
     66     pub sha256: String,
     67     pub media_type: String,
     68     pub byte_size: u64,
     69     pub width: u32,
     70     pub height: u32,
     71     pub alt: String,
     72     pub prepared_at_unix_s: u64,
     73 }
     74 
     75 #[derive(Clone, Eq, PartialEq, uniffi::Record)]
     76 pub struct FfiComposerFormRecord {
     77     pub schema_version: u16,
     78     pub command_type: FfiAddCommandType,
     79     pub content: String,
     80     pub identifier: Option<String>,
     81     pub title: Option<String>,
     82     pub summary: Option<String>,
     83     pub location: Option<String>,
     84     pub event_timing: Option<FfiEventTimingKind>,
     85     pub event_start_date: Option<String>,
     86     pub event_end_date: Option<String>,
     87     pub event_start_unix_s: Option<u64>,
     88     pub event_end_unix_s: Option<u64>,
     89     pub event_timezone: Option<String>,
     90     pub price_amount: Option<String>,
     91     pub currency: Option<String>,
     92     pub unit: Option<String>,
     93     pub quantity: Option<String>,
     94     pub food_published_at_unix_s: Option<u64>,
     95     pub food_status: Option<String>,
     96     pub media: Vec<FfiComposerMediaRecord>,
     97 }
     98 
     99 impl TryFrom<FfiComposerFormRecord> for ComposerPartialForm {
    100     type Error = TeraAppError;
    101     fn try_from(value: FfiComposerFormRecord) -> Result<Self, Self::Error> {
    102         version(value.schema_version)?;
    103         let input = ComposerFormInput {
    104             command_type: match value.command_type {
    105                 FfiAddCommandType::CreateUpdate => AddCommandType::CreateUpdate,
    106                 FfiAddCommandType::CreatePhotoUpdate => AddCommandType::CreatePhotoUpdate,
    107                 FfiAddCommandType::CreateAsk => AddCommandType::CreateAsk,
    108                 FfiAddCommandType::CreateEvent => AddCommandType::CreateEvent,
    109                 FfiAddCommandType::CreateFoodAvailability => AddCommandType::CreateFoodAvailability,
    110             },
    111             content: value.content,
    112             identifier: value.identifier,
    113             title: value.title,
    114             summary: value.summary,
    115             location: value.location,
    116             event_timing: value.event_timing.map(|timing| match timing {
    117                 FfiEventTimingKind::AllDay => Phase1DraftEventTiming::AllDay,
    118                 FfiEventTimingKind::Timed => Phase1DraftEventTiming::Timed,
    119             }),
    120             event_start_date: value.event_start_date,
    121             event_end_date: value.event_end_date,
    122             event_start_unix_s: value.event_start_unix_s,
    123             event_end_unix_s: value.event_end_unix_s,
    124             event_timezone: value.event_timezone,
    125             price_amount: value.price_amount,
    126             currency: value.currency,
    127             unit: value.unit,
    128             quantity: value.quantity,
    129             food_published_at_unix_s: value.food_published_at_unix_s,
    130             food_status: value.food_status,
    131             media: value
    132                 .media
    133                 .into_iter()
    134                 .map(TryInto::try_into)
    135                 .collect::<Result<_, _>>()?,
    136         };
    137         Self::new(input).map_err(Into::into)
    138     }
    139 }
    140 
    141 impl TryFrom<FfiComposerMediaRecord> for ComposerMediaInput {
    142     type Error = TeraAppError;
    143     fn try_from(value: FfiComposerMediaRecord) -> Result<Self, Self::Error> {
    144         version(value.schema_version)?;
    145         Ok(Self {
    146             opaque_reference: value.opaque_reference,
    147             sha256: value.sha256,
    148             media_type: value.media_type,
    149             byte_size: value.byte_size,
    150             width: value.width,
    151             height: value.height,
    152             alt: value.alt,
    153             prepared_at_unix_s: value.prepared_at_unix_s,
    154         })
    155     }
    156 }
    157 
    158 impl From<&ComposerPartialForm> for FfiComposerFormRecord {
    159     fn from(form: &ComposerPartialForm) -> Self {
    160         let value = form.input();
    161         Self {
    162             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    163             command_type: value.command_type.into(),
    164             content: value.content.clone(),
    165             identifier: value.identifier.clone(),
    166             title: value.title.clone(),
    167             summary: value.summary.clone(),
    168             location: value.location.clone(),
    169             event_timing: value.event_timing.map(|timing| match timing {
    170                 Phase1DraftEventTiming::AllDay => FfiEventTimingKind::AllDay,
    171                 Phase1DraftEventTiming::Timed => FfiEventTimingKind::Timed,
    172             }),
    173             event_start_date: value.event_start_date.clone(),
    174             event_end_date: value.event_end_date.clone(),
    175             event_start_unix_s: value.event_start_unix_s,
    176             event_end_unix_s: value.event_end_unix_s,
    177             event_timezone: value.event_timezone.clone(),
    178             price_amount: value.price_amount.clone(),
    179             currency: value.currency.clone(),
    180             unit: value.unit.clone(),
    181             quantity: value.quantity.clone(),
    182             food_published_at_unix_s: value.food_published_at_unix_s,
    183             food_status: value.food_status.clone(),
    184             media: value.media.iter().map(Into::into).collect(),
    185         }
    186     }
    187 }
    188 
    189 impl From<&ComposerMediaInput> for FfiComposerMediaRecord {
    190     fn from(value: &ComposerMediaInput) -> Self {
    191         Self {
    192             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    193             opaque_reference: value.opaque_reference.clone(),
    194             sha256: value.sha256.clone(),
    195             media_type: value.media_type.clone(),
    196             byte_size: value.byte_size,
    197             width: value.width,
    198             height: value.height,
    199             alt: value.alt.clone(),
    200             prepared_at_unix_s: value.prepared_at_unix_s,
    201         }
    202     }
    203 }
    204 
    205 #[derive(Clone, Eq, PartialEq, uniffi::Record)]
    206 pub struct FfiComposerSaveRequest {
    207     pub schema_version: u16,
    208     pub scope: FfiComposerScopeRecord,
    209     pub id: String,
    210     /// None creates a new composer; Some compares with the durable head.
    211     pub expected_revision: Option<u64>,
    212     pub edit_sequence: u64,
    213     pub form: FfiComposerFormRecord,
    214 }
    215 
    216 #[derive(Clone, Eq, PartialEq, uniffi::Record)]
    217 pub struct FfiComposerDraftRecord {
    218     pub schema_version: u16,
    219     pub scope: FfiComposerScopeRecord,
    220     pub id: String,
    221     pub revision: u64,
    222     pub edit_sequence: u64,
    223     pub form: FfiComposerFormRecord,
    224 }
    225 
    226 impl From<&ComposerDraft> for FfiComposerDraftRecord {
    227     fn from(value: &ComposerDraft) -> Self {
    228         Self {
    229             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    230             scope: value.scope().into(),
    231             id: hex::encode(value.id().as_bytes()),
    232             revision: value.revision().get(),
    233             edit_sequence: value.edit_sequence().get(),
    234             form: value.form().into(),
    235         }
    236     }
    237 }
    238 
    239 /// A historical owner-committed acknowledgment, not a promise that no later edit exists.
    240 #[derive(Clone, Eq, PartialEq, uniffi::Record)]
    241 pub struct FfiComposerSaveReceipt {
    242     pub schema_version: u16,
    243     pub draft: FfiComposerDraftRecord,
    244     pub replayed: bool,
    245 }
    246 
    247 impl From<&ComposerSaveReceipt> for FfiComposerSaveReceipt {
    248     fn from(value: &ComposerSaveReceipt) -> Self {
    249         Self {
    250             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    251             draft: value.draft().into(),
    252             replayed: value.is_replay(),
    253         }
    254     }
    255 }
    256 
    257 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    258 pub struct FfiComposerSummaryRecord {
    259     pub schema_version: u16,
    260     pub id: String,
    261     pub revision: u64,
    262     pub edit_sequence: u64,
    263     pub command_type: FfiAddCommandType,
    264     pub created_at_unix_ms: u64,
    265     pub updated_at_unix_ms: u64,
    266 }
    267 
    268 #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
    269 pub enum FfiComposerRepairReason {
    270     UnsupportedSchema,
    271     CorruptRecord,
    272 }
    273 
    274 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Enum)]
    275 pub enum FfiComposerListEntry {
    276     Draft {
    277         summary: FfiComposerSummaryRecord,
    278     },
    279     /// An opaque storage locator, including possibly invalid ID bytes. Never an editing ID.
    280     Repair {
    281         draft_key: String,
    282         revision: u64,
    283         reason: FfiComposerRepairReason,
    284     },
    285 }
    286 
    287 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
    288 pub struct FfiComposerPageRecord {
    289     pub schema_version: u16,
    290     pub scope: FfiComposerScopeRecord,
    291     pub entries: Vec<FfiComposerListEntry>,
    292     pub next_cursor: Option<String>,
    293 }
    294 
    295 impl From<&ComposerPage> for FfiComposerPageRecord {
    296     fn from(value: &ComposerPage) -> Self {
    297         Self {
    298             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    299             scope: value.scope().into(),
    300             entries: value
    301                 .entries()
    302                 .iter()
    303                 .map(|entry| match entry {
    304                     ComposerListEntry::Draft(summary) => FfiComposerListEntry::Draft {
    305                         summary: FfiComposerSummaryRecord {
    306                             schema_version: MOBILE_FFI_SCHEMA_VERSION,
    307                             id: hex::encode(summary.id().as_bytes()),
    308                             revision: summary.revision().get(),
    309                             edit_sequence: summary.edit_sequence().get(),
    310                             command_type: summary.command_type().into(),
    311                             created_at_unix_ms: summary.created_at_unix_ms(),
    312                             updated_at_unix_ms: summary.updated_at_unix_ms(),
    313                         },
    314                     },
    315                     ComposerListEntry::Repair {
    316                         draft_key,
    317                         revision,
    318                         reason,
    319                     } => FfiComposerListEntry::Repair {
    320                         draft_key: hex::encode(draft_key),
    321                         revision: *revision,
    322                         reason: match reason {
    323                             ComposerRepairReason::UnsupportedSchema => {
    324                                 FfiComposerRepairReason::UnsupportedSchema
    325                             }
    326                             ComposerRepairReason::CorruptRecord => {
    327                                 FfiComposerRepairReason::CorruptRecord
    328                             }
    329                         },
    330                     },
    331                 })
    332                 .collect(),
    333             next_cursor: value.next_cursor().map(str::to_owned),
    334         }
    335     }
    336 }
    337 
    338 pub(crate) fn version(value: u16) -> Result<(), TeraAppError> {
    339     if value != MOBILE_FFI_SCHEMA_VERSION {
    340         return Err(TeraAppError::invalid_argument(
    341             "composer_schema_unsupported",
    342         ));
    343     }
    344     Ok(())
    345 }
    346 
    347 fn canonical_hex(value: &str) -> bool {
    348     value
    349         .bytes()
    350         .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
    351 }
    352 
    353 pub(crate) fn id(value: &str) -> Result<ComposerId, TeraAppError> {
    354     if value.len() != 32 || !canonical_hex(value) {
    355         return Err(ComposerError::InvalidIdentity.into());
    356     }
    357     ComposerId::new(crate::decode_id(value, "composer_id_invalid")?).map_err(Into::into)
    358 }
    359 
    360 impl FfiComposerSaveRequest {
    361     pub(crate) fn validate_identity(
    362         &self,
    363     ) -> Result<(ComposerId, Option<ComposerRevision>, ComposerEditSequence), TeraAppError> {
    364         version(self.schema_version)?;
    365         Ok((
    366             id(&self.id)?,
    367             self.expected_revision
    368                 .map(ComposerRevision::new)
    369                 .transpose()?,
    370             ComposerEditSequence::new(self.edit_sequence)?,
    371         ))
    372     }
    373 }
    374 
    375 // Editing payloads must not leak into diagnostic formatting.
    376 macro_rules! redacted_debug {
    377     ($($name:ty),+ $(,)?) => {$(
    378         impl std::fmt::Debug for $name {
    379             fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    380                 formatter.debug_struct(stringify!($name)).finish_non_exhaustive()
    381             }
    382         }
    383     )+};
    384 }
    385 redacted_debug!(
    386     FfiComposerMediaRecord,
    387     FfiComposerFormRecord,
    388     FfiComposerSaveRequest,
    389     FfiComposerDraftRecord,
    390     FfiComposerSaveReceipt
    391 );