Skip to main content

sccp_protocol/phone/xml/
image.rs

1//! Image phone XML document family.
2
3use super::*;
4
5/// Validated hexadecimal bitmap data used by image-service documents.
6///
7/// The public value is binary. Serde converts it to and from the XML Schema
8/// `hexBinary` lexical representation, accepting schema whitespace and either
9/// letter case while emitting one stable uppercase representation.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub struct PhoneBitmapData(Vec<u8>);
12
13impl PhoneBitmapData {
14    /// Validates decoded bitmap bytes against [`PHONE_IMAGE_BITMAP_MAX_BYTES`].
15    pub fn new(bytes: impl Into<Vec<u8>>) -> Result<Self, PhoneXmlError> {
16        let bytes = bytes.into();
17        validate_count(
18            "bitmap image data bytes",
19            bytes.len(),
20            PHONE_IMAGE_BITMAP_MAX_BYTES,
21        )?;
22        Ok(Self(bytes))
23    }
24
25    pub fn as_bytes(&self) -> &[u8] {
26        &self.0
27    }
28
29    pub fn into_bytes(self) -> Vec<u8> {
30        self.0
31    }
32}
33
34impl Serialize for PhoneBitmapData {
35    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
36    where
37        S: serde::Serializer,
38    {
39        const HEX: &[u8; 16] = b"0123456789ABCDEF";
40        let mut encoded = String::with_capacity(self.0.len().saturating_mul(2));
41        for byte in &self.0 {
42            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
43            encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
44        }
45        serializer.serialize_str(&encoded)
46    }
47}
48
49impl<'de> serde::Deserialize<'de> for PhoneBitmapData {
50    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
51    where
52        D: serde::Deserializer<'de>,
53    {
54        let encoded = <String as serde::Deserialize>::deserialize(deserializer)?;
55        let digits = encoded
56            .bytes()
57            .filter(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
58            .count();
59        let digit_count = encoded.len().saturating_sub(digits);
60        if digit_count % 2 != 0 {
61            return Err(serde::de::Error::custom(
62                "bitmap data must contain complete hexadecimal bytes",
63            ));
64        }
65        let mut decoded = Vec::with_capacity(digit_count / 2);
66        let mut high = None;
67        for byte in encoded
68            .bytes()
69            .filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
70        {
71            let value = match byte {
72                b'0'..=b'9' => byte - b'0',
73                b'a'..=b'f' => byte - b'a' + 10,
74                b'A'..=b'F' => byte - b'A' + 10,
75                _ => {
76                    return Err(serde::de::Error::custom("bitmap data must be hexadecimal"));
77                }
78            };
79            if let Some(high) = high.take() {
80                decoded.push((high << 4) | value);
81            } else {
82                high = Some(value);
83            }
84        }
85        Self::new(decoded).map_err(serde::de::Error::custom)
86    }
87}
88
89/// A schema-bounded image resource URL.
90#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
91#[serde(transparent)]
92pub struct PhoneImageUrl(String);
93
94impl PhoneImageUrl {
95    /// Validates a non-empty image URL of at most 256 characters.
96    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
97        let value = value.into();
98        validate_optional_text("phone image URL", Some(&value), 1, PHONE_XML_URL_MAX_CHARS)?;
99        Ok(Self(value))
100    }
101
102    pub fn as_str(&self) -> &str {
103        &self.0
104    }
105}
106
107impl<'de> serde::Deserialize<'de> for PhoneImageUrl {
108    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
109    where
110        D: serde::Deserializer<'de>,
111    {
112        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
113        Self::new(value).map_err(serde::de::Error::custom)
114    }
115}
116
117/// A TFTP URI accepted in a background-image selection list.
118///
119/// The phone's selection-list schema uses the opaque `TFTP:path` form.  The
120/// authority-bearing `tftp://host/path` form and HTTP URLs are deliberately
121/// rejected because they are not interchangeable on the handset.
122#[derive(Clone, Eq, Hash, PartialEq)]
123pub struct PhoneBackgroundTftpUrl(String);
124
125impl PhoneBackgroundTftpUrl {
126    /// Validates an opaque `TFTP:path` URI naming a PNG resource.
127    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
128        let value = value.into();
129        validate_optional_text(
130            "background image TFTP URI",
131            Some(&value),
132            1,
133            PHONE_XML_URL_MAX_CHARS,
134        )?;
135        let parsed = url::Url::parse(&value).map_err(|_| PhoneXmlError::InvalidField {
136            field: "background image TFTP URI",
137            expected: "a TFTP:path URI to a PNG image",
138        })?;
139        let has_tftp_prefix = value
140            .get(..5)
141            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("tftp:"));
142        let path = value.get(5..).unwrap_or_default();
143        let is_png = path
144            .rsplit_once('.')
145            .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("png"));
146        if !has_tftp_prefix
147            || parsed.scheme() != "tftp"
148            || !parsed.cannot_be_a_base()
149            || parsed.host_str().is_some()
150            || parsed.query().is_some()
151            || parsed.fragment().is_some()
152            || !valid_background_tftp_path(path)
153            || !is_png
154        {
155            return Err(PhoneXmlError::InvalidField {
156                field: "background image TFTP URI",
157                expected: "a TFTP:path URI to a PNG image",
158            });
159        }
160        Ok(Self(value))
161    }
162
163    pub fn as_str(&self) -> &str {
164        &self.0
165    }
166
167    pub fn into_string(self) -> String {
168        self.0
169    }
170}
171
172impl fmt::Debug for PhoneBackgroundTftpUrl {
173    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174        formatter.write_str("PhoneBackgroundTftpUrl(<redacted>)")
175    }
176}
177
178impl Serialize for PhoneBackgroundTftpUrl {
179    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
180    where
181        S: serde::Serializer,
182    {
183        serializer.serialize_str(&self.0)
184    }
185}
186
187impl<'de> serde::Deserialize<'de> for PhoneBackgroundTftpUrl {
188    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
189    where
190        D: serde::Deserializer<'de>,
191    {
192        let value = String::deserialize(deserializer)?;
193        Self::new(value).map_err(serde::de::Error::custom)
194    }
195}
196
197pub(super) fn valid_background_tftp_path(path: &str) -> bool {
198    !path.is_empty()
199        && !path.starts_with('/')
200        && !path.contains(['?', '#', '\\'])
201        && path.split('/').all(|component| {
202            valid_percent_encoding(component)
203                && percent_encoding::percent_decode_str(component)
204                    .decode_utf8()
205                    .is_ok_and(|decoded| {
206                        !decoded.is_empty()
207                            && decoded != "."
208                            && decoded != ".."
209                            && !decoded.contains(['/', '\\'])
210                            && !decoded.chars().any(char::is_control)
211                    })
212        })
213}
214
215pub(super) fn valid_percent_encoding(value: &str) -> bool {
216    let bytes = value.as_bytes();
217    let mut index = 0;
218    while index < bytes.len() {
219        if bytes[index] == b'%' {
220            if index + 2 >= bytes.len()
221                || !bytes[index + 1].is_ascii_hexdigit()
222                || !bytes[index + 2].is_ascii_hexdigit()
223            {
224                return false;
225            }
226            index += 3;
227        } else {
228            index += 1;
229        }
230    }
231    true
232}
233
234/// An HTTP URL accepted by the background selection and preview application.
235#[derive(Clone, Eq, Hash, PartialEq)]
236pub struct PhoneBackgroundHttpUrl(String);
237
238impl PhoneBackgroundHttpUrl {
239    /// Validates an absolute HTTP URL without credentials or a fragment.
240    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
241        let value = value.into();
242        validate_http_resource_url(
243            "background image HTTP URL",
244            "an absolute HTTP URL without credentials or a fragment",
245            &value,
246        )?;
247        Ok(Self(value))
248    }
249
250    pub fn as_str(&self) -> &str {
251        &self.0
252    }
253
254    pub fn into_string(self) -> String {
255        self.0
256    }
257}
258
259pub(super) fn validate_http_resource_url(
260    field: &'static str,
261    expected: &'static str,
262    value: &str,
263) -> Result<(), PhoneXmlError> {
264    validate_optional_text(field, Some(value), 1, PHONE_XML_URL_MAX_CHARS)?;
265    if value
266        .chars()
267        .any(|character| character.is_ascii_whitespace() || character.is_ascii_control())
268        || value.contains('\\')
269        || !valid_percent_encoding(value)
270    {
271        return Err(PhoneXmlError::InvalidField { field, expected });
272    }
273    let parsed =
274        url::Url::parse(value).map_err(|_| PhoneXmlError::InvalidField { field, expected })?;
275    if parsed.scheme() != "http"
276        || parsed.host_str().is_none()
277        || !parsed.username().is_empty()
278        || parsed.password().is_some()
279        || parsed.fragment().is_some()
280    {
281        return Err(PhoneXmlError::InvalidField { field, expected });
282    }
283    Ok(())
284}
285
286impl fmt::Debug for PhoneBackgroundHttpUrl {
287    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
288        formatter.write_str("PhoneBackgroundHttpUrl(<redacted>)")
289    }
290}
291
292impl Serialize for PhoneBackgroundHttpUrl {
293    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
294    where
295        S: serde::Serializer,
296    {
297        serializer.serialize_str(&self.0)
298    }
299}
300
301impl<'de> serde::Deserialize<'de> for PhoneBackgroundHttpUrl {
302    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
303    where
304        D: serde::Deserializer<'de>,
305    {
306        let value = String::deserialize(deserializer)?;
307        Self::new(value).map_err(serde::de::Error::custom)
308    }
309}
310
311/// One ordered full-size/thumbnail pair in a background image list.
312#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct CiscoIpPhoneImageListItem {
315    #[serde(rename = "@Image")]
316    pub thumbnail_url: PhoneBackgroundTftpUrl,
317    #[serde(rename = "@URL")]
318    pub image_url: PhoneBackgroundTftpUrl,
319}
320
321/// The ordered background choices pulled from a desktop `List.xml` resource.
322#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
323#[serde(rename = "CiscoIPPhoneImageList", deny_unknown_fields)]
324pub struct CiscoIpPhoneImageList {
325    #[serde(rename = "ImageItem", default)]
326    pub items: Vec<CiscoIpPhoneImageListItem>,
327}
328
329impl CiscoIpPhoneImageList {
330    /// Builds and validates an ordered list of background choices.
331    pub fn new(items: Vec<CiscoIpPhoneImageListItem>) -> Result<Self, PhoneXmlError> {
332        let document = Self { items };
333        document.validate()?;
334        Ok(document)
335    }
336
337    /// Enforces [`PHONE_BACKGROUND_LIST_MAX_ITEMS`].
338    pub fn validate(&self) -> Result<(), PhoneXmlError> {
339        validate_count(
340            "background image choices",
341            self.items.len(),
342            PHONE_BACKGROUND_LIST_MAX_ITEMS,
343        )
344    }
345}
346
347/// Full-size and thumbnail URLs installed as the phone background.
348#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
349#[serde(deny_unknown_fields)]
350pub struct CiscoIpPhoneBackground {
351    #[serde(rename = "image")]
352    pub image_url: PhoneBackgroundHttpUrl,
353    #[serde(rename = "icon")]
354    pub thumbnail_url: PhoneBackgroundHttpUrl,
355}
356
357/// Background-selection application document.
358#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
359#[serde(rename = "setBackground", deny_unknown_fields)]
360pub struct CiscoIpPhoneSetBackground {
361    #[serde(rename = "background")]
362    pub background: CiscoIpPhoneBackground,
363}
364
365impl CiscoIpPhoneSetBackground {
366    /// Creates a background installation request from validated resource URLs.
367    pub fn new(image_url: PhoneBackgroundHttpUrl, thumbnail_url: PhoneBackgroundHttpUrl) -> Self {
368        Self {
369            background: CiscoIpPhoneBackground {
370                image_url,
371                thumbnail_url,
372            },
373        }
374    }
375
376    /// Parses an installation request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
377    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
378        #[derive(serde::Deserialize)]
379        enum SetBackgroundEnvelope {
380            #[serde(rename = "setBackground")]
381            SetBackground(CiscoIpPhoneSetBackground),
382        }
383
384        let SetBackgroundEnvelope::SetBackground(document) =
385            from_bytes(document, PHONE_BACKGROUND_CONTROL_MAX_BYTES)?;
386        Ok(document)
387    }
388
389    /// Serializes an installation request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
390    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
391        to_string(self, PHONE_BACKGROUND_CONTROL_MAX_BYTES)
392    }
393}
394
395/// Background-preview application document.
396#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
397#[serde(rename = "setBackgroundPreview", deny_unknown_fields)]
398pub struct CiscoIpPhoneSetBackgroundPreview {
399    #[serde(rename = "image")]
400    pub image_url: PhoneBackgroundHttpUrl,
401}
402
403impl CiscoIpPhoneSetBackgroundPreview {
404    /// Creates a preview request from a validated image URL.
405    pub const fn new(image_url: PhoneBackgroundHttpUrl) -> Self {
406        Self { image_url }
407    }
408
409    /// Parses a preview request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
410    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
411        #[derive(serde::Deserialize)]
412        enum PreviewEnvelope {
413            #[serde(rename = "setBackgroundPreview")]
414            Preview(CiscoIpPhoneSetBackgroundPreview),
415        }
416
417        let PreviewEnvelope::Preview(document) =
418            from_bytes(document, PHONE_BACKGROUND_CONTROL_MAX_BYTES)?;
419        Ok(document)
420    }
421
422    /// Serializes a preview request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
423    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
424        to_string(self, PHONE_BACKGROUND_CONTROL_MAX_BYTES)
425    }
426}
427
428/// Either application document accepted by the background-control service.
429#[derive(Clone, Debug, Eq, PartialEq)]
430pub enum PhoneBackgroundControlDocument {
431    Set(CiscoIpPhoneSetBackground),
432    Preview(CiscoIpPhoneSetBackgroundPreview),
433}
434
435impl PhoneBackgroundControlDocument {
436    /// Detects and parses either supported background-control root.
437    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
438        #[derive(serde::Deserialize)]
439        enum BackgroundEnvelope {
440            #[serde(rename = "setBackground")]
441            Set(CiscoIpPhoneSetBackground),
442            #[serde(rename = "setBackgroundPreview")]
443            Preview(CiscoIpPhoneSetBackgroundPreview),
444        }
445
446        Ok(
447            match from_bytes(document, PHONE_BACKGROUND_CONTROL_MAX_BYTES)? {
448                BackgroundEnvelope::Set(document) => Self::Set(document),
449                BackgroundEnvelope::Preview(document) => Self::Preview(document),
450            },
451        )
452    }
453
454    /// Serializes the selected document using the default control byte bound.
455    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
456        self.to_xml_with_limit(PHONE_BACKGROUND_CONTROL_MAX_BYTES)
457    }
458
459    /// Serializes the selected document within a caller-selected byte limit.
460    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
461        match self {
462            Self::Set(document) => to_string(document, maximum_bytes),
463            Self::Preview(document) => to_string(document, maximum_bytes),
464        }
465    }
466}
467
468/// An HTTP resource URL accepted by the ringtone-selection application.
469#[derive(Clone, Eq, Hash, PartialEq)]
470pub struct PhoneRingtoneUrl(String);
471
472impl PhoneRingtoneUrl {
473    /// Validates an absolute lowercase-scheme HTTP URL without credentials or a fragment.
474    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
475        let value = value.into();
476        if !value.starts_with("http://") {
477            return Err(PhoneXmlError::InvalidField {
478                field: "ringtone HTTP URL",
479                expected: "an absolute lowercase HTTP URL without credentials or a fragment",
480            });
481        }
482        validate_http_resource_url(
483            "ringtone HTTP URL",
484            "an absolute lowercase HTTP URL without credentials or a fragment",
485            &value,
486        )?;
487        Ok(Self(value))
488    }
489
490    pub fn as_str(&self) -> &str {
491        &self.0
492    }
493
494    pub fn into_string(self) -> String {
495        self.0
496    }
497}
498
499impl fmt::Debug for PhoneRingtoneUrl {
500    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
501        formatter.write_str("PhoneRingtoneUrl(<redacted>)")
502    }
503}
504
505impl Serialize for PhoneRingtoneUrl {
506    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
507    where
508        S: serde::Serializer,
509    {
510        serializer.serialize_str(&self.0)
511    }
512}
513
514impl<'de> serde::Deserialize<'de> for PhoneRingtoneUrl {
515    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
516    where
517        D: serde::Deserializer<'de>,
518    {
519        let value = String::deserialize(deserializer)?;
520        Self::new(value).map_err(serde::de::Error::custom)
521    }
522}
523
524/// Ringtone-selection application document.
525#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
526#[serde(rename = "setRingTone", deny_unknown_fields)]
527pub struct CiscoIpPhoneSetRingTone {
528    #[serde(rename = "ringTone")]
529    pub ringtone_url: PhoneRingtoneUrl,
530}
531
532impl CiscoIpPhoneSetRingTone {
533    /// Creates a ringtone request from a validated HTTP resource URL.
534    pub const fn new(ringtone_url: PhoneRingtoneUrl) -> Self {
535        Self { ringtone_url }
536    }
537
538    /// Parses a ringtone request using [`PHONE_RINGTONE_MAX_BYTES`].
539    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
540        #[derive(serde::Deserialize)]
541        enum RingToneEnvelope {
542            #[serde(rename = "setRingTone")]
543            RingTone(CiscoIpPhoneSetRingTone),
544        }
545
546        let RingToneEnvelope::RingTone(document) = from_bytes(document, PHONE_RINGTONE_MAX_BYTES)?;
547        Ok(document)
548    }
549
550    /// Serializes a ringtone request using [`PHONE_RINGTONE_MAX_BYTES`].
551    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
552        self.to_xml_with_limit(PHONE_RINGTONE_MAX_BYTES)
553    }
554
555    /// Serializes a ringtone request within a caller-selected byte limit.
556    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
557        to_string(self, maximum_bytes)
558    }
559}
560
561/// A rectangular selection region in a graphic-file menu.
562#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
563#[serde(deny_unknown_fields)]
564pub struct PhoneTouchArea {
565    #[serde(rename = "@X1")]
566    pub x1: u16,
567    #[serde(rename = "@Y1")]
568    pub y1: u16,
569    #[serde(rename = "@X2")]
570    pub x2: u16,
571    #[serde(rename = "@Y2")]
572    pub y2: u16,
573}
574
575/// One optional label, action, and selection region in a graphic-file menu.
576#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
577#[serde(deny_unknown_fields)]
578pub struct CiscoIpPhoneTouchAreaMenuItem {
579    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
580    pub name: Option<String>,
581    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
582    pub url: Option<String>,
583    #[serde(rename = "TouchArea", default, skip_serializing_if = "Option::is_none")]
584    pub touch_area: Option<PhoneTouchArea>,
585}
586
587/// A complete inline-bitmap image document.
588#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
589#[serde(rename = "CiscoIPPhoneImage", deny_unknown_fields)]
590pub struct CiscoIpPhoneImage {
591    #[serde(
592        rename = "@keypadTarget",
593        default,
594        skip_serializing_if = "Option::is_none"
595    )]
596    pub keypad_target: Option<PhoneKeypadTarget>,
597    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
598    pub application_id: Option<String>,
599    #[serde(
600        rename = "@onAppFocusLost",
601        default,
602        skip_serializing_if = "Option::is_none"
603    )]
604    pub on_focus_lost: Option<String>,
605    #[serde(
606        rename = "@onAppFocusGained",
607        default,
608        skip_serializing_if = "Option::is_none"
609    )]
610    pub on_focus_gained: Option<String>,
611    #[serde(
612        rename = "@onAppMinimized",
613        default,
614        skip_serializing_if = "Option::is_none"
615    )]
616    pub on_minimized: Option<String>,
617    #[serde(
618        rename = "@onAppClosed",
619        default,
620        skip_serializing_if = "Option::is_none"
621    )]
622    pub on_closed: Option<String>,
623    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
624    pub title: Option<String>,
625    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
626    pub prompt: Option<String>,
627    #[serde(rename = "SoftKeyItem", default)]
628    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
629    #[serde(rename = "KeyItem", default)]
630    pub key_items: Vec<CiscoIpPhoneKeyItem>,
631    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
632    /// Horizontal origin in `-1..=132`; `-1` requests automatic placement.
633    pub location_x: Option<i16>,
634    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
635    /// Vertical origin in `-1..=64`; `-1` requests automatic placement.
636    pub location_y: Option<i16>,
637    #[serde(rename = "Width")]
638    /// Bitmap width in pixels, constrained to `1..=133`.
639    pub width: u16,
640    #[serde(rename = "Height")]
641    /// Bitmap height in pixels, constrained to `1..=65`.
642    pub height: u16,
643    #[serde(rename = "Depth")]
644    /// Bitmap bit depth, constrained to `1..=2`.
645    pub depth: u16,
646    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
647    pub data: Option<PhoneBitmapData>,
648}
649
650impl CiscoIpPhoneImage {
651    /// Validates display metadata, pixel geometry, and decoded bitmap size.
652    pub fn validate(&self) -> Result<(), PhoneXmlError> {
653        validate_image_display(
654            self.title.as_deref(),
655            self.prompt.as_deref(),
656            self.application_id.as_deref(),
657            [
658                self.on_focus_lost.as_deref(),
659                self.on_focus_gained.as_deref(),
660                self.on_minimized.as_deref(),
661                self.on_closed.as_deref(),
662            ],
663            &self.soft_keys,
664            &self.key_items,
665        )?;
666        validate_bitmap_image(
667            self.location_x,
668            self.location_y,
669            self.width,
670            self.height,
671            self.depth,
672            self.data.as_ref(),
673        )
674    }
675
676    /// Detects, parses, and validates an image root within [`PHONE_IMAGE_MAX_BYTES`].
677    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
678        #[derive(serde::Deserialize)]
679        enum ImageEnvelope {
680            #[serde(rename = "CiscoIPPhoneImage")]
681            Image(CiscoIpPhoneImage),
682        }
683        let ImageEnvelope::Image(document) = from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
684        document.validate()?;
685        Ok(document)
686    }
687
688    /// Validates and serializes the image within [`PHONE_IMAGE_MAX_BYTES`].
689    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
690        self.validate()?;
691        to_string(self, PHONE_IMAGE_MAX_BYTES)
692    }
693}
694
695/// A complete URL-backed image document.
696#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
697#[serde(rename = "CiscoIPPhoneImageFile", deny_unknown_fields)]
698pub struct CiscoIpPhoneImageFile {
699    #[serde(
700        rename = "@keypadTarget",
701        default,
702        skip_serializing_if = "Option::is_none"
703    )]
704    pub keypad_target: Option<PhoneKeypadTarget>,
705    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
706    pub application_id: Option<String>,
707    #[serde(
708        rename = "@onAppFocusLost",
709        default,
710        skip_serializing_if = "Option::is_none"
711    )]
712    pub on_focus_lost: Option<String>,
713    #[serde(
714        rename = "@onAppFocusGained",
715        default,
716        skip_serializing_if = "Option::is_none"
717    )]
718    pub on_focus_gained: Option<String>,
719    #[serde(
720        rename = "@onAppMinimized",
721        default,
722        skip_serializing_if = "Option::is_none"
723    )]
724    pub on_minimized: Option<String>,
725    #[serde(
726        rename = "@onAppClosed",
727        default,
728        skip_serializing_if = "Option::is_none"
729    )]
730    pub on_closed: Option<String>,
731    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
732    pub title: Option<String>,
733    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
734    pub prompt: Option<String>,
735    #[serde(rename = "SoftKeyItem", default)]
736    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
737    #[serde(rename = "KeyItem", default)]
738    pub key_items: Vec<CiscoIpPhoneKeyItem>,
739    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
740    /// Horizontal origin in `-1..=297`; `-1` requests automatic placement.
741    pub location_x: Option<i16>,
742    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
743    /// Vertical origin in `-1..=167`; `-1` requests automatic placement.
744    pub location_y: Option<i16>,
745    #[serde(rename = "URL")]
746    pub url: PhoneImageUrl,
747}
748
749impl CiscoIpPhoneImageFile {
750    /// Validates display metadata and the optional image origin.
751    pub fn validate(&self) -> Result<(), PhoneXmlError> {
752        validate_image_display(
753            self.title.as_deref(),
754            self.prompt.as_deref(),
755            self.application_id.as_deref(),
756            [
757                self.on_focus_lost.as_deref(),
758                self.on_focus_gained.as_deref(),
759                self.on_minimized.as_deref(),
760                self.on_closed.as_deref(),
761            ],
762            &self.soft_keys,
763            &self.key_items,
764        )?;
765        validate_file_image_location(self.location_x, self.location_y)
766    }
767
768    /// Detects, parses, and validates an image-file root within [`PHONE_IMAGE_MAX_BYTES`].
769    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
770        #[derive(serde::Deserialize)]
771        enum ImageFileEnvelope {
772            #[serde(rename = "CiscoIPPhoneImageFile")]
773            ImageFile(CiscoIpPhoneImageFile),
774        }
775        let ImageFileEnvelope::ImageFile(document) = from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
776        document.validate()?;
777        Ok(document)
778    }
779
780    /// Validates and serializes the image-file document within its default bound.
781    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
782        self.validate()?;
783        to_string(self, PHONE_IMAGE_MAX_BYTES)
784    }
785}
786
787/// A complete inline-bitmap graphic menu document.
788#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
789#[serde(rename = "CiscoIPPhoneGraphicMenu", deny_unknown_fields)]
790pub struct CiscoIpPhoneGraphicMenu {
791    #[serde(
792        rename = "@keypadTarget",
793        default,
794        skip_serializing_if = "Option::is_none"
795    )]
796    pub keypad_target: Option<PhoneKeypadTarget>,
797    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
798    pub application_id: Option<String>,
799    #[serde(
800        rename = "@onAppFocusLost",
801        default,
802        skip_serializing_if = "Option::is_none"
803    )]
804    pub on_focus_lost: Option<String>,
805    #[serde(
806        rename = "@onAppFocusGained",
807        default,
808        skip_serializing_if = "Option::is_none"
809    )]
810    pub on_focus_gained: Option<String>,
811    #[serde(
812        rename = "@onAppMinimized",
813        default,
814        skip_serializing_if = "Option::is_none"
815    )]
816    pub on_minimized: Option<String>,
817    #[serde(
818        rename = "@onAppClosed",
819        default,
820        skip_serializing_if = "Option::is_none"
821    )]
822    pub on_closed: Option<String>,
823    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
824    pub title: Option<String>,
825    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
826    pub prompt: Option<String>,
827    #[serde(rename = "SoftKeyItem", default)]
828    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
829    #[serde(rename = "KeyItem", default)]
830    pub key_items: Vec<CiscoIpPhoneKeyItem>,
831    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
832    /// Horizontal origin in `-1..=132`; `-1` requests automatic placement.
833    pub location_x: Option<i16>,
834    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
835    /// Vertical origin in `-1..=64`; `-1` requests automatic placement.
836    pub location_y: Option<i16>,
837    #[serde(rename = "Width")]
838    /// Bitmap width in pixels, constrained to `1..=133`.
839    pub width: u16,
840    #[serde(rename = "Height")]
841    /// Bitmap height in pixels, constrained to `1..=65`.
842    pub height: u16,
843    #[serde(rename = "Depth")]
844    /// Bitmap bit depth, constrained to `1..=2`.
845    pub depth: u16,
846    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
847    pub data: Option<PhoneBitmapData>,
848    #[serde(rename = "MenuItem", default)]
849    pub items: Vec<CiscoIpPhoneMenuItem>,
850}
851
852impl CiscoIpPhoneGraphicMenu {
853    /// Validates display metadata, bitmap geometry, and selectable-item bounds.
854    pub fn validate(&self) -> Result<(), PhoneXmlError> {
855        validate_image_display(
856            self.title.as_deref(),
857            self.prompt.as_deref(),
858            self.application_id.as_deref(),
859            [
860                self.on_focus_lost.as_deref(),
861                self.on_focus_gained.as_deref(),
862                self.on_minimized.as_deref(),
863                self.on_closed.as_deref(),
864            ],
865            &self.soft_keys,
866            &self.key_items,
867        )?;
868        validate_bitmap_image(
869            self.location_x,
870            self.location_y,
871            self.width,
872            self.height,
873            self.depth,
874            self.data.as_ref(),
875        )?;
876        validate_count(
877            "graphic menu items",
878            self.items.len(),
879            PHONE_GRAPHIC_MENU_MAX_ITEMS,
880        )?;
881        for item in &self.items {
882            validate_optional_text("graphic menu item name", item.name.as_deref(), 0, 64)?;
883            validate_optional_text(
884                "graphic menu item URL",
885                item.url.as_deref(),
886                0,
887                PHONE_XML_URL_MAX_CHARS,
888            )?;
889        }
890        Ok(())
891    }
892
893    /// Detects, parses, and validates a graphic menu within [`PHONE_IMAGE_MAX_BYTES`].
894    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
895        #[derive(serde::Deserialize)]
896        enum GraphicMenuEnvelope {
897            #[serde(rename = "CiscoIPPhoneGraphicMenu")]
898            GraphicMenu(CiscoIpPhoneGraphicMenu),
899        }
900        let GraphicMenuEnvelope::GraphicMenu(document) =
901            from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
902        document.validate()?;
903        Ok(document)
904    }
905
906    /// Validates and serializes the graphic menu within its default bound.
907    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
908        self.validate()?;
909        to_string(self, PHONE_IMAGE_MAX_BYTES)
910    }
911}
912
913/// A complete URL-backed graphic menu document.
914#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
915#[serde(rename = "CiscoIPPhoneGraphicFileMenu", deny_unknown_fields)]
916pub struct CiscoIpPhoneGraphicFileMenu {
917    #[serde(
918        rename = "@keypadTarget",
919        default,
920        skip_serializing_if = "Option::is_none"
921    )]
922    pub keypad_target: Option<PhoneKeypadTarget>,
923    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
924    pub application_id: Option<String>,
925    #[serde(
926        rename = "@onAppFocusLost",
927        default,
928        skip_serializing_if = "Option::is_none"
929    )]
930    pub on_focus_lost: Option<String>,
931    #[serde(
932        rename = "@onAppFocusGained",
933        default,
934        skip_serializing_if = "Option::is_none"
935    )]
936    pub on_focus_gained: Option<String>,
937    #[serde(
938        rename = "@onAppMinimized",
939        default,
940        skip_serializing_if = "Option::is_none"
941    )]
942    pub on_minimized: Option<String>,
943    #[serde(
944        rename = "@onAppClosed",
945        default,
946        skip_serializing_if = "Option::is_none"
947    )]
948    pub on_closed: Option<String>,
949    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
950    pub title: Option<String>,
951    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
952    pub prompt: Option<String>,
953    #[serde(rename = "SoftKeyItem", default)]
954    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
955    #[serde(rename = "KeyItem", default)]
956    pub key_items: Vec<CiscoIpPhoneKeyItem>,
957    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
958    /// Horizontal origin in `-1..=297`; `-1` requests automatic placement.
959    pub location_x: Option<i16>,
960    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
961    /// Vertical origin in `-1..=167`; `-1` requests automatic placement.
962    pub location_y: Option<i16>,
963    #[serde(rename = "URL")]
964    pub url: PhoneImageUrl,
965    #[serde(rename = "MenuItem", default)]
966    pub items: Vec<CiscoIpPhoneTouchAreaMenuItem>,
967}
968
969impl CiscoIpPhoneGraphicFileMenu {
970    /// Validates display metadata, image origin, and touch-area item bounds.
971    pub fn validate(&self) -> Result<(), PhoneXmlError> {
972        validate_image_display(
973            self.title.as_deref(),
974            self.prompt.as_deref(),
975            self.application_id.as_deref(),
976            [
977                self.on_focus_lost.as_deref(),
978                self.on_focus_gained.as_deref(),
979                self.on_minimized.as_deref(),
980                self.on_closed.as_deref(),
981            ],
982            &self.soft_keys,
983            &self.key_items,
984        )?;
985        validate_file_image_location(self.location_x, self.location_y)?;
986        validate_count(
987            "graphic-file menu items",
988            self.items.len(),
989            PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS,
990        )?;
991        for item in &self.items {
992            validate_optional_text("graphic-file menu item name", item.name.as_deref(), 0, 32)?;
993            validate_optional_text(
994                "graphic-file menu item URL",
995                item.url.as_deref(),
996                0,
997                PHONE_XML_URL_MAX_CHARS,
998            )?;
999        }
1000        Ok(())
1001    }
1002
1003    /// Detects, parses, and validates a graphic-file menu within its default bound.
1004    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1005        #[derive(serde::Deserialize)]
1006        enum GraphicFileMenuEnvelope {
1007            #[serde(rename = "CiscoIPPhoneGraphicFileMenu")]
1008            GraphicFileMenu(CiscoIpPhoneGraphicFileMenu),
1009        }
1010        let GraphicFileMenuEnvelope::GraphicFileMenu(document) =
1011            from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
1012        document.validate()?;
1013        Ok(document)
1014    }
1015
1016    /// Validates and serializes the graphic-file menu within its default bound.
1017    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1018        self.validate()?;
1019        to_string(self, PHONE_IMAGE_MAX_BYTES)
1020    }
1021}
1022
1023/// Any accepted image-service document family.
1024#[derive(Clone, Debug, Eq, PartialEq)]
1025pub enum PhoneImageDocument {
1026    Image(CiscoIpPhoneImage),
1027    ImageFile(CiscoIpPhoneImageFile),
1028    GraphicMenu(CiscoIpPhoneGraphicMenu),
1029    GraphicFileMenu(CiscoIpPhoneGraphicFileMenu),
1030}
1031
1032impl PhoneImageDocument {
1033    /// Applies the invariants for the selected image family.
1034    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1035        match self {
1036            Self::Image(document) => document.validate(),
1037            Self::ImageFile(document) => document.validate(),
1038            Self::GraphicMenu(document) => document.validate(),
1039            Self::GraphicFileMenu(document) => document.validate(),
1040        }
1041    }
1042
1043    /// Detects the root and parses any supported image family.
1044    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1045        #[derive(serde::Deserialize)]
1046        enum ImageDocumentEnvelope {
1047            #[serde(rename = "CiscoIPPhoneImage")]
1048            Image(CiscoIpPhoneImage),
1049            #[serde(rename = "CiscoIPPhoneImageFile")]
1050            ImageFile(CiscoIpPhoneImageFile),
1051            #[serde(rename = "CiscoIPPhoneGraphicMenu")]
1052            GraphicMenu(CiscoIpPhoneGraphicMenu),
1053            #[serde(rename = "CiscoIPPhoneGraphicFileMenu")]
1054            GraphicFileMenu(CiscoIpPhoneGraphicFileMenu),
1055        }
1056        let document = match from_bytes(document, PHONE_IMAGE_MAX_BYTES)? {
1057            ImageDocumentEnvelope::Image(document) => Self::Image(document),
1058            ImageDocumentEnvelope::ImageFile(document) => Self::ImageFile(document),
1059            ImageDocumentEnvelope::GraphicMenu(document) => Self::GraphicMenu(document),
1060            ImageDocumentEnvelope::GraphicFileMenu(document) => Self::GraphicFileMenu(document),
1061        };
1062        document.validate()?;
1063        Ok(document)
1064    }
1065
1066    /// Validates and serializes the selected family using [`PHONE_IMAGE_MAX_BYTES`].
1067    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1068        self.to_xml_with_limit(PHONE_IMAGE_MAX_BYTES)
1069    }
1070
1071    /// Validates and serializes the selected family within a caller-selected limit.
1072    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
1073        self.validate()?;
1074        match self {
1075            Self::Image(document) => to_string(document, maximum_bytes),
1076            Self::ImageFile(document) => to_string(document, maximum_bytes),
1077            Self::GraphicMenu(document) => to_string(document, maximum_bytes),
1078            Self::GraphicFileMenu(document) => to_string(document, maximum_bytes),
1079        }
1080    }
1081}