firebase_rs_sdk/messaging/
api.rs

1#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
2use std::sync::{
3    atomic::{AtomicUsize, Ordering},
4    Mutex,
5};
6use std::sync::{Arc, LazyLock};
7
8#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
9use rand::distributions::Alphanumeric;
10#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
11use rand::{thread_rng, Rng};
12
13use crate::app;
14use crate::app::FirebaseApp;
15use crate::component::types::{
16    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
17};
18use crate::component::{Component, ComponentType};
19#[cfg(all(
20    feature = "wasm-web",
21    target_arch = "wasm32",
22    feature = "experimental-indexed-db"
23))]
24use crate::installations::extract_app_config;
25#[cfg(all(
26    feature = "wasm-web",
27    target_arch = "wasm32",
28    feature = "experimental-indexed-db"
29))]
30use crate::installations::{get_installations_internal, InstallationEntryData};
31use crate::messaging::constants::MESSAGING_COMPONENT_NAME;
32#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
33use crate::messaging::error::invalid_argument;
34use crate::messaging::error::{
35    available_in_service_worker, available_in_window, internal_error, token_deletion_failed,
36    MessagingResult,
37};
38#[cfg(all(
39    feature = "wasm-web",
40    target_arch = "wasm32",
41    feature = "experimental-indexed-db"
42))]
43use crate::messaging::fcm_rest::{
44    FcmClient, FcmRegistrationRequest, FcmSubscription, FcmUpdateRequest,
45};
46#[cfg(all(
47    feature = "wasm-web",
48    target_arch = "wasm32",
49    feature = "experimental-indexed-db"
50))]
51use crate::messaging::token_store::{self, InstallationInfo, SubscriptionInfo, TokenRecord};
52#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
53use crate::messaging::token_store::{self, InstallationInfo, TokenRecord};
54#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
55use crate::messaging::types::MessagePayload;
56use crate::messaging::types::{MessageHandler, Unsubscribe};
57#[cfg(all(
58    feature = "wasm-web",
59    target_arch = "wasm32",
60    feature = "experimental-indexed-db"
61))]
62use std::time::{SystemTime, UNIX_EPOCH};
63#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
64use web_sys::NotificationPermission;
65
66#[cfg(all(
67    feature = "wasm-web",
68    target_arch = "wasm32",
69    feature = "experimental-indexed-db"
70))]
71use crate::messaging::constants::DEFAULT_VAPID_KEY;
72#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
73use crate::messaging::error::{permission_blocked, unsupported_browser};
74#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
75use wasm_bindgen::{JsCast, JsValue};
76#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
77use wasm_bindgen_futures::JsFuture;
78
79#[derive(Clone, Debug)]
80pub struct Messaging {
81    inner: Arc<MessagingInner>,
82}
83
84#[derive(Debug)]
85struct MessagingInner {
86    app: FirebaseApp,
87    #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
88    on_message_handler: Mutex<Option<HandlerEntry>>,
89    #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
90    on_background_message_handler: Mutex<Option<HandlerEntry>>,
91}
92
93#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
94#[derive(Clone)]
95struct HandlerEntry {
96    id: usize,
97    #[allow(dead_code)]
98    handler: MessageHandler,
99}
100
101#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
102impl std::fmt::Debug for HandlerEntry {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("HandlerEntry")
105            .field("id", &self.id)
106            .finish()
107    }
108}
109
110#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
111static NEXT_ON_MESSAGE_ID: AtomicUsize = AtomicUsize::new(1);
112
113#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
114static NEXT_ON_BACKGROUND_ID: AtomicUsize = AtomicUsize::new(1);
115
116/// Notification permission states as exposed by the Web Notifications API.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub enum PermissionState {
119    /// The user has not decided whether to allow notifications.
120    Default,
121    /// The user granted notification permissions.
122    Granted,
123    /// The user denied notification permissions.
124    Denied,
125}
126
127impl Messaging {
128    fn new(app: FirebaseApp) -> Self {
129        let inner = MessagingInner {
130            app,
131            #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
132            on_message_handler: Mutex::new(None),
133            #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
134            on_background_message_handler: Mutex::new(None),
135        };
136        Self {
137            inner: Arc::new(inner),
138        }
139    }
140
141    pub fn app(&self) -> &FirebaseApp {
142        &self.inner.app
143    }
144
145    /// Requests browser notification permission.
146    ///
147    /// Port of the permission flow triggered by
148    /// `packages/messaging/src/api/getToken.ts` in the Firebase JS SDK.
149    pub async fn request_permission(&self) -> MessagingResult<PermissionState> {
150        request_permission_impl().await
151    }
152
153    pub async fn get_token(&self, vapid_key: Option<&str>) -> MessagingResult<String> {
154        get_token_impl(self, vapid_key).await
155    }
156
157    pub async fn delete_token(&self) -> MessagingResult<bool> {
158        delete_token_impl(self).await
159    }
160
161    #[cfg_attr(not(test), allow(dead_code))]
162    #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
163    #[allow(dead_code)]
164    pub(crate) fn dispatch_on_message(&self, payload: MessagePayload) {
165        let handler = {
166            self.inner
167                .on_message_handler
168                .lock()
169                .unwrap()
170                .as_ref()
171                .map(|entry| entry.handler.clone())
172        };
173        if let Some(handler) = handler {
174            handler(payload);
175        }
176    }
177
178    #[cfg_attr(not(test), allow(dead_code))]
179    #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
180    #[allow(dead_code)]
181    pub(crate) fn dispatch_on_background_message(&self, payload: MessagePayload) {
182        let handler = {
183            self.inner
184                .on_background_message_handler
185                .lock()
186                .unwrap()
187                .as_ref()
188                .map(|entry| entry.handler.clone())
189        };
190        if let Some(handler) = handler {
191            handler(payload);
192        }
193    }
194}
195
196//#[cfg(not(all(
197//    feature = "wasm-web",
198//    target_arch = "wasm32",
199//    feature = "experimental-indexed-db"
200//)))]
201#[cfg(not(target_arch = "wasm32"))]
202fn generate_token() -> String {
203    thread_rng()
204        .sample_iter(&Alphanumeric)
205        .map(char::from)
206        .take(32)
207        .collect()
208}
209
210#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
211async fn request_permission_impl() -> MessagingResult<PermissionState> {
212    use crate::messaging::support::is_supported;
213
214    let window = web_sys::window()
215        .ok_or_else(|| available_in_window("request_permission must run in a Window context"))?;
216
217    // Access navigator to match the JS guard (throws if navigator is missing).
218    let _navigator = window.navigator();
219
220    if !is_supported() {
221        return Err(unsupported_browser(
222            "This browser does not expose the APIs required for Firebase Messaging.",
223        ));
224    }
225
226    let current = web_sys::Notification::permission();
227    match permission_state_from_enum(current) {
228        PermissionState::Granted => return Ok(PermissionState::Granted),
229        PermissionState::Denied => {
230            return Err(permission_blocked(
231                "Notification permission was previously blocked by the user.",
232            ))
233        }
234        PermissionState::Default => {}
235    }
236
237    let promise = web_sys::Notification::request_permission()
238        .map_err(|err| internal_error(format_js_error("requestPermission", err)))?;
239    let result = JsFuture::from(promise)
240        .await
241        .map_err(|err| internal_error(format_js_error("requestPermission", err)))?;
242
243    let status = result.as_string();
244    let permission_state = status
245        .as_deref()
246        .map(permission_state_from_str)
247        .unwrap_or_else(|| permission_state_from_enum(web_sys::Notification::permission()));
248
249    match permission_state {
250        PermissionState::Granted => Ok(PermissionState::Granted),
251        PermissionState::Denied | PermissionState::Default => Err(permission_blocked(
252            "Notification permission not granted by the user.",
253        )),
254    }
255}
256
257#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
258fn permission_state_from_enum(value: NotificationPermission) -> PermissionState {
259    match value {
260        NotificationPermission::Granted => PermissionState::Granted,
261        NotificationPermission::Denied => PermissionState::Denied,
262        _ => PermissionState::Default,
263    }
264}
265
266#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
267fn permission_state_from_str(value: &str) -> PermissionState {
268    match value {
269        "granted" => PermissionState::Granted,
270        "denied" => PermissionState::Denied,
271        _ => PermissionState::Default,
272    }
273}
274
275#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
276fn format_js_error(operation: &str, err: JsValue) -> String {
277    if let Some(message) = err.as_string() {
278        format!("{operation} failed: {message}")
279    } else if let Some(exception) = err.dyn_ref::<web_sys::DomException>() {
280        format!(
281            "{operation} failed: {}: {}",
282            exception.name(),
283            exception.message()
284        )
285    } else {
286        format!("{operation} failed: {:?}", err)
287    }
288}
289
290#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
291async fn request_permission_impl() -> MessagingResult<PermissionState> {
292    Ok(PermissionState::Granted)
293}
294
295static MESSAGING_COMPONENT: LazyLock<()> = LazyLock::new(|| {
296    let component = Component::new(
297        MESSAGING_COMPONENT_NAME,
298        Arc::new(messaging_factory),
299        ComponentType::Public,
300    )
301    .with_instantiation_mode(InstantiationMode::Lazy);
302    let _ = app::register_component(component);
303});
304
305fn messaging_factory(
306    container: &crate::component::ComponentContainer,
307    _options: InstanceFactoryOptions,
308) -> Result<DynService, ComponentError> {
309    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
310        ComponentError::InitializationFailed {
311            name: MESSAGING_COMPONENT_NAME.to_string(),
312            reason: "Firebase app not attached to component container".to_string(),
313        }
314    })?;
315    let messaging = Messaging::new((*app).clone());
316    Ok(Arc::new(messaging) as DynService)
317}
318
319fn ensure_registered() {
320    LazyLock::force(&MESSAGING_COMPONENT);
321}
322
323pub fn register_messaging_component() {
324    ensure_registered();
325}
326
327pub async fn get_messaging(app: Option<FirebaseApp>) -> MessagingResult<Arc<Messaging>> {
328    ensure_registered();
329    let app = match app {
330        Some(app) => app,
331        None => crate::app::get_app(None)
332            .await
333            .map_err(|err| internal_error(err.to_string()))?,
334    };
335
336    let provider = app::get_provider(&app, MESSAGING_COMPONENT_NAME);
337    if let Some(messaging) = provider.get_immediate::<Messaging>() {
338        Ok(messaging)
339    } else {
340        provider
341            .initialize::<Messaging>(serde_json::Value::Null, None)
342            .map_err(|err| internal_error(err.to_string()))
343    }
344}
345
346async fn get_token_impl(messaging: &Messaging, vapid_key: Option<&str>) -> MessagingResult<String> {
347    #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
348    {
349        get_token_wasm(messaging, vapid_key).await
350    }
351
352    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
353    {
354        get_token_native(messaging, vapid_key).await
355    }
356}
357
358#[allow(dead_code)]
359fn app_store_key(messaging: &Messaging) -> String {
360    messaging.inner.app.name().to_string()
361}
362
363#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
364pub fn on_message(messaging: &Messaging, handler: MessageHandler) -> MessagingResult<Unsubscribe> {
365    if web_sys::window().is_none() {
366        return Err(available_in_window(
367            "on_message must be called in a Window context",
368        ));
369    }
370
371    let id = NEXT_ON_MESSAGE_ID.fetch_add(1, Ordering::SeqCst);
372    let messaging_clone = messaging.clone();
373    {
374        let mut guard = messaging_clone.inner.on_message_handler.lock().unwrap();
375        *guard = Some(HandlerEntry { id, handler });
376    }
377
378    Ok(Box::new(move || {
379        let mut guard = messaging_clone.inner.on_message_handler.lock().unwrap();
380        if guard.as_ref().map(|entry| entry.id) == Some(id) {
381            *guard = None;
382        }
383    }))
384}
385
386#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
387pub fn on_message(
388    _messaging: &Messaging,
389    _handler: MessageHandler,
390) -> MessagingResult<Unsubscribe> {
391    Err(available_in_window(
392        "on_message must be called in a Window context (wasm target only)",
393    ))
394}
395
396#[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
397pub fn on_background_message(
398    messaging: &Messaging,
399    handler: MessageHandler,
400) -> MessagingResult<Unsubscribe> {
401    if web_sys::window().is_some() {
402        return Err(available_in_service_worker(
403            "on_background_message must be called in a Service Worker context",
404        ));
405    }
406
407    let id = NEXT_ON_BACKGROUND_ID.fetch_add(1, Ordering::SeqCst);
408    let messaging_clone = messaging.clone();
409    {
410        let mut guard = messaging_clone
411            .inner
412            .on_background_message_handler
413            .lock()
414            .unwrap();
415        *guard = Some(HandlerEntry { id, handler });
416    }
417
418    Ok(Box::new(move || {
419        let mut guard = messaging_clone
420            .inner
421            .on_background_message_handler
422            .lock()
423            .unwrap();
424        if guard.as_ref().map(|entry| entry.id) == Some(id) {
425            *guard = None;
426        }
427    }))
428}
429
430#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
431pub fn on_background_message(
432    _messaging: &Messaging,
433    _handler: MessageHandler,
434) -> MessagingResult<Unsubscribe> {
435    Err(available_in_service_worker(
436        "on_background_message must be called in a Service Worker context (wasm target only)",
437    ))
438}
439
440#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
441async fn get_token_native(
442    messaging: &Messaging,
443    vapid_key: Option<&str>,
444) -> MessagingResult<String> {
445    if let Some(key) = vapid_key {
446        if key.trim().is_empty() {
447            return Err(invalid_argument("VAPID key must not be empty"));
448        }
449    }
450
451    let store_key = app_store_key(messaging);
452    if let Some(record) = token_store::read_token(&store_key)? {
453        if !record.is_expired(current_timestamp_ms(), TOKEN_EXPIRATION_MS) {
454            return Ok(record.token);
455        }
456    }
457
458    let token = generate_token();
459    let record = TokenRecord {
460        token: token.clone(),
461        create_time_ms: current_timestamp_ms(),
462        subscription: None,
463        installation: dummy_installation_info(),
464    };
465    token_store::write_token(&store_key, &record)?;
466    Ok(token)
467}
468
469#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
470async fn delete_token_impl(messaging: &Messaging) -> MessagingResult<bool> {
471    let store_key = app_store_key(messaging);
472    if token_store::remove_token(&store_key)? {
473        Ok(true)
474    } else {
475        Err(token_deletion_failed("No token stored for this app"))
476    }
477}
478
479#[cfg(all(
480    feature = "wasm-web",
481    target_arch = "wasm32",
482    feature = "experimental-indexed-db"
483))]
484async fn get_token_wasm(messaging: &Messaging, vapid_key: Option<&str>) -> MessagingResult<String> {
485    use crate::messaging::subscription::PushSubscriptionManager;
486    use crate::messaging::support::is_supported;
487    use crate::messaging::sw_manager::ServiceWorkerManager;
488
489    if !is_supported() {
490        return Err(unsupported_browser(
491            "This browser does not expose the APIs required for Firebase Messaging.",
492        ));
493    }
494
495    let vapid_key = vapid_key
496        .filter(|key| !key.trim().is_empty())
497        .unwrap_or(DEFAULT_VAPID_KEY);
498    let app_config =
499        extract_app_config(messaging.app()).map_err(|err| internal_error(err.to_string()))?;
500    let fcm_client = FcmClient::new()?;
501
502    let mut sw_manager = ServiceWorkerManager::new();
503    let registration = sw_manager.register_default().await?;
504    let scope = registration.as_web_sys().scope();
505
506    let mut push_manager = PushSubscriptionManager::new();
507    let subscription = push_manager.subscribe(&registration, vapid_key).await?;
508    let details = subscription.details()?;
509
510    let subscription_info = SubscriptionInfo {
511        vapid_key: vapid_key.to_string(),
512        scope,
513        endpoint: details.endpoint.clone(),
514        auth: details.auth.clone(),
515        p256dh: details.p256dh.clone(),
516    };
517
518    let store_key = app_store_key(messaging);
519    let now_ms = current_timestamp_ms();
520    let subscription_payload = FcmSubscription {
521        endpoint: &subscription_info.endpoint,
522        auth: &subscription_info.auth,
523        p256dh: &subscription_info.p256dh,
524        application_pub_key: if subscription_info.vapid_key == DEFAULT_VAPID_KEY {
525            None
526        } else {
527            Some(subscription_info.vapid_key.as_str())
528        },
529    };
530
531    if let Some(record) = token_store::read_token(&store_key).await? {
532        if let Some(existing) = &record.subscription {
533            if existing == &subscription_info {
534                let installation_needs_refresh = record.installation.auth_token_expired(now_ms);
535                if !record.is_expired(now_ms, TOKEN_EXPIRATION_MS) && !installation_needs_refresh {
536                    return Ok(record.token);
537                }
538
539                let installation_info =
540                    fetch_installation_info(messaging, installation_needs_refresh).await?;
541                let update_request = FcmUpdateRequest {
542                    registration_token: &record.token,
543                    registration: FcmRegistrationRequest {
544                        project_id: &app_config.project_id,
545                        api_key: &app_config.api_key,
546                        installation_auth_token: &installation_info.auth_token,
547                        subscription: subscription_payload.clone(),
548                    },
549                };
550
551                let token = fcm_client.update_token(&update_request).await?;
552                let record = TokenRecord {
553                    token: token.clone(),
554                    create_time_ms: now_ms,
555                    subscription: Some(subscription_info),
556                    installation: installation_info,
557                };
558                token_store::write_token(&store_key, &record).await?;
559                return Ok(token);
560            }
561        }
562
563        let installation_info = fetch_installation_info(messaging, true).await?;
564        let _ = fcm_client
565            .delete_token(
566                &app_config.project_id,
567                &app_config.api_key,
568                &installation_info.auth_token,
569                &record.token,
570            )
571            .await;
572        let _ = token_store::remove_token(&store_key).await?;
573    }
574
575    let installation_info = fetch_installation_info(messaging, true).await?;
576    let registration_request = FcmRegistrationRequest {
577        project_id: &app_config.project_id,
578        api_key: &app_config.api_key,
579        installation_auth_token: &installation_info.auth_token,
580        subscription: subscription_payload,
581    };
582
583    let token = fcm_client.register_token(&registration_request).await?;
584    let record = TokenRecord {
585        token: token.clone(),
586        create_time_ms: now_ms,
587        subscription: Some(subscription_info),
588        installation: installation_info,
589    };
590    token_store::write_token(&store_key, &record).await?;
591    Ok(token)
592}
593
594#[cfg(all(
595    feature = "wasm-web",
596    target_arch = "wasm32",
597    not(feature = "experimental-indexed-db")
598))]
599async fn get_token_wasm(_: &Messaging, _: Option<&str>) -> MessagingResult<String> {
600    Err(unsupported_browser(
601        "Firebase Messaging token persistence requires the `experimental-indexed-db` feature on wasm targets.",
602    ))
603}
604
605#[cfg(all(
606    feature = "wasm-web",
607    target_arch = "wasm32",
608    feature = "experimental-indexed-db"
609))]
610async fn delete_token_impl(messaging: &Messaging) -> MessagingResult<bool> {
611    use crate::messaging::subscription::PushSubscriptionManager;
612    use crate::messaging::sw_manager::ServiceWorkerManager;
613
614    let store_key = app_store_key(messaging);
615    let record = match token_store::read_token(&store_key).await? {
616        Some(record) => record,
617        None => return Err(token_deletion_failed("No token stored for this app")),
618    };
619
620    let app_config =
621        extract_app_config(messaging.app()).map_err(|err| internal_error(err.to_string()))?;
622    let installation_info = fetch_installation_info(messaging, true).await?;
623    let fcm_client = FcmClient::new()?;
624
625    fcm_client
626        .delete_token(
627            &app_config.project_id,
628            &app_config.api_key,
629            &installation_info.auth_token,
630            &record.token,
631        )
632        .await?;
633
634    let removed = token_store::remove_token(&store_key).await?;
635
636    let mut sw_manager = ServiceWorkerManager::new();
637    let registration = sw_manager.register_default().await?;
638    let sw_registration = registration.as_web_sys();
639    let push_manager = sw_registration
640        .push_manager()
641        .map_err(|err| internal_error(format_js_error("pushManager", err)))?;
642    let subscription_value = JsFuture::from(
643        push_manager
644            .get_subscription()
645            .map_err(|err| internal_error(format_js_error("getSubscription", err)))?,
646    )
647    .await
648    .map_err(|err| internal_error(format_js_error("getSubscription", err)))?;
649
650    if !subscription_value.is_null() && !subscription_value.is_undefined() {
651        let subscription: web_sys::PushSubscription = subscription_value
652            .dyn_into()
653            .map_err(|_| internal_error("PushManager.getSubscription returned unexpected value"))?;
654        let promise = subscription
655            .unsubscribe()
656            .map_err(|err| internal_error(format_js_error("PushSubscription.unsubscribe", err)))?;
657        let _ = JsFuture::from(promise)
658            .await
659            .map_err(|err| internal_error(format_js_error("PushSubscription.unsubscribe", err)))?;
660    }
661
662    let mut push_manager = PushSubscriptionManager::new();
663    push_manager.clear_cache();
664
665    Ok(removed)
666}
667
668#[cfg(all(
669    feature = "wasm-web",
670    target_arch = "wasm32",
671    not(feature = "experimental-indexed-db")
672))]
673async fn delete_token_impl(_: &Messaging) -> MessagingResult<bool> {
674    Err(token_deletion_failed(
675        "Token deletion is unavailable without the `experimental-indexed-db` feature on wasm targets.",
676    ))
677}
678
679#[cfg(all(
680    feature = "wasm-web",
681    target_arch = "wasm32",
682    feature = "experimental-indexed-db"
683))]
684async fn fetch_installation_info(
685    messaging: &Messaging,
686    force_refresh: bool,
687) -> MessagingResult<InstallationInfo> {
688    let internal = get_installations_internal(Some(messaging.inner.app.clone()))
689        .map_err(|err| internal_error(format!("Failed to initialise installations: {err}")))?;
690
691    let InstallationEntryData {
692        fid,
693        refresh_token,
694        mut auth_token,
695    } = internal
696        .get_installation_entry()
697        .await
698        .map_err(|err| internal_error(format!("Failed to load installation entry: {err}")))?;
699
700    if force_refresh || auth_token.is_expired() {
701        auth_token = internal.get_token(true).await.map_err(|err| {
702            internal_error(format!("Failed to refresh installation token: {err}"))
703        })?;
704    }
705
706    let expires_at_ms = system_time_to_millis(auth_token.expires_at)?;
707
708    Ok(InstallationInfo {
709        fid,
710        refresh_token,
711        auth_token: auth_token.token,
712        auth_token_expiration_ms: expires_at_ms,
713    })
714}
715
716#[cfg(all(
717    feature = "wasm-web",
718    target_arch = "wasm32",
719    feature = "experimental-indexed-db"
720))]
721fn system_time_to_millis(time: SystemTime) -> MessagingResult<u64> {
722    time.duration_since(UNIX_EPOCH)
723        .map(|duration| duration.as_millis() as u64)
724        .map_err(|_| internal_error("Installation token expiration precedes UNIX epoch"))
725}
726
727#[allow(dead_code)]
728fn current_timestamp_ms() -> u64 {
729    #[cfg(all(feature = "wasm-web", target_arch = "wasm32"))]
730    {
731        js_sys::Date::now() as u64
732    }
733
734    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
735    {
736        use std::time::{SystemTime, UNIX_EPOCH};
737
738        SystemTime::now()
739            .duration_since(UNIX_EPOCH)
740            .unwrap_or_default()
741            .as_millis() as u64
742    }
743}
744
745#[allow(dead_code)]
746const TOKEN_EXPIRATION_MS: u64 = 7 * 24 * 60 * 60 * 1000;
747
748#[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
749fn dummy_installation_info() -> InstallationInfo {
750    InstallationInfo {
751        fid: "placeholder".to_string(),
752        refresh_token: String::new(),
753        auth_token: String::new(),
754        auth_token_expiration_ms: u64::MAX,
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use crate::app::initialize_app;
762    use crate::app::{FirebaseAppSettings, FirebaseOptions};
763    #[allow(unused_imports)]
764    use std::task::Waker;
765    use std::task::{RawWaker, RawWakerVTable};
766
767    fn unique_settings() -> FirebaseAppSettings {
768        use std::sync::atomic::{AtomicUsize, Ordering};
769        static COUNTER: AtomicUsize = AtomicUsize::new(0);
770        FirebaseAppSettings {
771            name: Some(format!(
772                "messaging-{}",
773                COUNTER.fetch_add(1, Ordering::SeqCst)
774            )),
775            ..Default::default()
776        }
777    }
778
779    #[tokio::test(flavor = "current_thread")]
780    async fn token_is_stable_until_deleted() {
781        let options = FirebaseOptions {
782            project_id: Some("project".into()),
783            ..Default::default()
784        };
785        let app = initialize_app(options, Some(unique_settings()))
786            .await
787            .expect("init app");
788        let messaging = get_messaging(Some(app)).await.unwrap();
789        let permission = messaging.request_permission().await.unwrap();
790        assert_eq!(permission, PermissionState::Granted);
791        let token1 = messaging.get_token(None).await.unwrap();
792        let token2 = messaging.get_token(None).await.unwrap();
793        assert_eq!(token1, token2);
794        messaging.delete_token().await.unwrap();
795        let token3 = messaging.get_token(None).await.unwrap();
796        assert_ne!(token1, token3);
797    }
798
799    #[tokio::test(flavor = "current_thread")]
800    async fn get_token_with_empty_vapid_key_returns_error() {
801        let options = FirebaseOptions {
802            project_id: Some("project".into()),
803            ..Default::default()
804        };
805        let app = initialize_app(options, Some(unique_settings()))
806            .await
807            .expect("init app");
808        let messaging = get_messaging(Some(app)).await.unwrap();
809        let err = messaging.get_token(Some(" ")).await.unwrap_err();
810        assert_eq!(err.code_str(), "messaging/invalid-argument");
811    }
812
813    #[tokio::test(flavor = "current_thread")]
814    async fn delete_token_without_existing_token_returns_error() {
815        let options = FirebaseOptions {
816            project_id: Some("project".into()),
817            ..Default::default()
818        };
819        let app = initialize_app(options, Some(unique_settings()))
820            .await
821            .expect("init app");
822        let messaging = get_messaging(Some(app)).await.unwrap();
823        let err = messaging.delete_token().await.unwrap_err();
824        assert_eq!(err.code_str(), "messaging/token-deletion-failed");
825    }
826
827    #[tokio::test(flavor = "current_thread")]
828    async fn token_persists_across_messaging_instances() {
829        let options = FirebaseOptions {
830            project_id: Some("project".into()),
831            ..Default::default()
832        };
833        let app = initialize_app(options, Some(unique_settings()))
834            .await
835            .expect("init app");
836        let messaging = get_messaging(Some(app.clone())).await.unwrap();
837        let token1 = messaging.get_token(None).await.unwrap();
838
839        // Re-fetch messaging for the same app and validate the stored token is reused.
840        let messaging_again = get_messaging(Some(app)).await.unwrap();
841        let token2 = messaging_again.get_token(None).await.unwrap();
842        assert_eq!(token1, token2);
843
844        messaging_again.delete_token().await.unwrap();
845    }
846
847    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
848    #[tokio::test(flavor = "current_thread")]
849    async fn on_message_returns_window_error_on_non_wasm() {
850        let options = FirebaseOptions {
851            project_id: Some("project".into()),
852            ..Default::default()
853        };
854        let app = initialize_app(options, Some(unique_settings()))
855            .await
856            .expect("init app");
857        let messaging = get_messaging(Some(app)).await.unwrap();
858
859        let handler: MessageHandler = Arc::new(|_| {});
860        let err = match super::on_message(&messaging, handler) {
861            Ok(unsub) => {
862                unsub();
863                panic!("expected on_message to fail on non-wasm targets");
864            }
865            Err(err) => err,
866        };
867        assert_eq!(err.code_str(), "messaging/available-in-window");
868    }
869
870    #[cfg(not(all(feature = "wasm-web", target_arch = "wasm32")))]
871    #[tokio::test(flavor = "current_thread")]
872    async fn on_background_message_returns_sw_error_on_non_wasm() {
873        let options = FirebaseOptions {
874            project_id: Some("project".into()),
875            ..Default::default()
876        };
877        let app = initialize_app(options, Some(unique_settings()))
878            .await
879            .expect("init app");
880        let messaging = get_messaging(Some(app)).await.unwrap();
881
882        let handler: MessageHandler = Arc::new(|_| {});
883        let err = match super::on_background_message(&messaging, handler) {
884            Ok(unsub) => {
885                unsub();
886                panic!("expected on_background_message to fail on non-wasm targets");
887            }
888            Err(err) => err,
889        };
890        assert_eq!(err.code_str(), "messaging/available-in-sw");
891    }
892
893    #[allow(dead_code)]
894    fn noop_raw_waker() -> RawWaker {
895        RawWaker::new(std::ptr::null(), &NOOP_RAW_WAKER_VTABLE)
896    }
897
898    #[allow(dead_code)]
899    unsafe fn noop_raw_waker_clone(_: *const ()) -> RawWaker {
900        noop_raw_waker()
901    }
902
903    #[allow(dead_code)]
904    unsafe fn noop_raw_waker_wake(_: *const ()) {}
905
906    #[allow(dead_code)]
907    unsafe fn noop_raw_waker_wake_by_ref(_: *const ()) {}
908
909    #[allow(dead_code)]
910    unsafe fn noop_raw_waker_drop(_: *const ()) {}
911
912    #[allow(dead_code)]
913    static NOOP_RAW_WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new(
914        noop_raw_waker_clone,
915        noop_raw_waker_wake,
916        noop_raw_waker_wake_by_ref,
917        noop_raw_waker_drop,
918    );
919}