matrix_sdk/widget/settings/
element_call.rs

1// Copyright 2023 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// This module contains ALL the Element Call related code (minus the FFI
16// bindings for this file). Hence all other files in the rust sdk contain code
17// that is relevant for all widgets. This makes it simple to rip out Element
18// Call related pieces.
19// TODO: The goal is to have not any Element Call specific code
20// in the rust sdk. Find a better solution for this.
21
22use serde::Serialize;
23use url::Url;
24
25use super::{WidgetSettings, url_params};
26
27#[derive(Serialize)]
28#[serde(rename_all = "camelCase")]
29/// Serialization struct for URL parameters for the Element Call widget.
30/// These are documented at https://github.com/element-hq/element-call/blob/livekit/docs/url-params.md
31///
32/// The ElementCallParams are used to be translated into url query parameters.
33/// For all optional fields, the None case implies, that it will not be part of
34/// the url parameters.
35///
36/// # Example:
37///
38/// ```
39/// ElementCallParams {
40///     // Required parameters:
41///     user_id: "@1234",
42///     room_id: "$1234",
43///     ...
44///     // Optional configuration:
45///     hide_screensharing: Some(true),
46///     ..ElementCallParams::default()
47/// }
48/// ```
49/// will become: `my.url? ...requires_parameters... &hide_screensharing=true`
50/// The reason it might be desirable to not list those configurations in the
51/// URLs parameters is that the `intent` implies defaults for all configuration
52/// values in the widget itself. Setting the URL parameter specifically will
53/// overwrite those defaults.
54struct ElementCallUrlParams {
55    user_id: String,
56    room_id: String,
57    widget_id: String,
58    display_name: String,
59    lang: String,
60    theme: String,
61    client_id: String,
62    device_id: String,
63    base_url: String,
64    // Non template parameters
65    parent_url: String,
66    /// Deprecated since Element Call v0.8.0. Included for backwards
67    /// compatibility. Set to `true` if intent is `Intent::StartCall`.
68    skip_lobby: Option<bool>,
69    confine_to_room: Option<bool>,
70    app_prompt: Option<bool>,
71    /// Supported since Element Call v0.13.0.
72    header: Option<HeaderStyle>,
73    /// Deprecated since Element Call v0.13.0. Included for backwards
74    /// compatibility. Use header: "standard"|"none" instead.
75    hide_header: Option<bool>,
76    preload: Option<bool>,
77    /// Deprecated since Element Call v0.9.0. Included for backwards
78    /// compatibility. Set to the same as `posthog_user_id`.
79    analytics_id: Option<String>,
80    /// Supported since Element Call v0.9.0.
81    posthog_user_id: Option<String>,
82    font_scale: Option<f64>,
83    font: Option<String>,
84    #[serde(rename = "perParticipantE2EE")]
85    per_participant_e2ee: Option<bool>,
86    password: Option<String>,
87    /// Supported since Element Call v0.8.0.
88    intent: Option<Intent>,
89    /// Supported since Element Call v0.9.0. Only used by the embedded package.
90    posthog_api_host: Option<String>,
91    /// Supported since Element Call v0.9.0. Only used by the embedded package.
92    posthog_api_key: Option<String>,
93    /// Supported since Element Call v0.9.0. Only used by the embedded package.
94    rageshake_submit_url: Option<String>,
95    /// Supported since Element Call v0.9.0. Only used by the embedded package.
96    sentry_dsn: Option<String>,
97    /// Supported since Element Call v0.9.0. Only used by the embedded package.
98    sentry_environment: Option<String>,
99    /// Supported since Element Call v0.9.0.
100    hide_screensharing: Option<bool>,
101    /// Supported since Element Call v0.13.0.
102    controlled_audio_devices: Option<bool>,
103    /// Supported since Element Call v0.14.0.
104    send_notification_type: Option<NotificationType>,
105}
106
107/// Defines if a call is encrypted and which encryption system should be used.
108///
109/// This controls the url parameters: `perParticipantE2EE`, `password`.
110#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
111#[derive(Debug, PartialEq, Default, Clone)]
112pub enum EncryptionSystem {
113    /// Equivalent to the element call url parameter: `perParticipantE2EE=false`
114    /// and no password.
115    Unencrypted,
116    /// Equivalent to the element call url parameters:
117    /// `perParticipantE2EE=true`
118    #[default]
119    PerParticipantKeys,
120    /// Equivalent to the element call url parameters:
121    /// `password={secret}`
122    SharedSecret {
123        /// The secret/password which is used in the url.
124        secret: String,
125    },
126}
127
128/// Defines the intent of showing the call.
129///
130/// This controls whether to show or skip the lobby.
131#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
132#[derive(Debug, PartialEq, Serialize, Default, Clone)]
133#[serde(rename_all = "snake_case")]
134pub enum Intent {
135    #[default]
136    /// The user wants to start a call.
137    StartCall,
138    /// The user wants to join an existing call.
139    JoinExisting,
140    /// The user wants to join an existing call that is a "Direct Message" (DM)
141    /// room.
142    JoinExistingDm,
143    /// The user wants to start a call in a "Direct Message" (DM) room.
144    StartCallDm,
145}
146
147/// Defines how (if) element-call renders a header.
148#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
149#[derive(Debug, PartialEq, Serialize, Default, Clone)]
150#[serde(rename_all = "snake_case")]
151pub enum HeaderStyle {
152    /// The normal header with branding.
153    #[default]
154    Standard,
155    /// Render a header with a back button (useful on mobile platforms).
156    AppBar,
157    /// No Header (useful for webapps).
158    None,
159}
160
161/// Types of call notifications.
162#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
163#[derive(Debug, PartialEq, Serialize, Clone, Default)]
164#[serde(rename_all = "snake_case")]
165pub enum NotificationType {
166    /// The receiving client should display a visual notification.    
167    #[default]
168    Notification,
169    /// The receiving client should ring with an audible sound.
170    Ring,
171}
172
173/// Configuration parameters, to create a new virtual Element Call widget.
174///
175/// If `intent` is provided the appropriate default values for all other
176/// parameters will be used by element call.
177/// In most cases its enough to only set the intent. Use the other properties
178/// only if you want to deviate from the `intent` defaults.
179///
180/// Set [`docs/url-params.md`](https://github.com/element-hq/element-call/blob/livekit/docs/url-params.md)
181/// to find out more about the parameters and their defaults.
182#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
183#[derive(Debug, Default, Clone)]
184pub struct VirtualElementCallWidgetConfig {
185    /// The intent of showing the call.
186    /// If the user wants to start a call or join an existing one.
187    /// Controls if the lobby is skipped or not.
188    pub intent: Option<Intent>,
189
190    /// Skip the lobby when joining a call.
191    #[uniffi(default = None)]
192    pub skip_lobby: Option<bool>,
193
194    /// Whether the branding header of Element call should be shown or if a
195    /// mobile header navbar should be render.
196    ///
197    /// Default: [`HeaderStyle::Standard`]
198    #[uniffi(default = None)]
199    pub header: Option<HeaderStyle>,
200
201    /// Whether the branding header of Element call should be hidden.
202    ///
203    /// Default: `true`
204    #[deprecated(note = "Use `header` instead", since = "0.12.1")]
205    #[uniffi(default = None)]
206    pub hide_header: Option<bool>,
207
208    /// If set, the lobby will be skipped and the widget will join the
209    /// call on the `io.element.join` action.
210    ///
211    /// Default: `false`
212    #[uniffi(default = None)]
213    pub preload: Option<bool>,
214
215    /// Whether element call should prompt the user to open in the browser or
216    /// the app.
217    ///
218    /// Default: `false`
219    #[uniffi(default = None)]
220    pub app_prompt: Option<bool>,
221
222    /// Make it not possible to get to the calls list in the webview.
223    ///
224    /// Default: `true`
225    #[uniffi(default = None)]
226    pub confine_to_room: Option<bool>,
227
228    /// Do not show the screenshare button.
229    #[uniffi(default = None)]
230    pub hide_screensharing: Option<bool>,
231
232    /// Make the audio devices be controlled by the os instead of the
233    /// element-call webview.
234    #[uniffi(default = None)]
235    pub controlled_audio_devices: Option<bool>,
236
237    /// Whether and what type of notification Element Call should send, when
238    /// starting a call.
239    #[uniffi(default = None)]
240    pub send_notification_type: Option<NotificationType>,
241}
242
243/// Properties to create a new virtual Element Call widget.
244///
245/// All these are required to start the widget in the first place.
246/// This is different from the `VirtualElementCallWidgetConfiguration` which
247/// configures the widgets behavior.
248#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
249#[derive(Debug, Default, Clone)]
250pub struct VirtualElementCallWidgetProperties {
251    /// The url to the app.
252    ///
253    /// E.g. <https://call.element.io>, <https://call.element.dev>, <https://call.element.dev/room>
254    pub element_call_url: String,
255
256    /// The widget id.
257    pub widget_id: String,
258
259    /// The url that is used as the target for the PostMessages sent
260    /// by the widget (to the client).
261    ///
262    /// For a web app client this is the client url. In case of using other
263    /// platforms the client most likely is setup up to listen to
264    /// postmessages in the same webview the widget is hosted. In this case
265    /// the `parent_url` is set to the url of the webview with the widget. Be
266    /// aware that this means that the widget will receive its own postmessage
267    /// messages. The `matrix-widget-api` (js) ignores those so this works but
268    /// it might break custom implementations.
269    ///
270    /// Defaults to `element_call_url` for the non-iframe (dedicated webview)
271    /// usecase.
272    #[uniffi(default = None)]
273    pub parent_url: Option<String>,
274
275    /// The font scale which will be used inside element call.
276    ///
277    /// Default: `1`
278    #[uniffi(default = None)]
279    pub font_scale: Option<f64>,
280
281    /// The font to use, to adapt to the system font.
282    #[uniffi(default = None)]
283    pub font: Option<String>,
284
285    /// The encryption system to use.
286    ///
287    /// Use `EncryptionSystem::Unencrypted` to disable encryption.
288    pub encryption: EncryptionSystem,
289
290    /// Can be used to pass a PostHog id to element call.
291    #[uniffi(default = None)]
292    pub posthog_user_id: Option<String>,
293    /// The host of the posthog api.
294    /// This is only used by the embedded package of Element Call.
295    #[uniffi(default = None)]
296    pub posthog_api_host: Option<String>,
297    /// The key for the posthog api.
298    /// This is only used by the embedded package of Element Call.
299    #[uniffi(default = None)]
300    pub posthog_api_key: Option<String>,
301
302    /// The url to use for submitting rageshakes.
303    /// This is only used by the embedded package of Element Call.
304    #[uniffi(default = None)]
305    pub rageshake_submit_url: Option<String>,
306
307    /// Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
308    /// This is only used by the embedded package of Element Call.
309    #[uniffi(default = None)]
310    pub sentry_dsn: Option<String>,
311
312    /// Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/)
313    /// This is only used by the embedded package of Element Call.
314    #[uniffi(default = None)]
315    pub sentry_environment: Option<String>,
316}
317
318impl WidgetSettings {
319    /// `WidgetSettings` are usually created from a state event.
320    /// (currently unimplemented)
321    ///
322    /// In some cases the client wants to create custom `WidgetSettings`
323    /// for specific rooms based on other conditions.
324    /// This function returns a `WidgetSettings` object which can be used
325    /// to setup a widget using `run_client_widget_api`
326    /// and to generate the correct url for the widget.
327    ///
328    /// # Arguments
329    ///
330    /// * `props` - A struct containing the configuration parameters for a
331    ///   element call widget.
332    pub fn new_virtual_element_call_widget(
333        props: VirtualElementCallWidgetProperties,
334        config: VirtualElementCallWidgetConfig,
335    ) -> Result<Self, url::ParseError> {
336        let mut raw_url: Url = Url::parse(&props.element_call_url)?;
337
338        #[allow(deprecated)]
339        let query_params = ElementCallUrlParams {
340            user_id: url_params::USER_ID.to_owned(),
341            room_id: url_params::ROOM_ID.to_owned(),
342            widget_id: url_params::WIDGET_ID.to_owned(),
343            display_name: url_params::DISPLAY_NAME.to_owned(),
344            lang: url_params::LANGUAGE.to_owned(),
345            theme: url_params::CLIENT_THEME.to_owned(),
346            client_id: url_params::CLIENT_ID.to_owned(),
347            device_id: url_params::DEVICE_ID.to_owned(),
348            base_url: url_params::HOMESERVER_URL.to_owned(),
349
350            parent_url: props.parent_url.unwrap_or(props.element_call_url.clone()),
351            confine_to_room: config.confine_to_room,
352            app_prompt: config.app_prompt,
353            header: config.header,
354            hide_header: config.hide_header,
355            preload: config.preload,
356            font_scale: props.font_scale,
357            font: props.font,
358            per_participant_e2ee: Some(props.encryption == EncryptionSystem::PerParticipantKeys),
359            password: match props.encryption {
360                EncryptionSystem::SharedSecret { secret } => Some(secret),
361                _ => None,
362            },
363            intent: config.intent,
364            skip_lobby: config.skip_lobby,
365            analytics_id: props.posthog_user_id.clone(),
366            posthog_user_id: props.posthog_user_id,
367            posthog_api_host: props.posthog_api_host,
368            posthog_api_key: props.posthog_api_key,
369            sentry_dsn: props.sentry_dsn,
370            sentry_environment: props.sentry_environment,
371            rageshake_submit_url: props.rageshake_submit_url,
372            hide_screensharing: config.hide_screensharing,
373            controlled_audio_devices: config.controlled_audio_devices,
374            send_notification_type: config.send_notification_type,
375        };
376
377        let query =
378            serde_html_form::to_string(query_params).map_err(|_| url::ParseError::Overflow)?;
379
380        // Revert the encoding for the template parameters. So we can have a unified
381        // replace logic.
382        let query = query.replace("%24", "$");
383
384        // All the params will be set inside the fragment (to keep the traffic to the
385        // server minimal and most importantly don't send the passwords).
386        raw_url.set_fragment(Some(&format!("?{query}")));
387
388        // for EC we always want init on content load to be true.
389        Ok(Self { widget_id: props.widget_id, init_on_content_load: true, raw_url })
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use std::collections::BTreeSet;
396
397    use ruma::api::client::profile::get_profile;
398    use url::Url;
399
400    use crate::widget::{
401        ClientProperties, Intent, WidgetSettings,
402        settings::element_call::{HeaderStyle, VirtualElementCallWidgetConfig},
403    };
404
405    const WIDGET_ID: &str = "1/@#w23";
406
407    fn get_element_call_widget_settings(
408        encryption: Option<EncryptionSystem>,
409        posthog: bool,
410        rageshake: bool,
411        sentry: bool,
412        intent: Option<Intent>,
413        controlled_output: bool,
414    ) -> WidgetSettings {
415        let props = VirtualElementCallWidgetProperties {
416            element_call_url: "https://call.element.io".to_owned(),
417            widget_id: WIDGET_ID.to_owned(),
418            posthog_user_id: posthog.then(|| "POSTHOG_USER_ID".to_owned()),
419            posthog_api_host: posthog.then(|| "posthog.element.io".to_owned()),
420            posthog_api_key: posthog.then(|| "POSTHOG_KEY".to_owned()),
421            rageshake_submit_url: rageshake.then(|| "https://rageshake.element.io".to_owned()),
422            sentry_dsn: sentry.then(|| "SENTRY_DSN".to_owned()),
423            sentry_environment: sentry.then(|| "SENTRY_ENV".to_owned()),
424            encryption: encryption.unwrap_or(EncryptionSystem::PerParticipantKeys),
425            ..VirtualElementCallWidgetProperties::default()
426        };
427
428        let config = VirtualElementCallWidgetConfig {
429            controlled_audio_devices: Some(controlled_output),
430            preload: Some(true),
431            app_prompt: Some(true),
432            confine_to_room: Some(true),
433            hide_screensharing: Some(false),
434            header: Some(HeaderStyle::Standard),
435            intent,
436            ..VirtualElementCallWidgetConfig::default()
437        };
438
439        WidgetSettings::new_virtual_element_call_widget(props, config)
440            .expect("could not parse virtual element call widget")
441    }
442
443    trait FragmentQuery {
444        fn fragment_query(&self) -> Option<&str>;
445    }
446
447    impl FragmentQuery for Url {
448        fn fragment_query(&self) -> Option<&str> {
449            Some(self.fragment()?.split_once('?')?.1)
450        }
451    }
452
453    // Convert query strings to BTreeSet so that we can compare the urls independent
454    // of the order of the params.
455    type QuerySet = BTreeSet<(String, String)>;
456
457    use serde_html_form::from_str;
458
459    use super::{EncryptionSystem, VirtualElementCallWidgetProperties};
460
461    fn get_query_sets(url: &Url) -> Option<(QuerySet, QuerySet)> {
462        let fq = from_str::<QuerySet>(url.fragment_query().unwrap_or_default()).ok()?;
463        let q = from_str::<QuerySet>(url.query().unwrap_or_default()).ok()?;
464        Some((q, fq))
465    }
466
467    #[test]
468    fn test_new_virtual_element_call_widget_base_url() {
469        let widget_settings =
470            get_element_call_widget_settings(None, false, false, false, None, false);
471        assert_eq!(widget_settings.base_url().unwrap().as_str(), "https://call.element.io/");
472    }
473
474    #[test]
475    fn test_new_virtual_element_call_widget_raw_url() {
476        const CONVERTED_URL: &str = "
477            https://call.element.io#\
478                ?userId=$matrix_user_id\
479                &roomId=$matrix_room_id\
480                &widgetId=$matrix_widget_id\
481                &displayName=$matrix_display_name\
482                &lang=$org.matrix.msc2873.client_language\
483                &theme=$org.matrix.msc2873.client_theme\
484                &clientId=$org.matrix.msc2873.client_id\
485                &deviceId=$org.matrix.msc2873.matrix_device_id\
486                &baseUrl=$org.matrix.msc4039.matrix_base_url\
487                &parentUrl=https%3A%2F%2Fcall.element.io\
488                &confineToRoom=true\
489                &appPrompt=true\
490                &header=standard\
491                &preload=true\
492                &perParticipantE2EE=true\
493                &hideScreensharing=false\
494                &controlledAudioDevices=false\
495        ";
496
497        let mut generated_url =
498            get_element_call_widget_settings(None, false, false, false, None, false)
499                .raw_url()
500                .clone();
501        let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
502        assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
503        generated_url.set_fragment(None);
504        generated_url.set_query(None);
505        expected_url.set_fragment(None);
506        expected_url.set_query(None);
507        assert_eq!(generated_url, expected_url);
508    }
509
510    #[test]
511    fn test_new_virtual_element_call_widget_id() {
512        assert_eq!(
513            get_element_call_widget_settings(None, false, false, false, None, false).widget_id(),
514            WIDGET_ID
515        );
516    }
517
518    fn build_url_from_widget_settings(settings: WidgetSettings) -> String {
519        let mut profile = get_profile::v3::Response::new();
520        profile.set("avatar_url", "some-url".into());
521        profile.set("displayname", "hello".into());
522
523        settings
524            ._generate_webview_url(
525                profile,
526                "@test:user.org".try_into().unwrap(),
527                "!room_id:room.org".try_into().unwrap(),
528                "ABCDEFG".into(),
529                "https://client-matrix.server.org".try_into().unwrap(),
530                ClientProperties::new(
531                    "io.my_matrix.client",
532                    Some(language_tags::LanguageTag::parse("en-us").unwrap()),
533                    Some("light".into()),
534                ),
535            )
536            .unwrap()
537            .to_string()
538    }
539
540    #[test]
541    fn test_new_virtual_element_call_widget_webview_url() {
542        const CONVERTED_URL: &str = "
543            https://call.element.io#\
544                ?parentUrl=https%3A%2F%2Fcall.element.io\
545                &widgetId=1/@#w23\
546                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
547                &roomId=%21room_id%3Aroom.org\
548                &lang=en-US&theme=light\
549                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
550                &header=standard\
551                &preload=true\
552                &confineToRoom=true\
553                &displayName=hello\
554                &appPrompt=true\
555                &clientId=io.my_matrix.client\
556                &perParticipantE2EE=true\
557                &hideScreensharing=false\
558                &controlledAudioDevices=false\
559        ";
560        let mut generated_url = Url::parse(&build_url_from_widget_settings(
561            get_element_call_widget_settings(None, false, false, false, None, false),
562        ))
563        .unwrap();
564        let mut expected_url = Url::parse(CONVERTED_URL).unwrap();
565        assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&expected_url).unwrap());
566        generated_url.set_fragment(None);
567        generated_url.set_query(None);
568        expected_url.set_fragment(None);
569        expected_url.set_query(None);
570        assert_eq!(generated_url, expected_url);
571    }
572
573    #[test]
574    fn test_new_virtual_element_call_widget_webview_url_with_posthog_rageshake_sentry() {
575        const CONVERTED_URL: &str = "
576            https://call.element.io#\
577                ?parentUrl=https%3A%2F%2Fcall.element.io\
578                &widgetId=1/@#w23\
579                &userId=%40test%3Auser.org&deviceId=ABCDEFG\
580                &roomId=%21room_id%3Aroom.org\
581                &lang=en-US&theme=light\
582                &baseUrl=https%3A%2F%2Fclient-matrix.server.org%2F\
583                &header=standard\
584                &preload=true\
585                &confineToRoom=true\
586                &displayName=hello\
587                &appPrompt=true\
588                &clientId=io.my_matrix.client\
589                &perParticipantE2EE=true\
590                &hideScreensharing=false\
591                &posthogApiHost=posthog.element.io\
592                &posthogApiKey=POSTHOG_KEY\
593                &analyticsId=POSTHOG_USER_ID\
594                &posthogUserId=POSTHOG_USER_ID\
595                &rageshakeSubmitUrl=https%3A%2F%2Frageshake.element.io\
596                &sentryDsn=SENTRY_DSN\
597                &sentryEnvironment=SENTRY_ENV\
598                &controlledAudioDevices=false\
599        ";
600        let mut generated_url = Url::parse(&build_url_from_widget_settings(
601            get_element_call_widget_settings(None, true, true, true, None, false),
602        ))
603        .unwrap();
604        let mut original_url = Url::parse(CONVERTED_URL).unwrap();
605        assert_eq!(get_query_sets(&generated_url).unwrap(), get_query_sets(&original_url).unwrap());
606        generated_url.set_fragment(None);
607        generated_url.set_query(None);
608        original_url.set_fragment(None);
609        original_url.set_query(None);
610        assert_eq!(generated_url, original_url);
611    }
612
613    #[test]
614    fn test_password_url_props_from_widget_settings() {
615        {
616            // PerParticipantKeys
617            let url = build_url_from_widget_settings(get_element_call_widget_settings(
618                Some(EncryptionSystem::PerParticipantKeys),
619                false,
620                false,
621                false,
622                None,
623                false,
624            ));
625            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
626            let expected_elements = [("perParticipantE2EE".to_owned(), "true".to_owned())];
627            for e in expected_elements {
628                assert!(
629                    query_set.contains(&e),
630                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
631                );
632            }
633        }
634        {
635            // Unencrypted
636            let url = build_url_from_widget_settings(get_element_call_widget_settings(
637                Some(EncryptionSystem::Unencrypted),
638                false,
639                false,
640                false,
641                None,
642                false,
643            ));
644            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
645            let expected_elements = ("perParticipantE2EE".to_owned(), "false".to_owned());
646            assert!(
647                query_set.contains(&expected_elements),
648                "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
649            );
650        }
651        {
652            // SharedSecret
653            let url = build_url_from_widget_settings(get_element_call_widget_settings(
654                Some(EncryptionSystem::SharedSecret { secret: "this_surely_is_save".to_owned() }),
655                false,
656                false,
657                false,
658                None,
659                false,
660            ));
661            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
662            let expected_elements = [("password".to_owned(), "this_surely_is_save".to_owned())];
663            for e in expected_elements {
664                assert!(
665                    query_set.contains(&e),
666                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
667                );
668            }
669        }
670    }
671
672    #[test]
673    fn test_controlled_output_url_props_from_widget_settings() {
674        {
675            // PerParticipantKeys
676            let url = build_url_from_widget_settings(get_element_call_widget_settings(
677                Some(EncryptionSystem::PerParticipantKeys),
678                false,
679                false,
680                false,
681                None,
682                true,
683            ));
684            let controlled_audio_element = ("controlledAudioDevices".to_owned(), "true".to_owned());
685            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
686            assert!(
687                query_set.contains(&controlled_audio_element),
688                "The query elements: \n{query_set:?}\nDid not contain: \n{controlled_audio_element:?}"
689            );
690        }
691    }
692
693    #[test]
694    fn test_intent_url_props_from_widget_settings() {
695        {
696            // no intent
697            let url = build_url_from_widget_settings(get_element_call_widget_settings(
698                None, false, false, false, None, false,
699            ));
700            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
701
702            let expected_unset_elements = ["intent".to_owned(), "skipLobby".to_owned()];
703
704            for e in expected_unset_elements {
705                assert!(
706                    !query_set.iter().any(|x| x.0 == e),
707                    "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
708                );
709            }
710        }
711        {
712            // Intent::JoinExisting
713            let url = build_url_from_widget_settings(get_element_call_widget_settings(
714                None,
715                false,
716                false,
717                false,
718                Some(Intent::JoinExisting),
719                false,
720            ));
721            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
722            let expected_elements = ("intent".to_owned(), "join_existing".to_owned());
723            assert!(
724                query_set.contains(&expected_elements),
725                "The url query elements for an unencrypted call: \n{query_set:?}\nDid not contain: \n{expected_elements:?}"
726            );
727
728            let expected_unset_elements = ["skipLobby".to_owned()];
729
730            for e in expected_unset_elements {
731                assert!(
732                    !query_set.iter().any(|x| x.0 == e),
733                    "The query elements: \n{query_set:?}\nShould not have contained: \n{e:?}"
734                );
735            }
736        }
737        {
738            // Intent::StartCall
739            let url = build_url_from_widget_settings(get_element_call_widget_settings(
740                None,
741                false,
742                false,
743                false,
744                Some(Intent::StartCall),
745                false,
746            ));
747            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
748
749            let expected_elements = [("intent".to_owned(), "start_call".to_owned())];
750            for e in expected_elements {
751                assert!(
752                    query_set.contains(&e),
753                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
754                );
755            }
756        }
757        {
758            // Intent::StartCallDm
759            let url = build_url_from_widget_settings(get_element_call_widget_settings(
760                None,
761                false,
762                false,
763                false,
764                Some(Intent::StartCallDm),
765                false,
766            ));
767            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
768
769            let expected_elements = [("intent".to_owned(), "start_call_dm".to_owned())];
770            for e in expected_elements {
771                assert!(
772                    query_set.contains(&e),
773                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
774                );
775            }
776        }
777        {
778            // Intent::JoinExistingDm
779            let url = build_url_from_widget_settings(get_element_call_widget_settings(
780                None,
781                false,
782                false,
783                false,
784                Some(Intent::JoinExistingDm),
785                false,
786            ));
787            let query_set = get_query_sets(&Url::parse(&url).unwrap()).unwrap().1;
788
789            let expected_elements = [("intent".to_owned(), "join_existing_dm".to_owned())];
790            for e in expected_elements {
791                assert!(
792                    query_set.contains(&e),
793                    "The query elements: \n{query_set:?}\nDid not contain: \n{e:?}"
794                );
795            }
796        }
797    }
798}