diff --git a/Cargo.lock b/Cargo.lock index 1d033e1..bc6bfd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,10 +812,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -890,6 +903,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "0.2.12" @@ -1085,7 +1104,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -1196,6 +1215,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1237,6 +1262,8 @@ checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -1297,6 +1324,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.177" @@ -1369,6 +1402,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", "vergen-gitcl", ] @@ -1419,7 +1453,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1825,6 +1859,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1907,6 +1951,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.9.2" @@ -2453,6 +2503,12 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -3098,6 +3154,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -3122,6 +3184,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "sha1_smol", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3203,7 +3277,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -3264,6 +3347,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.82" @@ -3636,6 +3753,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index e0b2398..83479e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,15 +23,16 @@ opentelemetry = { version = "0.31.0", features = ["metrics"] } opentelemetry_sdk = { version = "0.31.0", features = ["metrics", "rt-tokio"] } opentelemetry-prometheus = "0.31" prometheus = "0.14" -rust-telemetry = {version = "1.2.0", features = ["axum"]} +rust-telemetry = { version = "1.2.0", features = ["axum"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" time = { version = "0.3.44", features = ["serde"] } tokio = { version = "1.48.0", features = ["full"] } tower = "0.5.0" -tower-http = { version = "0.6.6", features = [ "catch-panic", "normalize-path" ] } +tower-http = { version = "0.6.6", features = ["catch-panic", "normalize-path"] } tracing = "0.1.41" tracing-subscriber = "0.3.20" +uuid = { version = "1", features = ["v4", "v5"] } [dev-dependencies] futures = "0.3.31" diff --git a/config.sample.yaml b/config.sample.yaml index cc19de8..8519a98 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -44,7 +44,11 @@ hedwig: image: null # headers specific for notifications sent to iOS devices: https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns#Send-a-POST-request-to-APNs - # those will be used both through FCM and direct APNS + # those will be used both through FCM and direct APNS (regular notifications). + # Note: VoIP pushes (data_message: ios_voip) use apns_push_type "voip" and the device's + # app_id (which must end in ".voip", e.g. com.example.app.voip) as the apns_topic. + # The app must register the pusher with notify_via: apns (the pushkey is a PushKit + # token, not an FCM token) and apns_key_file_path must be configured. apns_headers: # https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns#Know-when-to-use-push-types apns_push_type: background diff --git a/src/api.rs b/src/api.rs index 490ba32..18cf2ec 100644 --- a/src/api.rs +++ b/src/api.rs @@ -38,7 +38,7 @@ use crate::{ apns::APNSSender, fcm::FcmSender, metrics::{metrics_handler, HttpMetricsMiddleware}, - models::{Metrics, Notification, NotificationMethod, PushGatewayResponse}, + models::{DataMessageType, Metrics, Notification, NotificationMethod, PushGatewayResponse}, pusher, settings::Settings, }; @@ -61,18 +61,32 @@ pub async fn matrix_push( let mut retry_time = Duration::from_millis(250); let mut attempt = 0; - let notify_via = dev.notify_via.clone().unwrap_or_default(); + // VoIP pushkeys are PushKit tokens, so they are always routed via APNs; + // `notify_via: apns` may be set top-level or inside the pusher data. + let is_voip = matches!(dev.data_message_type(), DataMessageType::IosVoip); + let notify_via = dev.notification_method(); + loop { - if let Err(e) = match notify_via { + if let Err(e) = match ¬ify_via { NotificationMethod::Apns => { if let Some(apns_sender) = &app_state.apns_sender { - pusher::push_notification_apns( - ¬ification, - dev, - apns_sender, - &app_state.settings, - ) - .await + if is_voip { + pusher::push_notification_voip_apns( + ¬ification, + dev, + apns_sender, + &app_state.settings, + ) + .await + } else { + pusher::push_notification_apns( + ¬ification, + dev, + apns_sender, + &app_state.settings, + ) + .await + } } else { Err(crate::error::HedwigError { error: "APNS sender not configured".to_owned(), diff --git a/src/error.rs b/src/error.rs index d30cd0f..cd6b4bc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -42,6 +42,8 @@ pub enum ErrCode { APNSFailed, /// APNS not configured APNSNotConfigured, + /// VoIP push requested via a sender that does not support it (e.g. FCM) + VoipNotSupported, } /// Matrix error @@ -87,6 +89,13 @@ impl From for HedwigError { } } +impl From for HedwigError { + fn from(err: a2::Error) -> Self { + error!("apns error: {}", err); + Self { error: err.to_string(), errcode: ErrCode::APNSFailed } + } +} + impl axum::response::IntoResponse for HedwigError { fn into_response(self) -> Response { error!("Failed extracting request body {}", &self.error); diff --git a/src/models.rs b/src/models.rs index 3aaf7ed..c4373f4 100644 --- a/src/models.rs +++ b/src/models.rs @@ -97,6 +97,9 @@ pub enum DataMessageType { Android, /// Apns data message Ios, // Apple would hate me for this capitalization + /// iOS VoIP push via PushKit — routes directly through APNs with push-type + /// voip + IosVoip, } impl Device { @@ -112,9 +115,35 @@ impl Device { match self.data.as_ref().and_then(|d| d.data_message.as_ref()) { Some(msg) if msg == "android" => DataMessageType::Android, Some(msg) if msg == "ios" => DataMessageType::Ios, + Some(msg) if msg == "ios_voip" => DataMessageType::IosVoip, _ => DataMessageType::None, } } + + /// Returns how the notification should be delivered. + /// + /// Clients can only set custom keys inside the pusher's `data` dictionary + /// (the homeserver forwards them as `device.data`), so `notify_via` is + /// looked up there as well as at the top level. VoIP devices always use + /// APNs since PushKit tokens are not valid FCM tokens. + #[must_use] + pub fn notification_method(&self) -> NotificationMethod { + if let Some(method) = &self.notify_via { + return method.clone(); + } + if let Some(method) = self + .data + .as_ref() + .and_then(|d| d.data.get("notify_via")) + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + { + return method; + } + if matches!(self.data_message_type(), DataMessageType::IosVoip) { + return NotificationMethod::Apns; + } + NotificationMethod::default() + } } /// The notification request body diff --git a/src/pusher.rs b/src/pusher.rs index 57e4d90..8d3ee49 100644 --- a/src/pusher.rs +++ b/src/pusher.rs @@ -21,13 +21,14 @@ use std::sync::Arc; -use a2::{DefaultNotificationBuilder, NotificationBuilder, NotificationOptions}; +use a2::{DefaultNotificationBuilder, NotificationBuilder, NotificationOptions, PushType}; use firebae_cm::{ self, AndroidConfig, AndroidMessagePriority, AndroidNotification, ApnsConfig, MessageBody, }; use serde_json::json; use tokio::sync::Mutex; use tracing::debug; +use uuid::Uuid; use crate::{ apns::APNSSender, @@ -260,6 +261,13 @@ pub async fn push_notification_fcm( body.apns(ios_config); } + DataMessageType::IosVoip => { + // VoIP pushes are routed through push_notification_voip_apns, not FCM. + return Err(HedwigError { + error: "VoIP pushes must use the APNs sender, not FCM".to_owned(), + errcode: ErrCode::VoipNotSupported, + }); + } }; sender.lock().await.send(body).await?; @@ -312,3 +320,68 @@ pub async fn push_notification_apns( Ok(()) } + +/// Pushes a VoIP push notification to an iOS device via APNs / PushKit +/// +/// VoIP pushes use `apns-push-type: voip` and carry a raw payload (no `aps` +/// alert) with call metadata that the iOS `AppDelegate` reads to present the +/// native CallKit incoming-call screen. +pub async fn push_notification_voip_apns( + notification: &Notification, + device: &Device, + sender: &Arc, + settings: &Settings, +) -> Result<(), HedwigError> { + if !device.app_id.starts_with(&settings.hedwig.app_id) { + return Err(HedwigError { error: "Invalid app id!".to_owned(), errcode: ErrCode::BadJson }); + } + + // The APNs topic must match the PushKit registration (bundle-id + ".voip") + if !device.app_id.ends_with(".voip") { + return Err(HedwigError { + error: "VoIP pushes require an app_id ending in '.voip'".to_owned(), + errcode: ErrCode::BadJson, + }); + } + + let options = NotificationOptions { + apns_topic: Some(device.app_id.clone()), + apns_push_type: Some(PushType::Voip), + ..Default::default() + }; + + let mut payload = DefaultNotificationBuilder::new().build(device.pushkey.clone(), options); + + // Caller display name: prefer sender_display_name, fall back to room_name + let name = notification + .sender_display_name + .as_deref() + .or(notification.room_name.as_deref()) + .unwrap_or("Unknown"); + + // Stable UUID derived from event_id so CallKit can deduplicate; falls back to + // a random UUID when the homeserver sends event_id_only format without an id. + // The id must be a valid UUID string (the app parses it with + // UUID(uuidString:)), so the Matrix event_id ($abc...) is hashed into a UUIDv5 + // rather than passed raw. + let call_id = notification.event_id.as_ref().map_or_else( + || Uuid::new_v4().to_string(), + |event_id| Uuid::new_v5(&Uuid::NAMESPACE_OID, event_id.as_bytes()).to_string(), + ); + + // Fields read by AppDelegate via PKPushPayload.dictionaryPayload + payload.add_custom_data("id".to_owned(), &call_id)?; + payload.add_custom_data("nameCaller".to_owned(), &name)?; + // No caller address is known at push time, so the CallKit handle is + // intentionally left empty; the app resolves the call via the id. + payload.add_custom_data("handle".to_owned(), &"")?; + // The call type is not known at push time (event content is encrypted), so + // default to an audio call; the app updates the UI after decryption. + payload.add_custom_data("isVideo".to_owned(), &false)?; + + debug!("Pushing VoIP notification to {:?} device", device.data_message_type()); + + sender.send(payload).await?; + + Ok(()) +} diff --git a/src/settings.rs b/src/settings.rs index e919026..4388082 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -153,7 +153,8 @@ impl<'de> Deserialize<'de> for DeserializablePushType { "alert" => PushType::Alert, "background" => PushType::Background, // "location" => PushType::Location, - // "voip" => PushType::Voip, + // "voip" is intentionally not configurable: it is set internally for + // `data_message: ios_voip` devices and would break regular notifications. // "fileprovider" => PushType::FileProvider, // "mdm" => PushType::Mdm, // "liveactivity" => PushType::LiveActivity, diff --git a/tests/pusher.rs b/tests/pusher.rs index 355c5bf..2976e76 100644 --- a/tests/pusher.rs +++ b/tests/pusher.rs @@ -577,6 +577,139 @@ async fn many_devices() -> Result<(), Box> { Ok(()) } +#[tokio::test] +async fn voip_push() -> Result<(), Box> { + let (fcm_tx, mut _fcm_rx) = mpsc::channel(1337); + let (apns_tx, mut apns_rx) = mpsc::channel(1337); + let mut service = setup_server( + Box::new(FakeFcmSender(fcm_tx)), + Some(Box::new(FakeAPNSSender { tx: apns_tx })), + )?; + + let msg = json!({ + "notification": { + "counts": { "unread": 1_i32 }, + "devices": [{ + "app_id": "com.famedly.🦊.voip", + "data": { + "format": "event_id_only", + "data_message": "ios_voip" + }, + "pushkey": "voip_device_token", + "pushkey_ts": 1_655_896_032_i32, + "notify_via": "apns" + }], + "room_id": "!room:server", + "event_id": "$eventabc123", + "sender": "@alice:server", + "sender_display_name": "Alice", + "prio": "high" + } + }); + + let resp = run_request(&mut service, msg).await?; + assert_eq!(&resp, "{\"rejected\":[]}"); + + let payload = apns_rx.recv().await.unwrap(); + assert_eq!(payload.device_token, "voip_device_token"); + + let json: Value = serde_json::from_str(&payload.to_json_string()?)?; + // id is a stable UUIDv5 derived from event_id (must be a valid UUID string, + // since the app parses it with UUID(uuidString:)) + let expected_id = + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, "$eventabc123".as_bytes()).to_string(); + assert_eq!(json["id"], expected_id); + assert_eq!(json["nameCaller"], "Alice"); + assert_eq!(json["handle"], ""); + assert_eq!(json["isVideo"], false); + + Ok(()) +} + +#[tokio::test] +async fn voip_push_no_sender_display_name_falls_back_to_room_name( +) -> Result<(), Box> { + let (fcm_tx, mut _fcm_rx) = mpsc::channel(1337); + let (apns_tx, mut apns_rx) = mpsc::channel(1337); + let mut service = setup_server( + Box::new(FakeFcmSender(fcm_tx)), + Some(Box::new(FakeAPNSSender { tx: apns_tx })), + )?; + + let msg = json!({ + "notification": { + "counts": { "unread": 1_i32 }, + "devices": [{ + "app_id": "com.famedly.🦊.voip", + "data": { + "format": "event_id_only", + "data_message": "ios_voip" + }, + "pushkey": "voip_device_token", + "pushkey_ts": 1_655_896_032_i32, + "notify_via": "apns" + }], + "room_id": "!room:server", + "room_name": "My Room", + "prio": "high" + } + }); + + let resp = run_request(&mut service, msg).await?; + assert_eq!(&resp, "{\"rejected\":[]}"); + + let payload = apns_rx.recv().await.unwrap(); + let json: Value = serde_json::from_str(&payload.to_json_string()?)?; + assert_eq!(json["nameCaller"], "My Room"); + // No event_id: id must be a non-empty UUID string + assert!(json.get("id").and_then(|v| v.as_str()).is_some_and(|s| !s.is_empty())); + + Ok(()) +} + +#[tokio::test] +async fn voip_push_without_top_level_notify_via_routes_to_apns( +) -> Result<(), Box> { + let (fcm_tx, mut _fcm_rx) = mpsc::channel(1337); + let (apns_tx, mut apns_rx) = mpsc::channel(1337); + let mut service = setup_server( + Box::new(FakeFcmSender(fcm_tx)), + Some(Box::new(FakeAPNSSender { tx: apns_tx })), + )?; + + // Homeservers forward custom pusher keys inside `device.data` and never at + // the device top level, so `notify_via` must be honoured from there (and + // VoIP devices must route via APNs even without it). + let msg = json!({ + "notification": { + "counts": { "unread": 1_i32 }, + "devices": [{ + "app_id": "com.famedly.🦊.voip", + "data": { + "format": "event_id_only", + "data_message": "ios_voip", + "notify_via": "apns" + }, + "pushkey": "voip_device_token", + "pushkey_ts": 1_655_896_032_i32 + }], + "room_id": "!room:server", + "sender_display_name": "Alice", + "prio": "high" + } + }); + + let resp = run_request(&mut service, msg).await?; + assert_eq!(&resp, "{\"rejected\":[]}"); + + let payload = apns_rx.recv().await.unwrap(); + assert_eq!(payload.device_token, "voip_device_token"); + let json: Value = serde_json::from_str(&payload.to_json_string()?)?; + assert_eq!(json["nameCaller"], "Alice"); + + Ok(()) +} + #[derive(Debug)] struct PanickingFcmSender; #[async_trait]