Skip to main content

sccp_protocol/phone/xml/
menu.rs

1//! Menu phone XML document family.
2
3use super::*;
4
5/// A directory entry containing an optional display name and dialable value.
6#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
7#[serde(deny_unknown_fields)]
8pub struct CiscoIpPhoneDirectoryEntry {
9    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
10    pub name: Option<String>,
11    #[serde(rename = "Telephone", default, skip_serializing_if = "Option::is_none")]
12    pub telephone: Option<String>,
13}
14
15/// A complete, schema-ordered phone directory response.
16#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename = "CiscoIPPhoneDirectory", deny_unknown_fields)]
18pub struct CiscoIpPhoneDirectory {
19    #[serde(
20        rename = "@keypadTarget",
21        default,
22        skip_serializing_if = "Option::is_none"
23    )]
24    pub keypad_target: Option<PhoneKeypadTarget>,
25    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
26    pub application_id: Option<String>,
27    #[serde(
28        rename = "@onAppFocusLost",
29        default,
30        skip_serializing_if = "Option::is_none"
31    )]
32    pub on_focus_lost: Option<String>,
33    #[serde(
34        rename = "@onAppFocusGained",
35        default,
36        skip_serializing_if = "Option::is_none"
37    )]
38    pub on_focus_gained: Option<String>,
39    #[serde(
40        rename = "@onAppMinimized",
41        default,
42        skip_serializing_if = "Option::is_none"
43    )]
44    pub on_minimized: Option<String>,
45    #[serde(
46        rename = "@onAppClosed",
47        default,
48        skip_serializing_if = "Option::is_none"
49    )]
50    pub on_closed: Option<String>,
51    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
52    pub title: Option<String>,
53    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
54    pub prompt: Option<String>,
55    #[serde(rename = "SoftKeyItem", default)]
56    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
57    #[serde(rename = "KeyItem", default)]
58    pub key_items: Vec<CiscoIpPhoneKeyItem>,
59    #[serde(rename = "DirectoryEntry", default)]
60    pub entries: Vec<CiscoIpPhoneDirectoryEntry>,
61}
62
63impl CiscoIpPhoneDirectory {
64    /// Builds and validates a directory with no optional lifecycle actions.
65    pub fn new(
66        title: impl Into<String>,
67        prompt: impl Into<String>,
68        entries: Vec<CiscoIpPhoneDirectoryEntry>,
69    ) -> Result<Self, PhoneXmlError> {
70        let document = Self {
71            keypad_target: None,
72            application_id: None,
73            on_focus_lost: None,
74            on_focus_gained: None,
75            on_minimized: None,
76            on_closed: None,
77            title: Some(title.into()),
78            prompt: Some(prompt.into()),
79            soft_keys: Vec::new(),
80            key_items: Vec::new(),
81            entries,
82        };
83        document.validate()?;
84        Ok(document)
85    }
86
87    /// Checks entry counts, text bounds, lifecycle actions, and key bindings.
88    pub fn validate(&self) -> Result<(), PhoneXmlError> {
89        validate_count(
90            "directory entries",
91            self.entries.len(),
92            PHONE_DIRECTORY_MAX_ENTRIES,
93        )?;
94        validate_count("directory soft keys", self.soft_keys.len(), 16)?;
95        validate_count("directory key items", self.key_items.len(), 32)?;
96        validate_optional_text("directory title", self.title.as_deref(), 0, 32)?;
97        validate_optional_text("directory prompt", self.prompt.as_deref(), 0, 32)?;
98        validate_optional_text(
99            "directory application id",
100            self.application_id.as_deref(),
101            1,
102            64,
103        )?;
104        for value in [
105            self.on_focus_lost.as_deref(),
106            self.on_focus_gained.as_deref(),
107            self.on_minimized.as_deref(),
108            self.on_closed.as_deref(),
109        ] {
110            validate_optional_text("directory lifecycle URL", value, 1, PHONE_XML_URL_MAX_CHARS)?;
111        }
112        validate_internal_action("directory onAppClosed action", self.on_closed.as_deref())?;
113        for entry in &self.entries {
114            validate_optional_text(
115                "directory entry name",
116                entry.name.as_deref(),
117                0,
118                PHONE_DIRECTORY_TEXT_MAX_CHARS,
119            )?;
120            validate_optional_text(
121                "directory entry telephone",
122                entry.telephone.as_deref(),
123                0,
124                PHONE_DIRECTORY_TEXT_MAX_CHARS,
125            )?;
126        }
127        for soft_key in &self.soft_keys {
128            validate_optional_text("directory soft-key name", soft_key.name.as_deref(), 0, 32)?;
129            validate_optional_text(
130                "directory soft-key URL",
131                soft_key.url.as_deref(),
132                0,
133                PHONE_XML_URL_MAX_CHARS,
134            )?;
135            validate_optional_text(
136                "directory soft-key down URL",
137                soft_key.url_down.as_deref(),
138                0,
139                PHONE_XML_URL_MAX_CHARS,
140            )?;
141            validate_internal_action("directory soft-key URLDown", soft_key.url_down.as_deref())?;
142        }
143        for key_item in &self.key_items {
144            validate_optional_text(
145                "directory key URL",
146                key_item.url.as_deref(),
147                0,
148                PHONE_XML_URL_MAX_CHARS,
149            )?;
150            validate_optional_text(
151                "directory key down URL",
152                key_item.url_down.as_deref(),
153                0,
154                PHONE_XML_URL_MAX_CHARS,
155            )?;
156            validate_internal_action("directory key URLDown", key_item.url_down.as_deref())?;
157        }
158        Ok(())
159    }
160}
161
162pub(super) fn validate_optional_text(
163    field: &'static str,
164    value: Option<&str>,
165    minimum: usize,
166    maximum: usize,
167) -> Result<(), PhoneXmlError> {
168    let Some(value) = value else {
169        return Ok(());
170    };
171    if !text_length_is_within(value, minimum, maximum) {
172        return Err(PhoneXmlError::InvalidField {
173            field,
174            expected: match (minimum, maximum) {
175                (0, 32) => "at most 32 characters",
176                (0, 256) => "at most 256 characters",
177                (1, 64) => "between 1 and 64 characters",
178                (1, 256) => "between 1 and 256 characters",
179                _ => "within the schema length bounds",
180            },
181        });
182    }
183    if !has_only_xml_characters(value) {
184        return Err(PhoneXmlError::InvalidField {
185            field,
186            expected: "valid XML text without forbidden control characters",
187        });
188    }
189    Ok(())
190}
191
192pub(super) fn has_only_xml_characters(value: &str) -> bool {
193    value.chars().all(|character| {
194        matches!(
195            character as u32,
196            0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF
197        )
198    })
199}
200
201pub(super) fn action_kind(value: &str) -> PhoneActionKind {
202    match value.split_once(':').map(|(scheme, _)| scheme) {
203        Some(scheme)
204            if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") =>
205        {
206            PhoneActionKind::Http
207        }
208        _ => PhoneActionKind::Internal,
209    }
210}
211
212pub(super) fn validate_internal_action(
213    field: &'static str,
214    value: Option<&str>,
215) -> Result<(), PhoneXmlError> {
216    if value.is_some_and(|value| action_kind(value) == PhoneActionKind::Http) {
217        Err(PhoneXmlError::InvalidField {
218            field,
219            expected: "an internal phone action, not HTTP or HTTPS",
220        })
221    } else {
222        Ok(())
223    }
224}
225
226pub(super) fn validate_displayable(
227    title: Option<&str>,
228    prompt: Option<&str>,
229    application_id: Option<&str>,
230    lifecycle_urls: [Option<&str>; 4],
231    soft_keys: &[CiscoIpPhoneSoftKeyItem],
232    key_items: &[CiscoIpPhoneKeyItem],
233) -> Result<(), PhoneXmlError> {
234    validate_optional_text("display title", title, 0, 32)?;
235    validate_optional_text("display prompt", prompt, 0, 32)?;
236    validate_optional_text("display application id", application_id, 1, 64)?;
237    let [on_focus_lost, on_focus_gained, on_minimized, on_closed] = lifecycle_urls;
238    for url in [on_focus_lost, on_focus_gained, on_minimized, on_closed] {
239        validate_optional_text("display lifecycle URL", url, 1, PHONE_XML_URL_MAX_CHARS)?;
240    }
241    validate_internal_action("display onAppClosed action", on_closed)?;
242    validate_count("display soft keys", soft_keys.len(), 16)?;
243    for soft_key in soft_keys {
244        validate_optional_text("display soft-key name", soft_key.name.as_deref(), 0, 32)?;
245        validate_optional_text(
246            "display soft-key URL",
247            soft_key.url.as_deref(),
248            0,
249            PHONE_XML_URL_MAX_CHARS,
250        )?;
251        validate_optional_text(
252            "display soft-key down URL",
253            soft_key.url_down.as_deref(),
254            0,
255            PHONE_XML_URL_MAX_CHARS,
256        )?;
257        validate_internal_action("display soft-key URLDown", soft_key.url_down.as_deref())?;
258    }
259    validate_count("display key items", key_items.len(), 32)?;
260    for key_item in key_items {
261        validate_optional_text(
262            "display key URL",
263            key_item.url.as_deref(),
264            0,
265            PHONE_XML_URL_MAX_CHARS,
266        )?;
267        validate_optional_text(
268            "display key down URL",
269            key_item.url_down.as_deref(),
270            0,
271            PHONE_XML_URL_MAX_CHARS,
272        )?;
273        validate_internal_action("display key URLDown", key_item.url_down.as_deref())?;
274    }
275    Ok(())
276}
277
278pub(super) fn validate_image_display(
279    title: Option<&str>,
280    prompt: Option<&str>,
281    application_id: Option<&str>,
282    lifecycle_urls: [Option<&str>; 4],
283    soft_keys: &[CiscoIpPhoneSoftKeyItem],
284    key_items: &[CiscoIpPhoneKeyItem],
285) -> Result<(), PhoneXmlError> {
286    validate_displayable(
287        title,
288        prompt,
289        application_id,
290        lifecycle_urls,
291        soft_keys,
292        key_items,
293    )
294}
295
296pub(super) fn validate_bitmap_image(
297    location_x: Option<i16>,
298    location_y: Option<i16>,
299    width: u16,
300    height: u16,
301    depth: u16,
302    data: Option<&PhoneBitmapData>,
303) -> Result<(), PhoneXmlError> {
304    if location_x.is_some_and(|value| !(-1..=132).contains(&value)) {
305        return Err(PhoneXmlError::InvalidField {
306            field: "bitmap image horizontal location",
307            expected: "between -1 and 132",
308        });
309    }
310    if location_y.is_some_and(|value| !(-1..=64).contains(&value)) {
311        return Err(PhoneXmlError::InvalidField {
312            field: "bitmap image vertical location",
313            expected: "between -1 and 64",
314        });
315    }
316    if !(1..=133).contains(&width) {
317        return Err(PhoneXmlError::InvalidField {
318            field: "bitmap image width",
319            expected: "between 1 and 133",
320        });
321    }
322    if !(1..=65).contains(&height) {
323        return Err(PhoneXmlError::InvalidField {
324            field: "bitmap image height",
325            expected: "between 1 and 65",
326        });
327    }
328    if !(1..=2).contains(&depth) {
329        return Err(PhoneXmlError::InvalidField {
330            field: "bitmap image depth",
331            expected: "between 1 and 2",
332        });
333    }
334    if let Some(data) = data {
335        validate_count(
336            "bitmap image data bytes",
337            data.as_bytes().len(),
338            PHONE_IMAGE_BITMAP_MAX_BYTES,
339        )?;
340    }
341    Ok(())
342}
343
344pub(super) fn validate_file_image_location(
345    location_x: Option<i16>,
346    location_y: Option<i16>,
347) -> Result<(), PhoneXmlError> {
348    if location_x.is_some_and(|value| !(-1..=297).contains(&value)) {
349        return Err(PhoneXmlError::InvalidField {
350            field: "image-file horizontal location",
351            expected: "between -1 and 297",
352        });
353    }
354    if location_y.is_some_and(|value| !(-1..=167).contains(&value)) {
355        return Err(PhoneXmlError::InvalidField {
356            field: "image-file vertical location",
357            expected: "between -1 and 167",
358        });
359    }
360    Ok(())
361}
362
363pub(super) fn validate_icon_menu_items(
364    items: &[CiscoIpPhoneIconMenuItem],
365) -> Result<(), PhoneXmlError> {
366    validate_count("icon menu items", items.len(), PHONE_ICON_MENU_MAX_ITEMS)?;
367    for item in items {
368        validate_optional_text("icon menu item name", item.name.as_deref(), 0, 64)?;
369        validate_optional_text(
370            "icon menu item URL",
371            item.url.as_deref(),
372            0,
373            PHONE_XML_URL_MAX_CHARS,
374        )?;
375        if item.icon_index.is_some_and(|index| index > 9) {
376            return Err(PhoneXmlError::InvalidField {
377                field: "icon menu item index",
378                expected: "between 0 and 9",
379            });
380        }
381    }
382    Ok(())
383}
384
385pub(super) fn validate_bitmap_icon(icon: &CiscoIpPhoneIconItem) -> Result<(), PhoneXmlError> {
386    if !(1..=16).contains(&icon.width) {
387        return Err(PhoneXmlError::InvalidField {
388            field: "bitmap icon width",
389            expected: "between 1 and 16",
390        });
391    }
392    if !(1..=10).contains(&icon.height) {
393        return Err(PhoneXmlError::InvalidField {
394            field: "bitmap icon height",
395            expected: "between 1 and 10",
396        });
397    }
398    if !(1..=2).contains(&icon.depth) {
399        return Err(PhoneXmlError::InvalidField {
400            field: "bitmap icon depth",
401            expected: "between 1 and 2",
402        });
403    }
404    if let Some(data) = &icon.data
405        && (data.len() > 80
406            || data.len() % 2 != 0
407            || !data.bytes().all(|byte| byte.is_ascii_hexdigit()))
408    {
409        return Err(PhoneXmlError::InvalidField {
410            field: "bitmap icon data",
411            expected: "at most 40 hexadecimal bytes",
412        });
413    }
414    Ok(())
415}
416
417/// One optional label/action pair in a plain menu.
418#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
419#[serde(deny_unknown_fields)]
420pub struct CiscoIpPhoneMenuItem {
421    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
422    pub name: Option<String>,
423    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
424    pub url: Option<String>,
425}
426
427/// A complete plain menu with optional lifecycle and physical-key actions.
428#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
429#[serde(rename = "CiscoIPPhoneMenu", deny_unknown_fields)]
430pub struct CiscoIpPhoneMenu {
431    #[serde(
432        rename = "@keypadTarget",
433        default,
434        skip_serializing_if = "Option::is_none"
435    )]
436    pub keypad_target: Option<PhoneKeypadTarget>,
437    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
438    pub application_id: Option<String>,
439    #[serde(
440        rename = "@onAppFocusLost",
441        default,
442        skip_serializing_if = "Option::is_none"
443    )]
444    pub on_focus_lost: Option<String>,
445    #[serde(
446        rename = "@onAppFocusGained",
447        default,
448        skip_serializing_if = "Option::is_none"
449    )]
450    pub on_focus_gained: Option<String>,
451    #[serde(
452        rename = "@onAppMinimized",
453        default,
454        skip_serializing_if = "Option::is_none"
455    )]
456    pub on_minimized: Option<String>,
457    #[serde(
458        rename = "@onAppClosed",
459        default,
460        skip_serializing_if = "Option::is_none"
461    )]
462    pub on_closed: Option<String>,
463    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
464    pub title: Option<String>,
465    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
466    pub prompt: Option<String>,
467    #[serde(rename = "SoftKeyItem", default)]
468    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
469    #[serde(rename = "KeyItem", default)]
470    pub key_items: Vec<CiscoIpPhoneKeyItem>,
471    #[serde(rename = "MenuItem", default)]
472    pub items: Vec<CiscoIpPhoneMenuItem>,
473}
474
475impl CiscoIpPhoneMenu {
476    /// Builds and validates a menu with no optional lifecycle actions.
477    pub fn new(
478        title: impl Into<String>,
479        prompt: impl Into<String>,
480        items: Vec<CiscoIpPhoneMenuItem>,
481    ) -> Result<Self, PhoneXmlError> {
482        let document = Self {
483            keypad_target: None,
484            application_id: None,
485            on_focus_lost: None,
486            on_focus_gained: None,
487            on_minimized: None,
488            on_closed: None,
489            title: Some(title.into()),
490            prompt: Some(prompt.into()),
491            soft_keys: Vec::new(),
492            key_items: Vec::new(),
493            items,
494        };
495        document.validate()?;
496        Ok(document)
497    }
498
499    /// Validates display metadata and the bounded list of menu choices.
500    pub fn validate(&self) -> Result<(), PhoneXmlError> {
501        validate_displayable(
502            self.title.as_deref(),
503            self.prompt.as_deref(),
504            self.application_id.as_deref(),
505            [
506                self.on_focus_lost.as_deref(),
507                self.on_focus_gained.as_deref(),
508                self.on_minimized.as_deref(),
509                self.on_closed.as_deref(),
510            ],
511            &self.soft_keys,
512            &self.key_items,
513        )?;
514        validate_count("menu items", self.items.len(), PHONE_MENU_MAX_ITEMS)?;
515        for item in &self.items {
516            validate_optional_text("menu item name", item.name.as_deref(), 0, 64)?;
517            validate_optional_text(
518                "menu item URL",
519                item.url.as_deref(),
520                0,
521                PHONE_XML_URL_MAX_CHARS,
522            )?;
523        }
524        Ok(())
525    }
526}
527
528/// One indexed inline bitmap icon used by an icon menu.
529#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
530#[serde(deny_unknown_fields)]
531pub struct CiscoIpPhoneIconItem {
532    #[serde(rename = "Index")]
533    pub index: u16,
534    #[serde(rename = "Width")]
535    /// Icon width in pixels, constrained to `1..=16`.
536    pub width: u16,
537    #[serde(rename = "Height")]
538    /// Icon height in pixels, constrained to `1..=10`.
539    pub height: u16,
540    #[serde(rename = "Depth")]
541    /// Icon bit depth, constrained to `1..=2`.
542    pub depth: u16,
543    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
544    /// Optional hexadecimal bitmap containing at most 40 bytes.
545    pub data: Option<String>,
546}
547
548/// One indexed referenced icon used by an icon-file menu.
549#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
550#[serde(deny_unknown_fields)]
551pub struct CiscoIpPhoneIconFileItem {
552    #[serde(rename = "Index")]
553    pub index: u16,
554    #[serde(rename = "URL")]
555    /// Resource URL constrained to at most 256 characters.
556    pub url: String,
557}
558
559/// One optional label/action pair with an optional icon association.
560#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
561#[serde(deny_unknown_fields)]
562pub struct CiscoIpPhoneIconMenuItem {
563    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
564    pub name: Option<String>,
565    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
566    pub url: Option<String>,
567    #[serde(rename = "IconIndex", default, skip_serializing_if = "Option::is_none")]
568    /// Icon index in `0..=9`; omission displays no icon.
569    pub icon_index: Option<u16>,
570}
571
572/// Icon-bearing title used by an icon-file menu.
573#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
574#[serde(deny_unknown_fields)]
575pub struct CiscoIpPhoneIconTitle {
576    #[serde(
577        rename = "@IconIndex",
578        default,
579        skip_serializing_if = "Option::is_none"
580    )]
581    /// Icon index in `0..=9`; omission displays only title text.
582    pub icon_index: Option<u16>,
583    #[serde(rename = "$text", default)]
584    pub text: String,
585}
586
587/// A complete menu whose icons are inline hexadecimal bitmaps.
588#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
589#[serde(rename = "CiscoIPPhoneIconMenu", deny_unknown_fields)]
590pub struct CiscoIpPhoneIconMenu {
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 = "MenuItem", default)]
632    pub items: Vec<CiscoIpPhoneIconMenuItem>,
633    #[serde(rename = "IconItem", default)]
634    pub icons: Vec<CiscoIpPhoneIconItem>,
635}
636
637impl CiscoIpPhoneIconMenu {
638    /// Builds and validates an inline-icon menu without lifecycle actions.
639    pub fn new(
640        title: impl Into<String>,
641        prompt: impl Into<String>,
642        items: Vec<CiscoIpPhoneIconMenuItem>,
643        icons: Vec<CiscoIpPhoneIconItem>,
644    ) -> Result<Self, PhoneXmlError> {
645        let document = Self {
646            keypad_target: None,
647            application_id: None,
648            on_focus_lost: None,
649            on_focus_gained: None,
650            on_minimized: None,
651            on_closed: None,
652            title: Some(title.into()),
653            prompt: Some(prompt.into()),
654            soft_keys: Vec::new(),
655            key_items: Vec::new(),
656            items,
657            icons,
658        };
659        document.validate()?;
660        Ok(document)
661    }
662
663    /// Validates display metadata, choice bounds, and bitmap icon geometry.
664    pub fn validate(&self) -> Result<(), PhoneXmlError> {
665        validate_displayable(
666            self.title.as_deref(),
667            self.prompt.as_deref(),
668            self.application_id.as_deref(),
669            [
670                self.on_focus_lost.as_deref(),
671                self.on_focus_gained.as_deref(),
672                self.on_minimized.as_deref(),
673                self.on_closed.as_deref(),
674            ],
675            &self.soft_keys,
676            &self.key_items,
677        )?;
678        validate_icon_menu_items(&self.items)?;
679        validate_count(
680            "icon menu icons",
681            self.icons.len(),
682            PHONE_ICON_MENU_MAX_ICONS,
683        )?;
684        for icon in &self.icons {
685            validate_bitmap_icon(icon)?;
686        }
687        Ok(())
688    }
689}
690
691/// A complete menu whose icons are loaded from resource URLs.
692#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
693#[serde(rename = "CiscoIPPhoneIconFileMenu", deny_unknown_fields)]
694pub struct CiscoIpPhoneIconFileMenu {
695    #[serde(
696        rename = "@keypadTarget",
697        default,
698        skip_serializing_if = "Option::is_none"
699    )]
700    pub keypad_target: Option<PhoneKeypadTarget>,
701    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
702    pub application_id: Option<String>,
703    #[serde(
704        rename = "@onAppFocusLost",
705        default,
706        skip_serializing_if = "Option::is_none"
707    )]
708    pub on_focus_lost: Option<String>,
709    #[serde(
710        rename = "@onAppFocusGained",
711        default,
712        skip_serializing_if = "Option::is_none"
713    )]
714    pub on_focus_gained: Option<String>,
715    #[serde(
716        rename = "@onAppMinimized",
717        default,
718        skip_serializing_if = "Option::is_none"
719    )]
720    pub on_minimized: Option<String>,
721    #[serde(
722        rename = "@onAppClosed",
723        default,
724        skip_serializing_if = "Option::is_none"
725    )]
726    pub on_closed: Option<String>,
727    #[serde(
728        rename = "@IconIndex",
729        default,
730        skip_serializing_if = "Option::is_none"
731    )]
732    /// Optional icon index in `0..=9` displayed beside the title.
733    pub icon_index: Option<u16>,
734    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
735    pub title: Option<CiscoIpPhoneIconTitle>,
736    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
737    pub prompt: Option<String>,
738    #[serde(rename = "SoftKeyItem", default)]
739    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
740    #[serde(rename = "KeyItem", default)]
741    pub key_items: Vec<CiscoIpPhoneKeyItem>,
742    #[serde(rename = "MenuItem", default)]
743    pub items: Vec<CiscoIpPhoneIconMenuItem>,
744    #[serde(rename = "IconItem", default)]
745    pub icons: Vec<CiscoIpPhoneIconFileItem>,
746}
747
748impl CiscoIpPhoneIconFileMenu {
749    /// Validates display metadata, choice bounds, and referenced icons.
750    pub fn validate(&self) -> Result<(), PhoneXmlError> {
751        validate_displayable(
752            self.title.as_ref().map(|title| title.text.as_str()),
753            self.prompt.as_deref(),
754            self.application_id.as_deref(),
755            [
756                self.on_focus_lost.as_deref(),
757                self.on_focus_gained.as_deref(),
758                self.on_minimized.as_deref(),
759                self.on_closed.as_deref(),
760            ],
761            &self.soft_keys,
762            &self.key_items,
763        )?;
764        validate_icon_menu_items(&self.items)?;
765        validate_count(
766            "icon-file menu icons",
767            self.icons.len(),
768            PHONE_ICON_MENU_MAX_ICONS,
769        )?;
770        for icon in &self.icons {
771            if icon.index > 9 {
772                return Err(PhoneXmlError::InvalidField {
773                    field: "icon-file index",
774                    expected: "between 0 and 9",
775                });
776            }
777            validate_optional_text("icon-file URL", Some(&icon.url), 1, PHONE_XML_URL_MAX_CHARS)?;
778        }
779        Ok(())
780    }
781}