umsh_mobile_core/
lib.rs

1//! Stable, value-oriented facade for native UMSH mobile applications.
2//!
3//! This crate deliberately exposes application operations instead of mirroring
4//! the internal protocol crate graph. Platform bindings should wrap this API;
5//! mobile feature code should not depend on `umsh-core` directly.
6
7use std::{
8    fmt,
9    sync::{Arc, Mutex},
10};
11
12use lwuri::UriRef;
13use umsh_core::{AddressParseError, NodeHint, PublicKey, RouterHint};
14use umsh_crypto::{NodeIdentity, software::SoftwareIdentity};
15use umsh_uri::UmshUri;
16use zeroize::Zeroize;
17
18mod counter_store;
19mod mobile_chat;
20mod mobile_mesh;
21mod ulcp;
22
23pub use counter_store::{CounterStoreError, MobileCounterStore};
24pub use mobile_chat::{
25    MobileChatArchiveLookupRecord, MobileChatArchiveRecord, MobileChatArchiveResultKind,
26    MobileChatCheckpointRecord, MobileChatComposeBatchRecord, MobileChatDeliveryRecord,
27    MobileChatDeliveryState, MobileChatDirection, MobileChatMutationKind, MobileChatMutationRecord,
28    MobileChatOriginalRef,
29};
30pub use mobile_mesh::{
31    MobileMeshAdvertisementRecord, MobileMeshError, MobileMeshOutboundFrameRecord,
32    MobileMeshPeerHeardRecord, MobileMeshPingEventRecord, MobileMeshPingOutcome,
33    MobileMeshRouteKind, MobileMeshRouteRecord, MobileMeshRxRecord, MobileMeshSession,
34    MobileMeshSessionUpdateRecord,
35};
36pub use ulcp::{
37    GattSegmentRecord, MobileGattReassembler, MobileUlcpSession, UlcpAlertState, UlcpAttachMode,
38    UlcpBatteryRecord, UlcpChargeState, UlcpDeviceConfigRecord, UlcpFixKind, UlcpGnssRecord,
39    UlcpGnssSettingsRecord, UlcpHostOwnership, UlcpOperationErrorRecord, UlcpPropertyFrameRecord,
40    UlcpRadioSettingsRecord, UlcpReceivedFrameRecord, UlcpRepeaterSettingsRecord, UlcpSessionPhase,
41    UlcpSessionSnapshotRecord, UlcpSessionUpdateRecord, UlcpSyncRecord, UlcpTimeRecord,
42    inspect_ulcp_alert, inspect_ulcp_battery, inspect_ulcp_property_frame, inspect_ulcp_status,
43    inspect_ulcp_sync, region_code_description, region_code_from_string, ulcp_gatt_segments,
44    ulcp_inspection_properties, ulcp_location_cell_meters, ulcp_max_dev_channels,
45    ulcp_max_dev_peers, ulcp_prop_get, ulcp_prop_set, ulcp_save,
46};
47
48uniffi::setup_scaffolding!();
49
50/// Version of the mobile facade contract.
51///
52/// Increment this when a binding-visible operation, record, or error contract
53/// changes incompatibly. It is independent of the UMSH wire version.
54pub const MOBILE_API_VERSION: u16 = 39;
55
56/// Stable error categories consumed by platform adapters.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Error)]
58pub enum MobileError {
59    InvalidAddressLength,
60    InvalidAddressCharacter,
61    AddressOverflow,
62    InvalidNodeHintLength,
63    InvalidRouterHintLength,
64    InvalidSecretKeyLength,
65    InvalidPublicKeyLength,
66    InvalidUri,
67    InvalidIdentityData,
68    InvalidUlcpFrame,
69    InvalidGattSegment,
70    /// A tethering operation was attempted on an administrative session.
71    AdministrativeSession,
72    /// Text could not be read as a region code, or a supplied code was
73    /// not exactly two octets.
74    InvalidRegionCode,
75    /// The operation needs a capability this radio does not advertise.
76    UnsupportedCapability,
77    /// A public channel name contained a non-ASCII byte. Canonicalization is
78    /// an ASCII case fold, so such a name has no defined key.
79    ChannelNameNotAscii,
80    /// A public channel name exceeded the derivation input limit.
81    ChannelNameTooLong,
82    /// A channel key was not exactly 32 octets.
83    InvalidChannelKeyLength,
84}
85
86impl MobileError {
87    /// Stable localization key. Rust prose is never shown directly in the UI.
88    pub const fn summary_key(self) -> &'static str {
89        match self {
90            Self::InvalidAddressLength => "mobile.error.address.invalid_length",
91            Self::InvalidAddressCharacter => "mobile.error.address.invalid_character",
92            Self::AddressOverflow => "mobile.error.address.overflow",
93            Self::InvalidNodeHintLength => "mobile.error.node_hint.invalid_length",
94            Self::InvalidRouterHintLength => "mobile.error.router_hint.invalid_length",
95            Self::InvalidSecretKeyLength => "mobile.error.secret_key.invalid_length",
96            Self::InvalidPublicKeyLength => "mobile.error.public_key.invalid_length",
97            Self::InvalidUri => "mobile.error.uri.invalid",
98            Self::InvalidIdentityData => "mobile.error.identity_data.invalid",
99            Self::InvalidUlcpFrame => "mobile.error.ulcp.invalid_frame",
100            Self::InvalidGattSegment => "mobile.error.ulcp.invalid_gatt_segment",
101            Self::AdministrativeSession => "mobile.error.ulcp.administrative_session",
102            Self::InvalidRegionCode => "mobile.error.region_code.invalid",
103            Self::UnsupportedCapability => "mobile.error.ulcp.unsupported_capability",
104            Self::ChannelNameNotAscii => "mobile.error.channel_name.not_ascii",
105            Self::ChannelNameTooLong => "mobile.error.channel_name.too_long",
106            Self::InvalidChannelKeyLength => "mobile.error.channel_key.invalid_length",
107        }
108    }
109
110    /// Redacted diagnostic code suitable for logs and support bundles.
111    pub const fn diagnostic_code(self) -> &'static str {
112        match self {
113            Self::InvalidAddressLength => "ADDRESS_INVALID_LENGTH",
114            Self::InvalidAddressCharacter => "ADDRESS_INVALID_CHARACTER",
115            Self::AddressOverflow => "ADDRESS_OVERFLOW",
116            Self::InvalidNodeHintLength => "NODE_HINT_INVALID_LENGTH",
117            Self::InvalidRouterHintLength => "ROUTER_HINT_INVALID_LENGTH",
118            Self::InvalidSecretKeyLength => "SECRET_KEY_INVALID_LENGTH",
119            Self::InvalidPublicKeyLength => "PUBLIC_KEY_INVALID_LENGTH",
120            Self::InvalidUri => "URI_INVALID",
121            Self::InvalidIdentityData => "IDENTITY_DATA_INVALID",
122            Self::InvalidUlcpFrame => "ULCP_INVALID_FRAME",
123            Self::InvalidGattSegment => "ULCP_INVALID_GATT_SEGMENT",
124            Self::AdministrativeSession => "ULCP_ADMINISTRATIVE_SESSION",
125            Self::InvalidRegionCode => "REGION_CODE_INVALID",
126            Self::UnsupportedCapability => "ULCP_UNSUPPORTED_CAPABILITY",
127            Self::ChannelNameNotAscii => "CHANNEL_NAME_NOT_ASCII",
128            Self::ChannelNameTooLong => "CHANNEL_NAME_TOO_LONG",
129            Self::InvalidChannelKeyLength => "CHANNEL_KEY_INVALID_LENGTH",
130        }
131    }
132}
133
134impl fmt::Display for MobileError {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(self.diagnostic_code())
137    }
138}
139
140impl std::error::Error for MobileError {}
141
142impl From<AddressParseError> for MobileError {
143    fn from(value: AddressParseError) -> Self {
144        match value {
145            AddressParseError::InvalidLength => Self::InvalidAddressLength,
146            AddressParseError::InvalidCharacter => Self::InvalidAddressCharacter,
147            AddressParseError::Overflow => Self::AddressOverflow,
148        }
149    }
150}
151
152/// Canonical rendering information for a three-byte node hint.
153#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
154pub struct NodeHintRecord {
155    /// Raw hint bytes. Mobile UI uses these bytes as the avatar RGB fill.
156    pub bytes: Vec<u8>,
157    /// Canonical, possibly star-truncated text rendered by the Rust core.
158    pub text: String,
159}
160
161/// Canonical rendering information for a two-byte router hint.
162#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
163pub struct RouterHintRecord {
164    /// Raw hint bytes, matchable against the first two bytes of a known
165    /// node's public key.
166    pub bytes: Vec<u8>,
167    /// Canonical, possibly star-truncated text rendered by the Rust core.
168    pub text: String,
169}
170
171/// Public identity information safe to keep in ordinary application models.
172#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
173pub struct PublicIdentityRecord {
174    /// Exact canonical 44-character fixed-width Base58 address.
175    pub canonical_address: String,
176    pub hint: NodeHintRecord,
177}
178
179/// Authentication state of a standalone node-identity bundle.
180#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
181pub enum IdentitySignatureState {
182    /// No signature was attached; the claims are unauthenticated.
183    Unsigned,
184    /// The attached signature verifies against the node's public key.
185    Valid,
186    /// The attached signature does not verify; the claims must not be
187    /// presented as coming from the key's owner.
188    Invalid,
189}
190
191/// Decoded advertised node identity (node-identity.md), UI-safe fields only.
192#[derive(Clone, Debug, PartialEq, uniffi::Record)]
193pub struct NodeIdentityRecord {
194    pub role_code: u8,
195    /// Canonical English role label; UI may localize by `role_code`.
196    pub role_label: String,
197    /// Canonical capability labels in wire bit order.
198    pub capabilities: Vec<String>,
199    /// The same capabilities as the advertised bitfield, for filtering. Bits
200    /// this build has no label for are still set here.
201    pub capability_bits: u8,
202    pub name: Option<String>,
203    /// Center of the advertised location grid cell, in degrees.
204    pub latitude: Option<f64>,
205    pub longitude: Option<f64>,
206    /// Grid-code precision in bytes (1-7); larger is finer.
207    pub location_precision: Option<u8>,
208    pub altitude_m: Option<i32>,
209    /// Seconds since the Unix epoch (freshness marker).
210    pub timestamp: Option<u32>,
211    pub signature: IdentitySignatureState,
212}
213
214/// Safe, non-mutating preview of a parsed node URI.
215#[derive(Clone, Debug, PartialEq, uniffi::Record)]
216pub struct NodeUriPreviewRecord {
217    pub canonical_address: String,
218    pub hint: NodeHintRecord,
219    pub has_identity_data: bool,
220    /// Decoded identity bundle when the URI carried one that parses.
221    pub identity: Option<NodeIdentityRecord>,
222    /// Raw identity payload bytes suitable for persistence. Absent when the
223    /// bundle is unparseable or its signature fails verification, so callers
224    /// never store tampered claims.
225    pub identity_payload: Option<Vec<u8>>,
226}
227
228/// Parse a node URI locally and return only validated public identity fields.
229#[uniffi::export]
230pub fn inspect_node_uri(uri: String) -> Result<NodeUriPreviewRecord, MobileError> {
231    let reference = UriRef::from_str(&uri).map_err(|_| MobileError::InvalidUri)?;
232    let node = match umsh_uri::parse_umsh_uri(reference).map_err(|_| MobileError::InvalidUri)? {
233        UmshUri::Node(node) => node,
234        _ => return Err(MobileError::InvalidUri),
235    };
236    let identity = public_identity_record(&node.public_key);
237    let (decoded, payload) = match node.identity_data {
238        None => (None, None),
239        Some(data) => match umsh_uri::decode_base58_bytes(data).ok().and_then(|bytes| {
240            node_identity_record(&node.public_key, &bytes)
241                .ok()
242                .map(|record| (record, bytes))
243        }) {
244            None => (None, None),
245            Some((record, bytes)) => {
246                // Tampered bundles are shown as invalid but never persisted.
247                let payload =
248                    (record.signature != IdentitySignatureState::Invalid).then_some(bytes);
249                (Some(record), payload)
250            }
251        },
252    };
253    Ok(NodeUriPreviewRecord {
254        canonical_address: identity.canonical_address,
255        hint: identity.hint,
256        has_identity_data: node.identity_data.is_some(),
257        identity: decoded,
258        identity_payload: payload,
259    })
260}
261
262/// How a channel's key is established.
263#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
264pub enum ChannelKindRecord {
265    /// Key derived from a canonical ASCII name. Anyone who knows the name can
266    /// join, so the name is not a secret.
267    NamedPublic,
268    /// Key distributed out of band. Possession is membership.
269    PrivateKey,
270}
271
272/// Safe, non-mutating preview of a channel the user is about to join.
273///
274/// `key` is secret membership material for a [`ChannelKindRecord::PrivateKey`]
275/// channel: callers must store it with identity-key protection and must not
276/// place it in ordinary application records.
277#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)]
278pub struct ChannelPreviewRecord {
279    pub kind: ChannelKindRecord,
280    /// Canonicalized (ASCII-lowercased) name for a named channel. The UI
281    /// should show this when it differs from what the user typed, because it
282    /// — not the input — is what determines the key.
283    pub canonical_name: Option<String>,
284    /// The 32-octet channel key.
285    pub key: Vec<u8>,
286    /// The two-octet derived channel identifier. This is a hint, not an
287    /// identity: distinct keys may derive the same value, and receivers
288    /// resolve collisions by trial decryption.
289    pub channel_id: Vec<u8>,
290    /// Three octets for presentation — a deterministic colour for the
291    /// channel. The first two are the identifier above.
292    pub tint: Vec<u8>,
293    /// Suggested local display name from the invitation (`n=`), decoded.
294    pub display_name: Option<String>,
295    /// Recommended flood-hop ceiling from the invitation (`mh=`).
296    pub max_flood_hops: Option<u8>,
297    /// Recommended region from the invitation (`r=`), as a two-octet code.
298    /// Absent when the invitation named a region that does not parse.
299    pub region: Option<Vec<u8>>,
300}
301
302/// Parse a channel URI locally and derive its key and identifier.
303///
304/// Accepts `umsh:cs:` (named) and `umsh:ck:` (direct key). A node URI is
305/// rejected; [`inspect_node_uri`] is its counterpart.
306#[uniffi::export]
307pub fn inspect_channel_uri(uri: String) -> Result<ChannelPreviewRecord, MobileError> {
308    let reference = UriRef::from_str(&uri).map_err(|_| MobileError::InvalidUri)?;
309    match umsh_uri::parse_umsh_uri(reference).map_err(|_| MobileError::InvalidUri)? {
310        UmshUri::ChannelByName(parsed) => {
311            let name =
312                umsh_uri::decode_percent(parsed.name).map_err(|_| MobileError::InvalidUri)?;
313            let mut record = named_channel_preview(&name)?;
314            apply_channel_params(&mut record, &parsed.params)?;
315            Ok(record)
316        }
317        UmshUri::ChannelByKey(parsed) => {
318            let channel = umsh_node::Channel::private(parsed.key, "");
319            let mut record = ChannelPreviewRecord {
320                kind: ChannelKindRecord::PrivateKey,
321                canonical_name: None,
322                key: channel.key().0.to_vec(),
323                channel_id: channel.channel_id().0.to_vec(),
324                tint: channel_tint(channel.key()),
325                display_name: None,
326                max_flood_hops: None,
327                region: None,
328            };
329            apply_channel_params(&mut record, &parsed.params)?;
330            Ok(record)
331        }
332        UmshUri::Node(_) => Err(MobileError::InvalidUri),
333    }
334}
335
336/// Derive a public channel from a plain name, without a URI.
337///
338/// This is the join-by-name path. The returned `canonical_name` is the
339/// lowercase form the key is actually derived from.
340#[uniffi::export]
341pub fn inspect_channel_name(name: String) -> Result<ChannelPreviewRecord, MobileError> {
342    named_channel_preview(&name)
343}
344
345/// Generate a fresh 32-octet key for a new private channel.
346///
347/// Drawn from the same cryptographic generator the MAC uses for its own key
348/// material.
349#[uniffi::export]
350pub fn generate_channel_key() -> Vec<u8> {
351    use rand::Rng;
352
353    let mut key = [0u8; 32];
354    rand::rng().fill_bytes(&mut key);
355    let bytes = key.to_vec();
356    key.zeroize();
357    bytes
358}
359
360/// Derive the two-octet channel identifier for a key.
361///
362/// Used to match a locally held key against the identifiers a device reports,
363/// which never include key material.
364#[uniffi::export]
365pub fn derive_channel_id(key: Vec<u8>) -> Result<Vec<u8>, MobileError> {
366    let channel = umsh_node::Channel::private(channel_key_from_bytes(&key)?, "");
367    Ok(channel.channel_id().0.to_vec())
368}
369
370/// Derive the three presentation octets for a key — the identifier extended by
371/// one byte, so a channel's colour is stable wherever it is shown.
372#[uniffi::export]
373pub fn derive_channel_tint(key: Vec<u8>) -> Result<Vec<u8>, MobileError> {
374    Ok(channel_tint(&channel_key_from_bytes(&key)?))
375}
376
377fn channel_tint(key: &umsh_core::ChannelKey) -> Vec<u8> {
378    use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
379
380    umsh_crypto::CryptoEngine::new(SoftwareAes, SoftwareSha256)
381        .derive_channel_tint(key)
382        .to_vec()
383}
384
385/// The tag a channel's conversation is keyed by.
386///
387/// Same derivation as the identifier and the tint, run to sixteen bytes: wide
388/// enough that two channels can never share one, which the two-byte identifier
389/// cannot promise.
390pub(crate) fn channel_tag(key: &umsh_core::ChannelKey) -> umsh_core::ChannelTag {
391    use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
392
393    umsh_crypto::CryptoEngine::new(SoftwareAes, SoftwareSha256).derive_channel_tag(key)
394}
395
396/// The canonical name of the well-known `EMERGENCY` channel.
397pub(crate) const EMERGENCY_CHANNEL_NAME: &str = "emergency";
398
399/// The tag of the well-known `EMERGENCY` channel.
400///
401/// Emergency traffic is singled out by which channel carries it and by nothing
402/// on the wire, so recognizing the channel is the whole of the test. Derived
403/// from the name rather than written down as a literal, so it cannot drift
404/// from the derivation every other channel goes through.
405pub(crate) fn emergency_channel_tag() -> umsh_core::ChannelTag {
406    static TAG: std::sync::OnceLock<umsh_core::ChannelTag> = std::sync::OnceLock::new();
407
408    *TAG.get_or_init(|| {
409        channel_tag(
410            umsh_node::Channel::named(EMERGENCY_CHANNEL_NAME)
411                .expect("the emergency channel name is short ASCII")
412                .key(),
413        )
414    })
415}
416
417/// The address of a channel's group conversation, as the chat records carry it.
418///
419/// Exported so the platform never has to reproduce the derivation itself and
420/// risk disagreeing with the records it is matching against.
421#[uniffi::export]
422pub fn channel_conversation_address(key: Vec<u8>) -> Result<String, MobileError> {
423    Ok(crate::mobile_chat::channel_address(channel_tag(
424        &channel_key_from_bytes(&key)?,
425    )))
426}
427
428/// Build a shareable channel URI.
429///
430/// Supplying `name` produces the `umsh:cs:` form, which is not a secret;
431/// otherwise the `umsh:ck:` form is produced, which carries the channel key
432/// and grants full membership to anyone who receives it.
433#[uniffi::export]
434pub fn format_channel_invitation(
435    key: Vec<u8>,
436    name: Option<String>,
437    display_name: Option<String>,
438    max_flood_hops: Option<u8>,
439    region: Option<Vec<u8>>,
440) -> Result<String, MobileError> {
441    let region_text = match region {
442        None => None,
443        Some(code) => Some(ulcp::region_code_description(code)?),
444    };
445    let params = umsh_uri::ChannelParams {
446        display_name: display_name.as_deref(),
447        max_flood_hops,
448        region: region_text.as_deref(),
449        raw_query: None,
450    };
451
452    // Fixed-width scheme, key, and separators plus the escaped free-text
453    // parameters, each of which can triple in length.
454    let mut buf = alloc_uri_buffer(&params);
455    let written = match name.as_deref() {
456        Some(name) => umsh_uri::format_channel_name_uri_with_params(name, &params, &mut buf),
457        None => umsh_uri::format_channel_key_uri_with_params(
458            &channel_key_from_bytes(&key)?,
459            &params,
460            &mut buf,
461        ),
462    }
463    .map_err(|_| MobileError::InvalidUri)?;
464
465    String::from_utf8(buf[..written].to_vec()).map_err(|_| MobileError::InvalidUri)
466}
467
468fn alloc_uri_buffer(params: &umsh_uri::ChannelParams<'_>) -> Vec<u8> {
469    let escaped = |value: Option<&str>| value.map_or(0, |text| text.len() * 3);
470    let len = 128 + escaped(params.display_name) + escaped(params.region);
471    vec![0u8; len]
472}
473
474fn named_channel_preview(name: &str) -> Result<ChannelPreviewRecord, MobileError> {
475    let channel = umsh_node::Channel::named(name).map_err(|err| match err {
476        umsh_crypto::ChannelNameError::NotAscii => MobileError::ChannelNameNotAscii,
477        umsh_crypto::ChannelNameError::TooLong => MobileError::ChannelNameTooLong,
478    })?;
479    Ok(ChannelPreviewRecord {
480        kind: ChannelKindRecord::NamedPublic,
481        canonical_name: Some(name.to_ascii_lowercase()),
482        key: channel.key().0.to_vec(),
483        channel_id: channel.channel_id().0.to_vec(),
484        tint: channel_tint(channel.key()),
485        // Only the canonicalized form derives the key; the casing the name
486        // was written with is how people actually refer to the channel, so it
487        // is what a display name defaults to.
488        display_name: Some(name.to_owned()),
489        max_flood_hops: None,
490        region: None,
491    })
492}
493
494/// Overlay a URI's advisory parameters onto a preview.
495///
496/// These are recommendations from whoever wrote the invitation, not policy: a
497/// region that does not parse is dropped rather than failing the import.
498fn apply_channel_params(
499    record: &mut ChannelPreviewRecord,
500    params: &umsh_uri::ChannelParams<'_>,
501) -> Result<(), MobileError> {
502    if let Some(display_name) = params.display_name {
503        record.display_name =
504            Some(umsh_uri::decode_percent(display_name).map_err(|_| MobileError::InvalidUri)?);
505    }
506    record.max_flood_hops = params.max_flood_hops;
507    if let Some(region) = params.region {
508        record.region = umsh_uri::decode_percent(region)
509            .ok()
510            .and_then(|text| ulcp::region_code_from_string(text).ok());
511    }
512    Ok(())
513}
514
515fn channel_key_from_bytes(key: &[u8]) -> Result<umsh_core::ChannelKey, MobileError> {
516    let bytes: [u8; 32] = key
517        .try_into()
518        .map_err(|_| MobileError::InvalidChannelKeyLength)?;
519    Ok(umsh_core::ChannelKey(bytes))
520}
521
522/// Decode a persisted advertised-identity payload for display.
523///
524/// `address` is the canonical Base58 address of the node the payload claims
525/// to describe; the signature state is recomputed against it on every call.
526#[uniffi::export]
527pub fn decode_node_identity(
528    address: String,
529    payload: Vec<u8>,
530) -> Result<NodeIdentityRecord, MobileError> {
531    let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
532    node_identity_record(&key, &payload)
533}
534
535fn node_identity_record(
536    key: &PublicKey,
537    payload: &[u8],
538) -> Result<NodeIdentityRecord, MobileError> {
539    let identity = umsh_node::NodeIdentityPayload::from_bytes(payload)
540        .map_err(|_| MobileError::InvalidIdentityData)?;
541
542    let signature = match identity.signature {
543        None => IdentitySignatureState::Unsigned,
544        Some(signature) => {
545            // The signed range is everything before the trailing signature.
546            let signed = &payload[..payload.len() - 64];
547            if umsh_crypto::verify_ed25519_signature(key, signed, &signature) {
548                IdentitySignatureState::Valid
549            } else {
550                IdentitySignatureState::Invalid
551            }
552        }
553    };
554
555    let role_label = match identity.role {
556        umsh_node::NodeRole::Unspecified => "Unspecified".to_owned(),
557        umsh_node::NodeRole::Repeater => "Repeater".to_owned(),
558        umsh_node::NodeRole::Chat => "Chat".to_owned(),
559        umsh_node::NodeRole::Tracker => "Tracker".to_owned(),
560        umsh_node::NodeRole::Sensor => "Sensor".to_owned(),
561        umsh_node::NodeRole::Bridge => "Bridge".to_owned(),
562        umsh_node::NodeRole::ChatRoom => "Chat room".to_owned(),
563        umsh_node::NodeRole::TemporarySession => "Temporary session".to_owned(),
564        umsh_node::NodeRole::Unknown(code) => format!("Unknown ({code})"),
565    };
566
567    let capabilities = [
568        (umsh_node::NodeCapabilities::REPEATER, "Repeater"),
569        (umsh_node::NodeCapabilities::MOBILE, "Mobile"),
570        (umsh_node::NodeCapabilities::TEXT_MESSAGES, "Text messages"),
571        (umsh_node::NodeCapabilities::TELEMETRY, "Telemetry"),
572        (umsh_node::NodeCapabilities::CHAT_ROOM, "Chat room"),
573        (umsh_node::NodeCapabilities::COAP, "CoAP"),
574    ]
575    .into_iter()
576    .filter(|(bit, _)| identity.capabilities.contains(*bit))
577    .map(|(_, label)| label.to_owned())
578    .collect();
579    let capability_bits = identity.capabilities.bits();
580
581    let location = identity.location.filter(|loc| !loc.is_unspecified());
582    let (latitude, longitude, location_precision) = match location {
583        None => (None, None, None),
584        Some(location) => {
585            let (lat, lon) = location.center();
586            (
587                Some(f64::from(lat)),
588                Some(f64::from(lon)),
589                Some(location.precision()),
590            )
591        }
592    };
593
594    Ok(NodeIdentityRecord {
595        role_code: identity.role.as_byte(),
596        role_label,
597        capabilities,
598        capability_bits,
599        name: identity.name,
600        latitude,
601        longitude,
602        location_precision,
603        altitude_m: identity.altitude_m,
604        timestamp: identity.timestamp,
605        signature,
606    })
607}
608
609/// The claims a node makes about itself, as an identity bundle carries them.
610///
611/// The decoded counterpart is [`NodeIdentityRecord`]; this is the same
612/// statement on the way in, without the derived labels and the signature
613/// state, which are results of encoding rather than inputs to it.
614#[derive(Clone, Debug, PartialEq, uniffi::Record)]
615pub struct NodeIdentityProfileRecord {
616    pub role_code: u8,
617    pub capability_bits: u8,
618    pub name: Option<String>,
619    /// Centre of the disclosed cell. Reduced to `location_precision` during
620    /// encoding, so nothing finer than the stated cell reaches the bundle.
621    /// Latitude and longitude are only carried when both are present
622    /// alongside a precision.
623    pub latitude: Option<f64>,
624    pub longitude: Option<f64>,
625    /// Grid-code precision in bytes (1-7); larger is finer.
626    pub location_precision: Option<u8>,
627    pub altitude_m: Option<i32>,
628    pub timestamp: Option<u32>,
629}
630
631/// Build and sign a node-identity bundle from a supplied secret key.
632///
633/// The encoding inverse of [`decode_node_identity`], for composing bundles
634/// that describe a node other than the caller: fixtures, and the iOS app's
635/// staging mode, which needs nodes that report a location. The live
636/// advertisement path keeps a position out of the durable bundle it signs on
637/// purpose — a frozen position goes stale and then travels wherever the QR is
638/// pasted — so a located bundle cannot be obtained from it.
639///
640/// This grants nothing a holder of the secret key does not already have:
641/// signing a statement about a key is what holding that key means. The name is
642/// truncated to the same 24 octets the advertisement path applies, so a bundle
643/// built here describes a node that could have sent it.
644#[uniffi::export]
645pub async fn sign_node_identity_bundle(
646    mut secret_key: Vec<u8>,
647    profile: NodeIdentityProfileRecord,
648) -> Result<Vec<u8>, MobileError> {
649    let mut bytes: [u8; 32] = match secret_key.as_slice().try_into() {
650        Ok(bytes) => bytes,
651        Err(_) => {
652            secret_key.zeroize();
653            return Err(MobileError::InvalidSecretKeyLength);
654        }
655    };
656    secret_key.zeroize();
657    let signer = SoftwareIdentity::from_secret_bytes(&bytes);
658    bytes.zeroize();
659
660    let name = profile
661        .name
662        .map(|name| {
663            let mut end = name.len().min(24);
664            while !name.is_char_boundary(end) {
665                end -= 1;
666            }
667            name[..end].to_owned()
668        })
669        .filter(|name| !name.is_empty());
670
671    let location = match (
672        profile.latitude,
673        profile.longitude,
674        profile.location_precision,
675    ) {
676        (Some(latitude), Some(longitude), Some(precision)) => {
677            if !(1..=umsh_node::location::MAX_PRECISION).contains(&precision)
678                || !latitude.is_finite()
679                || latitude.abs() > 90.0
680                || !longitude.is_finite()
681                || longitude.abs() > 180.0
682            {
683                return Err(MobileError::InvalidIdentityData);
684            }
685            Some(umsh_node::location::NodeLocation::from_e7(
686                (latitude * 1e7).round() as i32,
687                (longitude * 1e7).round() as i32,
688                precision,
689            ))
690        }
691        _ => None,
692    };
693
694    let payload = umsh_node::NodeIdentityPayload {
695        role: umsh_node::NodeRole::from_byte(profile.role_code),
696        capabilities: umsh_node::NodeCapabilities::from_bits_truncate(profile.capability_bits),
697        name,
698        location,
699        altitude_m: profile.altitude_m,
700        timestamp: profile.timestamp,
701        supported_regions: None,
702        nonce: None,
703        signature: None,
704    };
705
706    let mut buf = [0u8; 192];
707    let len = payload
708        .encode_for_signing(&mut buf)
709        .map_err(|_| MobileError::InvalidIdentityData)?;
710    let signature = signer
711        .sign(&buf[..len])
712        .await
713        .map_err(|_| MobileError::InvalidIdentityData)?;
714    let mut bundle = buf[..len].to_vec();
715    bundle.extend_from_slice(&signature);
716    Ok(bundle)
717}
718
719/// Inspect a pasted peer identity in any user-facing interchange form.
720///
721/// Accepted input is a node URI, the canonical fixed-width Base58 public
722/// address, or exactly 32 public-key bytes written as hexadecimal (with an
723/// optional `0x` prefix). The result always returns the canonical Base58 form.
724#[uniffi::export]
725pub fn inspect_peer_identity(input: String) -> Result<NodeUriPreviewRecord, MobileError> {
726    let input = input.trim();
727    if input.starts_with("umsh:") {
728        return inspect_node_uri(input.to_owned());
729    }
730
731    let hex_input = input
732        .strip_prefix("0x")
733        .or_else(|| input.strip_prefix("0X"))
734        .unwrap_or(input);
735    let hex = hex_input
736        .chars()
737        .filter(|character| !character.is_ascii_whitespace() && !matches!(character, ':' | '-'))
738        .collect::<String>();
739    if hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
740        let mut public_key = [0u8; 32];
741        for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() {
742            let pair =
743                core::str::from_utf8(pair).map_err(|_| MobileError::InvalidAddressCharacter)?;
744            public_key[index] =
745                u8::from_str_radix(pair, 16).map_err(|_| MobileError::InvalidAddressCharacter)?;
746        }
747        let identity = public_identity_record(&PublicKey(public_key));
748        return Ok(NodeUriPreviewRecord {
749            canonical_address: identity.canonical_address,
750            hint: identity.hint,
751            has_identity_data: false,
752            identity: None,
753            identity_payload: None,
754        });
755    }
756
757    let identity = inspect_public_identity(input.to_owned())?;
758    Ok(NodeUriPreviewRecord {
759        canonical_address: identity.canonical_address,
760        hint: identity.hint,
761        has_identity_data: false,
762        identity: None,
763        identity_payload: None,
764    })
765}
766
767/// Render the canonical shareable node URI for a public identity address.
768///
769/// The result is the plain `umsh:n:` form without an appended identity
770/// bundle, suitable for copying and for QR presentation of the bare key.
771#[uniffi::export]
772pub fn node_uri_for_address(address: String) -> Result<String, MobileError> {
773    let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
774    let mut buf = [0u8; 64];
775    let len = umsh_uri::format_node_uri(&key, &mut buf).map_err(|_| MobileError::InvalidUri)?;
776    Ok(core::str::from_utf8(&buf[..len])
777        .expect("node URI is ASCII")
778        .to_owned())
779}
780
781/// Render the shareable node URI carrying a signed identity bundle:
782/// `umsh:n:<address>:<base58 bundle>`.
783#[uniffi::export]
784pub fn node_uri_with_identity(
785    address: String,
786    identity_payload: Vec<u8>,
787) -> Result<String, MobileError> {
788    let base = node_uri_for_address(address)?;
789    Ok(format!(
790        "{base}:{}",
791        umsh_uri::encode_base58_bytes(&identity_payload)
792    ))
793}
794
795/// Return the binding-visible mobile API version.
796#[uniffi::export]
797pub fn mobile_api_version() -> u16 {
798    MOBILE_API_VERSION
799}
800
801/// Render a node hint using the protocol's canonical ambiguity rules.
802#[uniffi::export]
803pub fn render_node_hint(bytes: Vec<u8>) -> Result<NodeHintRecord, MobileError> {
804    let bytes: [u8; 3] = bytes
805        .try_into()
806        .map_err(|_| MobileError::InvalidNodeHintLength)?;
807    Ok(render_node_hint_bytes(bytes))
808}
809
810/// Render a router hint using the protocol's canonical ambiguity rules.
811#[uniffi::export]
812pub fn render_router_hint(bytes: Vec<u8>) -> Result<RouterHintRecord, MobileError> {
813    let bytes: [u8; 2] = bytes
814        .try_into()
815        .map_err(|_| MobileError::InvalidRouterHintLength)?;
816    Ok(RouterHintRecord {
817        bytes: bytes.to_vec(),
818        text: RouterHint(bytes).to_string(),
819    })
820}
821
822fn render_node_hint_bytes(bytes: [u8; 3]) -> NodeHintRecord {
823    NodeHintRecord {
824        bytes: bytes.to_vec(),
825        text: NodeHint(bytes).to_string(),
826    }
827}
828
829/// Parse and canonicalize a complete public identity address.
830///
831/// The returned record contains public information only. Invalid input is not
832/// copied into the error, preventing accidental disclosure through diagnostics.
833#[uniffi::export]
834pub fn inspect_public_identity(address: String) -> Result<PublicIdentityRecord, MobileError> {
835    let key = PublicKey(umsh_core::base58::decode(address.as_bytes())?);
836    Ok(public_identity_record(&key))
837}
838
839/// Decode a canonical address to the raw public-key bytes carried by ULCP
840/// `PROP_HOST_KEY`.
841#[uniffi::export]
842pub fn public_identity_bytes(address: String) -> Result<Vec<u8>, MobileError> {
843    Ok(umsh_core::base58::decode(address.as_bytes())?.to_vec())
844}
845
846/// Inspect a raw 32-byte Ed25519 public identity received from a trusted wire
847/// decoder, returning the same canonical UI-safe representation as an address.
848#[uniffi::export]
849pub fn inspect_public_identity_bytes(
850    public_key: Vec<u8>,
851) -> Result<PublicIdentityRecord, MobileError> {
852    let bytes: [u8; 32] = public_key
853        .try_into()
854        .map_err(|_| MobileError::InvalidPublicKeyLength)?;
855    Ok(public_identity_record(&PublicKey(bytes)))
856}
857
858/// Identity secret retained by the Rust engine after one controlled unlock
859/// transfer. Swift receives only public identity records from this object.
860#[derive(uniffi::Object)]
861pub struct MobileIdentity {
862    identity: Mutex<Option<SoftwareIdentity>>,
863    public_identity: PublicIdentityRecord,
864}
865
866#[uniffi::export]
867impl MobileIdentity {
868    #[uniffi::constructor]
869    pub fn unlock(mut secret_key: Vec<u8>) -> Result<Arc<Self>, MobileError> {
870        let result = (|| {
871            let mut bytes: [u8; 32] = secret_key
872                .as_slice()
873                .try_into()
874                .map_err(|_| MobileError::InvalidSecretKeyLength)?;
875            let identity = SoftwareIdentity::from_secret_bytes(&bytes);
876            let public_identity = public_identity_record(identity.public_key());
877            bytes.zeroize();
878            Ok(Arc::new(Self {
879                identity: Mutex::new(Some(identity)),
880                public_identity,
881            }))
882        })();
883        secret_key.zeroize();
884        result
885    }
886
887    pub fn public_identity(&self) -> PublicIdentityRecord {
888        self.public_identity.clone()
889    }
890}
891
892impl MobileIdentity {
893    fn take_for_session(&self) -> Result<SoftwareIdentity, MobileMeshError> {
894        self.identity
895            .lock()
896            .map_err(|_| MobileMeshError::SessionUnavailable)?
897            .take()
898            .ok_or(MobileMeshError::SessionUnavailable)
899    }
900}
901
902fn public_identity_record(key: &PublicKey) -> PublicIdentityRecord {
903    let canonical_address = umsh_core::base58::encode(&key.0)
904        .into_iter()
905        .map(char::from)
906        .collect();
907
908    PublicIdentityRecord {
909        canonical_address,
910        hint: render_node_hint_bytes(NodeHint::from_public_key(&key).0),
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917
918    /// A signed bundle decodes back to the claims it was built from, with a
919    /// signature that verifies against the signing key's own address — the
920    /// property every consumer of a stored advertisement relies on.
921    #[tokio::test]
922    async fn signed_identity_bundle_round_trips_through_decoding() {
923        let secret = [0x42u8; 32];
924        let address = MobileIdentity::unlock(secret.to_vec())
925            .unwrap()
926            .public_identity()
927            .canonical_address;
928
929        let bundle = sign_node_identity_bundle(
930            secret.to_vec(),
931            NodeIdentityProfileRecord {
932                role_code: umsh_node::NodeRole::Repeater.as_byte(),
933                capability_bits: (umsh_node::NodeCapabilities::REPEATER
934                    | umsh_node::NodeCapabilities::TEXT_MESSAGES)
935                    .bits(),
936                name: Some("Ridge Repeater".to_owned()),
937                latitude: Some(37.7749),
938                longitude: Some(-122.4194),
939                location_precision: Some(5),
940                altitude_m: Some(1_204),
941                timestamp: Some(1_760_000_000),
942            },
943        )
944        .await
945        .unwrap();
946
947        let decoded = decode_node_identity(address, bundle).unwrap();
948        assert_eq!(decoded.signature, IdentitySignatureState::Valid);
949        assert_eq!(decoded.role_label, "Repeater");
950        assert_eq!(decoded.name.as_deref(), Some("Ridge Repeater"));
951        assert_eq!(decoded.altitude_m, Some(1_204));
952        assert_eq!(decoded.timestamp, Some(1_760_000_000));
953        assert_eq!(decoded.location_precision, Some(5));
954        // The cell the coordinates were reduced to is ~38 m across, so the
955        // decoded centre lands near the input rather than exactly on it.
956        assert!((decoded.latitude.unwrap() - 37.7749).abs() < 0.001);
957        assert!((decoded.longitude.unwrap() + 122.4194).abs() < 0.001);
958    }
959
960    /// A bundle signed by one key must not read as authentic for another.
961    #[tokio::test]
962    async fn signed_identity_bundle_does_not_verify_for_another_key() {
963        let bundle = sign_node_identity_bundle(
964            [0x42u8; 32].to_vec(),
965            NodeIdentityProfileRecord {
966                role_code: umsh_node::NodeRole::Tracker.as_byte(),
967                capability_bits: umsh_node::NodeCapabilities::MOBILE.bits(),
968                name: Some("Tracker".to_owned()),
969                latitude: None,
970                longitude: None,
971                location_precision: None,
972                altitude_m: None,
973                timestamp: None,
974            },
975        )
976        .await
977        .unwrap();
978
979        let impostor = MobileIdentity::unlock([0x43u8; 32].to_vec())
980            .unwrap()
981            .public_identity()
982            .canonical_address;
983        assert_eq!(
984            decode_node_identity(impostor, bundle).unwrap().signature,
985            IdentitySignatureState::Invalid
986        );
987    }
988
989    /// A precision outside the grid's range is refused rather than clamped:
990    /// a bundle claiming a cell size the protocol has no code for would
991    /// misstate how precise the position is.
992    #[tokio::test]
993    async fn signed_identity_bundle_rejects_out_of_range_location() {
994        let profile = NodeIdentityProfileRecord {
995            role_code: umsh_node::NodeRole::Tracker.as_byte(),
996            capability_bits: umsh_node::NodeCapabilities::MOBILE.bits(),
997            name: None,
998            latitude: Some(37.0),
999            longitude: Some(-122.0),
1000            location_precision: Some(9),
1001            altitude_m: None,
1002            timestamp: None,
1003        };
1004        assert_eq!(
1005            sign_node_identity_bundle([0x42u8; 32].to_vec(), profile)
1006                .await
1007                .unwrap_err(),
1008            MobileError::InvalidIdentityData
1009        );
1010    }
1011
1012    #[test]
1013    fn reference_node_hints_match_protocol_vectors() {
1014        for (bytes, expected) in [
1015            ([0x00, 0x00, 0x00], "1111"),
1016            ([0xFF, 0xFF, 0xFF], "JEKN"),
1017            ([0xA1, 0xB2, 0x03], "BtC5"),
1018            ([0x84, 0x81, 0x1B], "9v*"),
1019        ] {
1020            assert_eq!(
1021                render_node_hint(bytes.to_vec()).unwrap(),
1022                NodeHintRecord {
1023                    bytes: bytes.to_vec(),
1024                    text: expected.to_owned(),
1025                }
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn reference_router_hints_match_protocol_vectors() {
1032        for (bytes, expected) in [
1033            ([0x00, 0x00], "111"),
1034            ([0xA1, 0xB2], "BtC"),
1035            ([0x5E, 0xA1], "7N*"),
1036            ([0x00, 0x41], "1*"),
1037        ] {
1038            assert_eq!(
1039                render_router_hint(bytes.to_vec()).unwrap(),
1040                RouterHintRecord {
1041                    bytes: bytes.to_vec(),
1042                    text: expected.to_owned(),
1043                }
1044            );
1045        }
1046
1047        assert_eq!(
1048            render_router_hint(vec![0x00, 0x01, 0x02]),
1049            Err(MobileError::InvalidRouterHintLength)
1050        );
1051    }
1052
1053    #[test]
1054    fn public_identity_is_canonical_and_derives_hint_in_rust() {
1055        let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1056        let identity = inspect_public_identity(address.to_owned()).unwrap();
1057
1058        assert_eq!(identity.canonical_address, address);
1059        assert_eq!(identity.hint.bytes, [0, 1, 2]);
1060        assert_eq!(identity.hint.text, "111t");
1061    }
1062
1063    #[test]
1064    fn node_uri_preview_is_typed_and_non_mutating() {
1065        let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1066        let preview = inspect_node_uri(format!("umsh:n:{address}")).unwrap();
1067        assert_eq!(preview.canonical_address, address);
1068        assert!(!preview.has_identity_data);
1069
1070        let with_metadata = inspect_node_uri(format!("umsh:n:{address}:signed-data")).unwrap();
1071        assert!(with_metadata.has_identity_data);
1072        assert_eq!(
1073            inspect_node_uri("umsh:cs:public".to_owned()),
1074            Err(MobileError::InvalidUri)
1075        );
1076    }
1077
1078    #[test]
1079    fn channel_and_node_uri_inspection_reject_each_other() {
1080        let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1081        assert_eq!(
1082            inspect_channel_uri(format!("umsh:n:{address}")),
1083            Err(MobileError::InvalidUri)
1084        );
1085        assert!(inspect_channel_uri("umsh:cs:public".to_owned()).is_ok());
1086    }
1087
1088    #[test]
1089    fn named_channel_key_follows_the_canonical_lowercase_name() {
1090        let typed = inspect_channel_name("Public".to_owned()).unwrap();
1091        let canonical = inspect_channel_name("public".to_owned()).unwrap();
1092
1093        assert_eq!(typed.kind, ChannelKindRecord::NamedPublic);
1094        assert_eq!(typed.canonical_name.as_deref(), Some("public"));
1095        // The fold is what makes these one channel, so the UI can show the
1096        // canonical form as the thing actually being joined.
1097        assert_eq!(typed.key, canonical.key);
1098        assert_eq!(typed.channel_id, canonical.channel_id);
1099        assert_eq!(typed.key.len(), 32);
1100        assert_eq!(typed.channel_id.len(), 2);
1101    }
1102
1103    #[test]
1104    fn named_channel_uri_and_plain_name_agree() {
1105        let from_uri = inspect_channel_uri("umsh:cs:EMERGENCY".to_owned()).unwrap();
1106        let from_name = inspect_channel_name("EMERGENCY".to_owned()).unwrap();
1107        assert_eq!(from_uri.key, from_name.key);
1108        assert_eq!(from_uri.canonical_name.as_deref(), Some("emergency"));
1109    }
1110
1111    #[test]
1112    fn channel_names_outside_the_derivation_domain_are_typed_errors() {
1113        assert_eq!(
1114            inspect_channel_name("café".to_owned()),
1115            Err(MobileError::ChannelNameNotAscii)
1116        );
1117        assert_eq!(
1118            inspect_channel_name("x".repeat(65)),
1119            Err(MobileError::ChannelNameTooLong)
1120        );
1121    }
1122
1123    #[test]
1124    fn private_channel_invitation_round_trips_through_a_uri() {
1125        let key = generate_channel_key();
1126        let region = ulcp::region_code_from_string("SJC".to_owned()).unwrap();
1127        let uri = format_channel_invitation(
1128            key.clone(),
1129            None,
1130            Some("Trail Crew".to_owned()),
1131            Some(3),
1132            Some(region.clone()),
1133        )
1134        .unwrap();
1135        assert!(uri.starts_with("umsh:ck:"));
1136
1137        let preview = inspect_channel_uri(uri).unwrap();
1138        assert_eq!(preview.kind, ChannelKindRecord::PrivateKey);
1139        assert_eq!(preview.key, key);
1140        assert_eq!(preview.canonical_name, None);
1141        assert_eq!(preview.display_name.as_deref(), Some("Trail Crew"));
1142        assert_eq!(preview.max_flood_hops, Some(3));
1143        assert_eq!(preview.region, Some(region));
1144        assert_eq!(preview.channel_id, derive_channel_id(key).unwrap());
1145    }
1146
1147    #[test]
1148    fn named_channel_invitation_keeps_the_name_out_of_the_key_slot() {
1149        let key = inspect_channel_name("trail-crew".to_owned()).unwrap().key;
1150        let uri =
1151            format_channel_invitation(key.clone(), Some("trail-crew".to_owned()), None, None, None)
1152                .unwrap();
1153        assert_eq!(uri, "umsh:cs:trail-crew");
1154
1155        let preview = inspect_channel_uri(uri).unwrap();
1156        assert_eq!(preview.kind, ChannelKindRecord::NamedPublic);
1157        assert_eq!(preview.key, key);
1158    }
1159
1160    #[test]
1161    fn a_named_channel_invitation_carries_the_name_as_written() {
1162        // The name is a URI path segment, so anything outside the unreserved
1163        // set has to be escaped or the invitation will not parse back. Casing
1164        // travels as written; canonicalization happens after percent-decoding,
1165        // on the way in.
1166        let key = inspect_channel_name("Trail Crew".to_owned()).unwrap().key;
1167        let uri =
1168            format_channel_invitation(key.clone(), Some("Trail Crew".to_owned()), None, None, None)
1169                .unwrap();
1170        assert_eq!(uri, "umsh:cs:Trail%20Crew");
1171
1172        let back = inspect_channel_uri(uri).unwrap();
1173        assert_eq!(back.key, key);
1174        assert_eq!(back.canonical_name.as_deref(), Some("trail crew"));
1175        // …and the recipient still sees it written the way the sender wrote it.
1176        assert_eq!(back.display_name.as_deref(), Some("Trail Crew"));
1177    }
1178
1179    #[test]
1180    fn a_named_channel_preview_keeps_the_written_casing_beside_the_canonical_form() {
1181        let preview = inspect_channel_name("EMERGENCY".to_owned()).unwrap();
1182        assert_eq!(preview.canonical_name.as_deref(), Some("emergency"));
1183        assert_eq!(preview.display_name.as_deref(), Some("EMERGENCY"));
1184        // Same channel either way — only the folded form reaches the key.
1185        assert_eq!(
1186            preview.key,
1187            inspect_channel_name("emergency".to_owned()).unwrap().key
1188        );
1189    }
1190
1191    #[test]
1192    fn generated_channel_keys_are_distinct_and_full_length() {
1193        let first = generate_channel_key();
1194        let second = generate_channel_key();
1195        assert_eq!(first.len(), 32);
1196        assert_ne!(first, second);
1197        assert_ne!(first, vec![0u8; 32]);
1198    }
1199
1200    #[test]
1201    fn a_channels_tint_extends_its_identifier() {
1202        let preview = inspect_channel_name("Public".to_owned()).unwrap();
1203        assert_eq!(preview.tint.len(), 3);
1204        // The interface colours a channel from the tint and labels it with the
1205        // identifier; they have to agree about which channel they describe.
1206        assert_eq!(&preview.tint[..2], &preview.channel_id[..]);
1207        assert_eq!(derive_channel_tint(preview.key).unwrap(), preview.tint);
1208    }
1209
1210    #[test]
1211    fn channel_id_derivation_rejects_a_wrong_length_key() {
1212        assert_eq!(
1213            derive_channel_id(vec![0u8; 31]),
1214            Err(MobileError::InvalidChannelKeyLength)
1215        );
1216    }
1217
1218    #[test]
1219    fn an_unparseable_region_recommendation_does_not_fail_the_import() {
1220        // Advisory parameters are the sender's suggestion, not policy, so a
1221        // corrupted one is dropped rather than blocking the join.
1222        let address = umsh_uri::encode_channel_key_base58(&umsh_core::ChannelKey([0x21; 32]));
1223        let preview = inspect_channel_uri(format!("umsh:ck:{address}?n=Camp;r=0xZZZZ")).unwrap();
1224        assert_eq!(preview.display_name.as_deref(), Some("Camp"));
1225        assert_eq!(preview.region, None);
1226    }
1227
1228    #[test]
1229    fn a_region_named_rather_than_coded_survives_the_invitation_round_trip() {
1230        // Display of a hashed name is `0xXXXX`, which must read back as the
1231        // same code or a shared invitation would silently retarget.
1232        let key = generate_channel_key();
1233        let region = ulcp::region_code_from_string("Willamette Valley".to_owned()).unwrap();
1234        let uri = format_channel_invitation(key, None, None, None, Some(region.clone())).unwrap();
1235        assert_eq!(inspect_channel_uri(uri).unwrap().region, Some(region));
1236    }
1237
1238    #[tokio::test]
1239    async fn signed_identity_bundle_round_trips_through_uri_inspection() {
1240        let identity = SoftwareIdentity::from_secret_bytes(&[7u8; 32]);
1241        let payload = umsh_node::NodeIdentityPayload {
1242            role: umsh_node::NodeRole::Chat,
1243            capabilities: umsh_node::NodeCapabilities::TEXT_MESSAGES
1244                | umsh_node::NodeCapabilities::MOBILE,
1245            name: Some("Basecamp".into()),
1246            location: Some(umsh_node::location::NodeLocation::from_lat_lon(
1247                44.05, -123.09, 4,
1248            )),
1249            altitude_m: Some(72),
1250            timestamp: Some(1_760_000_000),
1251            supported_regions: None,
1252            nonce: None,
1253            signature: None,
1254        };
1255        let mut buf = [0u8; 256];
1256        let len = payload.encode_for_signing(&mut buf).unwrap();
1257        let signature = identity.sign(&buf[..len]).await.unwrap();
1258        buf[len..len + 64].copy_from_slice(&signature);
1259        let bundle = &buf[..len + 64];
1260
1261        let address = public_identity_record(identity.public_key()).canonical_address;
1262        let uri = format!("umsh:n:{address}:{}", umsh_uri::encode_base58_bytes(bundle));
1263        let preview = inspect_node_uri(uri).unwrap();
1264        assert!(preview.has_identity_data);
1265        assert_eq!(preview.identity_payload.as_deref(), Some(bundle));
1266        let record = preview.identity.unwrap();
1267        assert_eq!(record.signature, IdentitySignatureState::Valid);
1268        assert_eq!(record.role_label, "Chat");
1269        assert_eq!(record.name.as_deref(), Some("Basecamp"));
1270        assert_eq!(
1271            record.capabilities,
1272            vec!["Mobile".to_owned(), "Text messages".to_owned()]
1273        );
1274        assert_eq!(
1275            record.capability_bits,
1276            (umsh_node::NodeCapabilities::TEXT_MESSAGES | umsh_node::NodeCapabilities::MOBILE)
1277                .bits()
1278        );
1279        assert_eq!(record.altitude_m, Some(72));
1280        assert_eq!(record.timestamp, Some(1_760_000_000));
1281        assert_eq!(record.location_precision, Some(4));
1282        // A 4-byte grid cell is ~610 x 305 m; the center must land nearby.
1283        assert!((record.latitude.unwrap() - 44.05).abs() < 0.01);
1284        assert!((record.longitude.unwrap() + 123.09).abs() < 0.01);
1285
1286        // Tampering with the signed range must flag the bundle and withhold
1287        // the persistable payload.
1288        let mut tampered = bundle.to_vec();
1289        tampered[0] ^= 0x01;
1290        let uri = format!(
1291            "umsh:n:{address}:{}",
1292            umsh_uri::encode_base58_bytes(&tampered)
1293        );
1294        let preview = inspect_node_uri(uri).unwrap();
1295        assert_eq!(
1296            preview.identity.unwrap().signature,
1297            IdentitySignatureState::Invalid
1298        );
1299        assert!(preview.identity_payload.is_none());
1300
1301        // An unsigned bundle stays displayable and persistable, but is
1302        // explicitly marked unauthenticated.
1303        let unsigned_len = {
1304            let unsigned = umsh_node::NodeIdentityPayload {
1305                signature: None,
1306                ..payload.clone()
1307            };
1308            unsigned.encode(&mut buf).unwrap()
1309        };
1310        let uri = format!(
1311            "umsh:n:{address}:{}",
1312            umsh_uri::encode_base58_bytes(&buf[..unsigned_len])
1313        );
1314        let preview = inspect_node_uri(uri).unwrap();
1315        assert_eq!(
1316            preview.identity.unwrap().signature,
1317            IdentitySignatureState::Unsigned
1318        );
1319        assert!(preview.identity_payload.is_some());
1320    }
1321
1322    #[test]
1323    fn node_uri_round_trips_through_inspection() {
1324        let address = "111thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE";
1325        let uri = node_uri_for_address(address.to_owned()).unwrap();
1326
1327        assert_eq!(uri, format!("umsh:n:{address}"));
1328        let preview = inspect_node_uri(uri).unwrap();
1329        assert_eq!(preview.canonical_address, address);
1330    }
1331
1332    #[test]
1333    fn errors_are_stable_and_do_not_echo_input() {
1334        let error = inspect_public_identity("secret-ish-invalid-input".to_owned()).unwrap_err();
1335
1336        assert_eq!(error, MobileError::InvalidAddressLength);
1337        assert_eq!(error.summary_key(), "mobile.error.address.invalid_length");
1338        assert_eq!(error.to_string(), "ADDRESS_INVALID_LENGTH");
1339        assert!(!error.to_string().contains("secret-ish"));
1340    }
1341
1342    #[test]
1343    fn distinguishes_invalid_character_from_overflow() {
1344        let invalid = "11111111111111111111111111111111111111111110";
1345        assert_eq!(
1346            inspect_public_identity(invalid.to_owned()).unwrap_err(),
1347            MobileError::InvalidAddressCharacter
1348        );
1349
1350        assert_eq!(
1351            inspect_public_identity("z".repeat(44)).unwrap_err(),
1352            MobileError::AddressOverflow
1353        );
1354    }
1355
1356    #[test]
1357    fn binding_hint_input_requires_exactly_three_bytes() {
1358        let error = render_node_hint(vec![0, 1]).unwrap_err();
1359        assert_eq!(error, MobileError::InvalidNodeHintLength);
1360        assert_eq!(error.to_string(), "NODE_HINT_INVALID_LENGTH");
1361    }
1362
1363    #[test]
1364    fn raw_public_identity_uses_the_canonical_renderer() {
1365        let bytes: Vec<u8> = (0u8..32).collect();
1366        let identity = inspect_public_identity_bytes(bytes.clone()).unwrap();
1367        let address = umsh_core::base58::encode(&bytes.try_into().unwrap())
1368            .into_iter()
1369            .map(char::from)
1370            .collect::<String>();
1371        assert_eq!(identity, inspect_public_identity(address).unwrap());
1372        assert_eq!(
1373            inspect_public_identity_bytes(vec![0; 31]).unwrap_err(),
1374            MobileError::InvalidPublicKeyLength
1375        );
1376    }
1377
1378    #[test]
1379    fn public_identity_bytes_round_trip_canonical_address() {
1380        let bytes: Vec<u8> = (0u8..32).collect();
1381        let identity = inspect_public_identity_bytes(bytes.clone()).unwrap();
1382        assert_eq!(
1383            public_identity_bytes(identity.canonical_address).unwrap(),
1384            bytes
1385        );
1386    }
1387
1388    #[test]
1389    fn peer_identity_accepts_uri_base58_and_hex() {
1390        let key = [0xAB; 32];
1391        let canonical = public_identity_record(&PublicKey(key)).canonical_address;
1392        let hex = key
1393            .iter()
1394            .map(|byte| format!("{byte:02x}"))
1395            .collect::<String>();
1396        let colon_hex = key
1397            .iter()
1398            .map(|byte| format!("{byte:02x}"))
1399            .collect::<Vec<_>>()
1400            .join(":");
1401
1402        for input in [
1403            canonical.clone(),
1404            format!("0x{hex}"),
1405            colon_hex,
1406            format!("umsh:n:{canonical}"),
1407        ] {
1408            let preview = inspect_peer_identity(input).unwrap();
1409            assert_eq!(preview.canonical_address, canonical);
1410        }
1411    }
1412
1413    #[test]
1414    fn peer_identity_rejects_wrong_length_hex() {
1415        assert!(inspect_peer_identity("ab12".into()).is_err());
1416    }
1417
1418    #[test]
1419    fn secret_identity_derivation_returns_only_valid_public_material() {
1420        let identity = MobileIdentity::unlock(vec![7; 32])
1421            .unwrap()
1422            .public_identity();
1423        assert_eq!(identity.canonical_address.len(), 44);
1424        assert_eq!(
1425            inspect_public_identity(identity.canonical_address.clone()).unwrap(),
1426            identity
1427        );
1428
1429        assert!(matches!(
1430            MobileIdentity::unlock(vec![7; 31]),
1431            Err(MobileError::InvalidSecretKeyLength)
1432        ));
1433    }
1434}