M5: push (FCM + ntfy) + offline catch-up + background notifications

Server (gateway-plugin):
- push.py: FCM (HTTP v1 service-account / legacy key) + ntfy backends; NtfyBackend.server_url for app discovery
- adapter.py: push on offline broadcast + high-priority push when live; fcm.register; server_caps.push_ntfy_server; outbox now ALWAYS appends so a reconnecting app catches up on live-delivered frames (fixes empty chat after notification tap / activity recreation)
- protocol.py / ws_server.py / outbox.py / plugin.yaml: M5 frames + env vars

App (Kotlin CMP):
- Protocol.kt: notification / fcm.register / sync frames + push caps
- GatewayClient.kt: hello carries push creds; auto-sync on reconnect
- IrisController.kt: banners, deep-link, notifyMessageIfBackgrounded (system notification on a regular reply when backgrounded)
- Android: PlatformPush, AppBridge, IrisNotifications, NtfyListenerService, IrisFirebaseMessagingService, AndroidPush, AndroidSecureStore
- Desktop: DesktopPush, DesktopSecureStore
- build files + manifest (permissions, services, deep-links)

Tests: 35-test tests/gateway/test_android.py suite passes (incl. new regression test_live_delivered_frame_still_parked_for_sync). E2E verified on device: push fire, reconnect sync, background notification, and message replay after ChatStore reset.
This commit is contained in:
ARIA committed 2026-08-19 19:20:55 +02:00
1 parent 913ee91024
commit 2ecfe1c05c
28 files changed
+1739 -51

No files matched your search

+322 -6
View File
@@ -4,12 +4,328 @@
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
(``ANDROID_FCM_SERVER_KEY``).
- ``NtfyBackend``: reuses hermes ntfy publish (``NTFY_TOPIC`` /
``NTFY_SERVER_URL``).
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic.
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Fired when a frame has no live subscriber; data payload drives a silent sync
on the device.
Fired when a frame has no live subscriber; the data payload drives a silent
sync on the device (docs/08-push.md).
Milestone M5.
"""
Zero new dependencies: ``httpx`` is a hermes core dep. ``google.auth`` is NOT
installed, so the FCM service-account OAuth2 access token is minted directly
with PyJWT + cryptography (both core deps).
Payloads carry no secrets and only a short preview (lock-screen privacy);
full content is fetched via ``sync`` over the authenticated WS.
"""
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token"
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
# Refresh the cached access token this long before its expiry.
_TOKEN_REFRESH_MARGIN_S = 600.0
_DEFAULT_NTFY_SERVER = "https://ntfy.sh"
_NTFY_BODY_LIMIT = 4096
_HTTP_TIMEOUT_S = 15.0
_NTFY_PRIORITY = {"high": "5", "normal": "3", "low": "1"}
class PushBackend:
"""Interface: wake one device (FCM token or ntfy topic)."""
name: str = "push"
# DeviceRegistry column that carries this backend's target token.
token_field: str = ""
def configured(self) -> bool:
"""True when the backend has credentials to send with."""
raise NotImplementedError
async def send(
self,
*,
device_id: str,
chat_id: str,
title: str,
body: str,
data: Dict[str, Any],
token: str,
priority: str = "normal",
data_only: bool = False,
) -> bool:
"""Deliver one push to *token*. Returns True on success.
``data`` is the silent-sync payload (``chat_id``, ``kind``,
``cursor``, optional ``thread_id``/``message_id``) -- string values
only on the wire.
"""
raise NotImplementedError
class FcmBackend(PushBackend):
"""FCM HTTP v1 (service account) or legacy ``/fcm/send`` (server key)."""
name = "fcm"
token_field = "fcm_token"
def __init__(
self,
service_account: Optional[str] = None,
server_key: Optional[str] = None,
):
self._sa_path = (service_account or "").strip() or None
self._server_key = (server_key or "").strip() or None
self._sa: Optional[Dict[str, Any]] = None
self._sa_failed = False
self._access_token: Optional[str] = None
self._token_expiry = 0.0
self._lock = threading.Lock()
def configured(self) -> bool:
if self._server_key:
return True
return bool(self._sa_path and Path(self._sa_path).is_file())
def _load_sa(self) -> Optional[Dict[str, Any]]:
if self._sa is not None:
return self._sa
if not self._sa_path or self._sa_failed:
return None
try:
with open(self._sa_path, "r", encoding="utf-8") as f:
sa = json.load(f)
if isinstance(sa, dict) and sa.get("client_email") and sa.get("private_key"):
self._sa = sa
return sa
except Exception:
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
self._sa_failed = True
return None
async def _authorization(self, client: httpx.AsyncClient) -> Optional[str]:
"""Bearer token: the legacy server key, or a cached service-account
OAuth2 access token (JWT-bearer grant, minted with PyJWT)."""
if self._server_key:
return self._server_key
sa = self._load_sa()
if sa is None:
return None
now = time.time()
with self._lock:
if self._access_token and now < self._token_expiry - _TOKEN_REFRESH_MARGIN_S:
return self._access_token
import jwt # PyJWT (core dep)
claims = {
"iss": sa["client_email"],
"scope": FCM_SCOPE,
"aud": FCM_TOKEN_URL,
"iat": int(now),
"exp": int(now) + 3600,
}
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
try:
assertion = jwt.encode(
claims, sa["private_key"], algorithm="RS256", headers=headers
)
except Exception:
logger.warning("android: FCM JWT mint failed", exc_info=True)
return None
try:
resp = await client.post(
FCM_TOKEN_URL,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
timeout=_HTTP_TIMEOUT_S,
)
except Exception:
logger.warning("android: FCM token exchange failed", exc_info=True)
return None
if resp.status_code != 200:
logger.warning(
"android: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200],
)
return None
try:
data = resp.json()
except (json.JSONDecodeError, ValueError):
return None
token = data.get("access_token")
if not isinstance(token, str) or not token:
return None
with self._lock:
self._access_token = token
try:
self._token_expiry = now + float(data.get("expires_in", 3600))
except (TypeError, ValueError):
self._token_expiry = now + 3600.0
return token
async def send(
self,
*,
device_id: str,
chat_id: str,
title: str,
body: str,
data: Dict[str, Any],
token: str,
priority: str = "normal",
data_only: bool = False,
) -> bool:
if not token:
return False
data = {str(k): str(v) for k, v in (data or {}).items()}
notification = None if data_only else {"title": title or "Iris", "body": body or ""}
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
if self._server_key:
payload: Dict[str, Any] = {"to": token}
if notification:
payload["notification"] = notification
if data:
payload["data"] = data
auth = self._server_key
url = FCM_LEGACY_SEND_URL
else:
sa = self._load_sa()
project_id = (sa or {}).get("project_id")
if not project_id:
return False
message: Dict[str, Any] = {"token": token}
if notification:
message["notification"] = notification
if data:
message["data"] = data
message["android"] = {
"priority": "high" if priority == "high" else "normal"
}
payload = {"message": message}
auth = await self._authorization(client)
if auth is None:
return False
url = FCM_V1_SEND_URL.format(project_id=project_id)
try:
resp = await client.post(
url,
json=payload,
headers={"Authorization": f"Bearer {auth}"},
)
except Exception:
logger.warning("android: FCM send failed (network)", exc_info=True)
return False
if resp.status_code >= 300:
# 404 NOT_FOUND = stale/invalid registration token.
logger.warning(
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
class NtfyBackend(PushBackend):
"""ntfy publish (self-host friendly; zero Firebase).
The structured payload rides in an ``X-Data`` header (JSON) the app's
listener parses; the message body is the short preview. A private topic
+ ``NTFY_AUTH_TOKEN`` provides the trust boundary (docs/08 §8.7).
"""
name = "ntfy"
token_field = "ntfy_topic"
def __init__(
self,
topic: Optional[str] = None,
server_url: Optional[str] = None,
auth_token: Optional[str] = None,
):
self._topic = (topic or "").strip() or None
self._server = (
(server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/")
or _DEFAULT_NTFY_SERVER
)
self._auth_token = (auth_token or "").strip() or None
@property
def server_url(self) -> str:
"""The ntfy server this backend publishes to (for app discovery)."""
return self._server
def configured(self) -> bool:
return bool(self._topic)
async def send(
self,
*,
device_id: str,
chat_id: str,
title: str,
body: str,
data: Dict[str, Any],
token: str,
priority: str = "normal",
data_only: bool = False,
) -> bool:
topic = (token or self._topic or "").strip()
if not topic:
return False
headers = {
"Content-Type": "text/plain; charset=utf-8",
"X-Title": (title or "Iris")[:512],
"X-Priority": _NTFY_PRIORITY.get(priority, "3"),
"X-Tag": "bell",
"X-Data": json.dumps(data or {}, separators=(",", ":")),
}
if self._auth_token:
headers["Authorization"] = f"Bearer {self._auth_token}"
text = (body or "")[:_NTFY_BODY_LIMIT]
url = f"{self._server}/{quote(topic, safe='')}"
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
resp = await client.post(
url, content=text.encode("utf-8"), headers=headers
)
except Exception:
logger.warning("android: ntfy publish failed (network)", exc_info=True)
return False
if resp.status_code >= 300:
logger.warning(
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False
return True
def build_push_backend(
name: Optional[str],
*,
fcm_service_account: Optional[str] = None,
fcm_server_key: Optional[str] = None,
ntfy_topic: Optional[str] = None,
ntfy_server_url: Optional[str] = None,
ntfy_auth_token: Optional[str] = None,
) -> PushBackend:
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
if (name or "").strip().lower() == "ntfy":
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
)
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)