use crate::signaling::{Device, DeviceCapabilities};
pub(crate) const DEVICE_STALE_HEARTBEAT_MS: i64 = 300_000;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const DEVICE_STALE_SWEEP_INTERVAL_SECS: u64 = 30;
pub(crate) const DEVICE_OFFLINE_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1000;
#[derive(Debug, Clone)]
pub(crate) struct SharedDeviceRecord {
pub canonical_device_id: Option<String>,
pub app_tag: Option<String>,
pub user_id: Option<String>,
pub device_name: String,
pub platform_type: Option<String>,
pub capabilities: Option<DeviceCapabilities>,
pub session_id: Option<String>,
pub node_id: Option<String>,
pub tag: Option<String>,
pub kind: Option<String>,
pub metadata: Option<String>,
pub online: bool,
pub ticket: Option<String>,
pub last_seen_at: Option<serde_json::Value>,
pub expires_at: Option<serde_json::Value>,
pub created_at: Option<serde_json::Value>,
pub updated_at: Option<serde_json::Value>,
pub excluded_peers: Vec<String>,
}
impl SharedDeviceRecord {
pub(crate) fn effective_app_tag(&self) -> Option<&str> {
self.app_tag.as_deref().or(self.tag.as_deref())
}
pub(crate) fn public_device_id(&self, document_id: &str) -> String {
self.canonical_device_id
.clone()
.unwrap_or_else(|| document_id.to_string())
}
pub(crate) fn into_public_device(self, document_id: String) -> Device {
let device_id = self.public_device_id(&document_id);
let app_tag = self.app_tag.or_else(|| self.tag.clone());
Device {
app_tag,
device_id,
user_id: self.user_id,
device_name: self.device_name,
platform_type: self.platform_type,
capabilities: self.capabilities,
session_id: self.session_id,
node_id: self.node_id,
tag: self.tag,
kind: self.kind,
metadata: self.metadata,
online: self.online,
ticket: self.ticket,
last_seen_at: self.last_seen_at,
expires_at: self.expires_at,
created_at: self.created_at,
updated_at: self.updated_at,
excluded_peers: self.excluded_peers,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DiscoveryRetention {
Ephemeral,
Persistent,
}
pub(crate) trait FirebaseDeviceRegistryContract {
fn registry_app_tag(&self) -> &str;
fn registry_retention(&self) -> DiscoveryRetention {
DiscoveryRetention::Ephemeral
}
fn project_discovered_device(
&self,
document_id: String,
record: SharedDeviceRecord,
exclude_node_id: Option<&str>,
now_ms: i64,
) -> Option<Device> {
if record.effective_app_tag() != Some(self.registry_app_tag()) {
return None;
}
if matches_excluded_identity(&record, &document_id, exclude_node_id) {
return None;
}
if !is_device_record_discoverable(&record, now_ms, self.registry_retention()) {
return None;
}
Some(record.into_public_device(document_id))
}
}
pub(crate) fn is_device_record_discoverable(
record: &SharedDeviceRecord,
now_ms: i64,
retention: DiscoveryRetention,
) -> bool {
match retention {
DiscoveryRetention::Ephemeral => {
!is_device_record_expired(record, now_ms, DEVICE_STALE_HEARTBEAT_MS)
}
DiscoveryRetention::Persistent => {
!is_device_record_beyond_offline_retention(record, now_ms)
}
}
}
fn is_device_record_beyond_offline_retention(record: &SharedDeviceRecord, now_ms: i64) -> bool {
let explicit_expires_at = parse_millis(record.expires_at.as_ref());
let freshest_activity = [
record.updated_at.as_ref(),
record.last_seen_at.as_ref(),
record.created_at.as_ref(),
]
.into_iter()
.flatten()
.filter_map(|value| parse_millis(Some(value)))
.max();
let deadline = match (explicit_expires_at, freshest_activity) {
(Some(expires_at), Some(activity)) => {
expires_at.max(activity.saturating_add(DEVICE_OFFLINE_RETENTION_MS))
}
(Some(expires_at), None) => expires_at,
(None, Some(activity)) => activity.saturating_add(DEVICE_OFFLINE_RETENTION_MS),
(None, None) => return false,
};
deadline <= now_ms
}
pub(crate) fn offline_device_expires_at_ms(now_ms: i64) -> i64 {
now_ms.saturating_add(DEVICE_OFFLINE_RETENTION_MS)
}
pub(crate) fn is_device_record_expired(
record: &SharedDeviceRecord,
now_ms: i64,
online_stale_after_ms: i64,
) -> bool {
if let Some(expires_at) = parse_millis(record.expires_at.as_ref()) {
return expires_at <= now_ms;
}
let fallback_ttl_ms = if record.online {
online_stale_after_ms
} else {
DEVICE_OFFLINE_RETENTION_MS
};
if let Some(updated_at) = parse_millis(record.updated_at.as_ref()) {
return updated_at.saturating_add(fallback_ttl_ms) <= now_ms;
}
if let Some(last_seen_at) = parse_millis(record.last_seen_at.as_ref()) {
return last_seen_at.saturating_add(fallback_ttl_ms) <= now_ms;
}
false
}
pub(crate) fn matches_excluded_identity(
record: &SharedDeviceRecord,
document_id: &str,
exclude_node_id: Option<&str>,
) -> bool {
let Some(exclude) = exclude_node_id
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return false;
};
record.public_device_id(document_id) == exclude
|| record.node_id.as_deref() == Some(exclude)
|| record.ticket.as_deref() == Some(exclude)
|| document_id == exclude
}
pub(crate) fn parse_millis(value: Option<&serde_json::Value>) -> Option<i64> {
match value {
Some(serde_json::Value::Number(number)) => number.as_i64(),
Some(serde_json::Value::String(raw)) => parse_millis_string(raw),
_ => None,
}
}
fn parse_millis_string(raw: &str) -> Option<i64> {
if let Ok(parsed) = raw.parse::<i64>() {
return Some(parsed);
}
#[cfg(target_arch = "wasm32")]
{
let millis = js_sys::Date::parse(raw);
if millis.is_finite() {
return Some(millis as i64);
}
}
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(raw) {
return Some(parsed.timestamp_millis());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn record(
online: bool,
updated_at: Option<i64>,
expires_at: Option<i64>,
) -> SharedDeviceRecord {
SharedDeviceRecord {
canonical_device_id: Some("device-1".to_string()),
app_tag: Some("app_test".to_string()),
user_id: Some("user-1".to_string()),
device_name: "Device 1".to_string(),
platform_type: Some("desktop".to_string()),
capabilities: None,
session_id: None,
node_id: Some("node-1".to_string()),
tag: Some("app_test".to_string()),
kind: Some("device".to_string()),
metadata: None,
online,
ticket: None,
last_seen_at: None,
expires_at: expires_at.map(serde_json::Value::from),
created_at: None,
updated_at: updated_at.map(serde_json::Value::from),
excluded_peers: vec![],
}
}
#[test]
fn online_records_expire_on_heartbeat_ttl() {
let now = 1_000_000;
let stale = record(true, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);
assert!(is_device_record_expired(
&stale,
now,
DEVICE_STALE_HEARTBEAT_MS,
));
}
#[test]
fn offline_records_without_expires_at_use_offline_retention() {
let now = 1_000_000_000;
let offline_recent = record(false, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);
let offline_expired = record(false, Some(now - DEVICE_OFFLINE_RETENTION_MS - 1), None);
assert!(!is_device_record_expired(
&offline_recent,
now,
DEVICE_STALE_HEARTBEAT_MS,
));
assert!(is_device_record_expired(
&offline_expired,
now,
DEVICE_STALE_HEARTBEAT_MS,
));
}
#[test]
fn persistent_retention_keeps_stale_online_records_discoverable() {
let now = 1_000_000_000;
let mut stale_online = record(true, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);
stale_online.expires_at = Some(serde_json::Value::from(now - 1));
assert!(is_device_record_discoverable(
&stale_online,
now,
DiscoveryRetention::Persistent,
));
assert!(!is_device_record_discoverable(
&stale_online,
now,
DiscoveryRetention::Ephemeral,
));
}
#[test]
fn persistent_retention_drops_records_past_offline_retention() {
let now = 100_000_000_000;
let beyond_retention = record(true, Some(now - DEVICE_OFFLINE_RETENTION_MS - 1), None);
let within_retention = record(true, Some(now - DEVICE_STALE_HEARTBEAT_MS - 1), None);
assert!(!is_device_record_discoverable(
&beyond_retention,
now,
DiscoveryRetention::Persistent,
));
assert!(is_device_record_discoverable(
&within_retention,
now,
DiscoveryRetention::Persistent,
));
}
#[test]
fn explicit_expires_at_wins_for_offline_records() {
let now = 1_000_000_000;
let expired = record(false, Some(now), Some(now - 1));
let retained = record(
false,
Some(now - DEVICE_OFFLINE_RETENTION_MS - 1),
Some(now + 1),
);
assert!(is_device_record_expired(
&expired,
now,
DEVICE_STALE_HEARTBEAT_MS,
));
assert!(!is_device_record_expired(
&retained,
now,
DEVICE_STALE_HEARTBEAT_MS,
));
}
}