//! The FCM HTTP v1 client (SPEC §14.6, §6.3): data-only messages, OAuth2 access tokens from a service-account JWT
//! (RS256) exchanged at Google's token endpoint and cached until 5 minutes before expiry.
//!
//! Endpoints are code constants; [`Endpoints`] exists so tests can point the client at a local stub server.
use std::path::Path;
use std::sync::Arc;
use std::time::Duration as StdDuration;
use chrono::{DateTime, Duration, Utc};
use jsonwebtoken::{Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use super::{PushMessage, PushType};
use crate::error::AppError;
use crate::time::Clock;
/// Google's OAuth2 token endpoint.
pub const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
/// The FCM API host; the send URL is `/v1/projects//messages:send`.
pub const SEND_BASE_URL: &str = "https://fcm.googleapis.com";
/// The OAuth2 scope of the HTTP v1 API.
pub const SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging";
/// The JWT bearer grant type.
pub const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
/// JWT lifetime: Google's maximum, one hour.
pub const JWT_LIFETIME: Duration = Duration::hours(1);
/// A cached access token is replaced this long before it expires.
pub const REFRESH_MARGIN: Duration = Duration::minutes(5);
/// Per-request timeout of the HTTP client.
pub const HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(30);
/// `android.ttl` of every message (SPEC §14.6).
pub const MESSAGE_TTL: &str = "86400s";
/// Payload version (`data.v`).
pub const PAYLOAD_VERSION: &str = "1";
/// Where the client sends. [`Endpoints::google`] in production.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoints {
pub token_url: String,
pub send_base_url: String,
}
impl Endpoints {
pub fn google() -> Self {
Self {
token_url: TOKEN_URL.to_owned(),
send_base_url: SEND_BASE_URL.to_owned(),
}
}
}
/// The fields of a service-account key file the client uses.
#[derive(Clone, Deserialize)]
pub struct ServiceAccount {
pub client_email: String,
pub private_key: String,
#[serde(default)]
pub private_key_id: Option,
}
impl std::fmt::Debug for ServiceAccount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Never print the key.
f.debug_struct("ServiceAccount")
.field("client_email", &self.client_email)
.finish_non_exhaustive()
}
}
#[derive(Serialize)]
struct Claims<'a> {
iss: &'a str,
scope: &'a str,
aud: &'a str,
iat: i64,
exp: i64,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: i64,
}
/// The result of one send attempt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendOutcome {
/// 2xx.
Sent,
/// `UNREGISTERED` or HTTP 404: the token is dead; clear it.
Unregistered,
/// 429, 5xx or a network error: try again.
Retry(String),
/// Anything else: give up on this message for this device.
Failed(String),
}
/// The `entity_type` of a push type (SPEC §14.6 table).
pub fn entity_type_of(push_type: PushType) -> &'static str {
match push_type {
PushType::ExpenseCreated
| PushType::ExpenseUpdated
| PushType::ExpenseDeleted
| PushType::RecurringExpenseCreated => "expense",
PushType::SettlementCreated | PushType::SettlementUpdated | PushType::SettlementDeleted => {
"settlement"
}
PushType::SyncBatch => "",
}
}
/// The HTTP v1 request body for one device (SPEC §14.6): data only, every value a string, ids only (AC-13.8).
pub fn message_body(fcm_token: &str, message: &PushMessage) -> Value {
json!({
"message": {
"token": fcm_token,
"android": { "priority": "high", "ttl": MESSAGE_TTL },
"data": {
"v": PAYLOAD_VERSION,
"type": message.push_type.as_str(),
"entity_type": entity_type_of(message.push_type),
"entity_id": message.entity_id.map(|id| id.to_string()).unwrap_or_default(),
"actor_user_id": message.actor_user_id.to_string(),
"count": message.count.to_string(),
}
}
})
}
/// Whether an FCM error body carries `errorCode` `UNREGISTERED` in a `google.firebase.fcm.v1.FcmError` detail.
pub fn is_unregistered(body: &Value) -> bool {
body.pointer("/error/details")
.and_then(Value::as_array)
.is_some_and(|details| {
details
.iter()
.any(|d| d.get("errorCode").and_then(Value::as_str) == Some("UNREGISTERED"))
})
}
/// Sends FCM HTTP v1 messages for one project.
pub struct FcmClient {
http: reqwest::Client,
account: ServiceAccount,
key: EncodingKey,
project_id: String,
endpoints: Endpoints,
clock: Arc,
cache: Mutex