Push notification dedupe: one message = one notification — an offline message was notified twice (FCM push, then again when the app synced the outbox and mirrored the replayed frames). Fix: the gateway records the highest outbox cursor delivered per device via push (devices.last_pushed_cursor, advanced only on successful send) and returns it in hello.ack; sync-replayed frames carry their outbox cursor in the envelope; the app skips system notifications for replayed frames at/below the watermark (live frames never suppressed — that is the case where no push fired). Also: 5s per-chat push coalescing so a cron delivery (notification frame + message frame) pushes once, and the FCM handler no longer posts a redundant notification (skips when WS is connected or FCM already displayed the notification payload; data-only messages are the exception). Docs: frames.schema.json, 04-wire-protocol.md, 08-push.md §8.8

This commit is contained in:
ARIA committed 2026-08-21 17:21:54 +02:00
1 parent acd5fb4ad0
commit 9f3f9842c8
11 files changed
+301 -98

No files matched your search

+54 -11
View File
@@ -17,7 +17,7 @@ import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any
from urllib.parse import quote
logger = logging.getLogger(__name__)
@@ -31,7 +31,7 @@ def generate_token() -> str:
return secrets.token_hex(TOKEN_BYTES)
def verify_token(provided: Optional[str], expected: Optional[str]) -> bool:
def verify_token(provided: str | None, expected: str | None) -> bool:
"""Constant-time token comparison (never time-leaks the token)."""
if not provided or not expected:
return False
@@ -65,6 +65,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
# Device registry (SQLite)
# ---------------------------------------------------------------------------
class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``.
@@ -88,20 +89,32 @@ class DeviceRegistry:
caps TEXT NOT NULL DEFAULT '{}',
fcm_token TEXT,
ntfy_topic TEXT,
last_pushed_cursor INTEGER NOT NULL DEFAULT 0,
last_seen REAL NOT NULL DEFAULT 0,
created REAL NOT NULL DEFAULT 0
)
"""
)
# M5: migrate pre-push-cursor databases (the column carries the
# highest outbox cursor already delivered to the device via the
# push backend; hello.ack returns it for notification dedupe).
cols = {
r["name"]
for r in self._conn.execute("PRAGMA table_info(devices)").fetchall()
}
if "last_pushed_cursor" not in cols:
self._conn.execute(
"ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0"
)
self._conn.commit()
def upsert(
self,
device_id: str,
name: str,
caps: Optional[Dict[str, Any]] = None,
fcm_token: Optional[str] = None,
ntfy_topic: Optional[str] = None,
caps: dict[str, Any] | None = None,
fcm_token: str | None = None,
ntfy_topic: str | None = None,
) -> None:
now = time.time()
caps_json = json.dumps(caps or {}, separators=(",", ":"))
@@ -125,8 +138,8 @@ class DeviceRegistry:
def update_push_tokens(
self,
device_id: str,
fcm_token: Optional[str] = None,
ntfy_topic: Optional[str] = None,
fcm_token: str | None = None,
ntfy_topic: str | None = None,
) -> None:
with self._lock:
self._conn.execute(
@@ -149,14 +162,43 @@ class DeviceRegistry:
)
self._conn.commit()
def get(self, device_id: str) -> Optional[Dict[str, Any]]:
def update_push_cursor(self, device_id: str, cursor: int) -> None:
"""Advance the device's last-pushed cursor (monotonic; never
regresses). Called after a successful push send."""
try:
cursor = max(0, int(cursor or 0))
except (TypeError, ValueError):
return
with self._lock:
self._conn.execute(
"""
UPDATE devices SET last_pushed_cursor = MAX(last_pushed_cursor, ?)
WHERE device_id = ?
""",
(cursor, device_id),
)
self._conn.commit()
def last_pushed_cursor(self, device_id: str) -> int:
"""Highest outbox cursor pushed to this device (0 = never/unknown)."""
with self._lock:
row = self._conn.execute(
"SELECT last_pushed_cursor FROM devices WHERE device_id = ?",
(device_id,),
).fetchone()
try:
return int(row["last_pushed_cursor"]) if row else 0
except (TypeError, ValueError, KeyError, IndexError):
return 0
def get(self, device_id: str) -> dict[str, Any] | None:
with self._lock:
row = self._conn.execute(
"SELECT * FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
return _row_to_device(row) if row else None
def list(self) -> List[Dict[str, Any]]:
def list(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM devices ORDER BY last_seen DESC"
@@ -171,7 +213,7 @@ class DeviceRegistry:
pass
def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
try:
caps = json.loads(row["caps"] or "{}")
if not isinstance(caps, dict):
@@ -184,6 +226,7 @@ def _row_to_device(row: sqlite3.Row) -> Dict[str, Any]:
"caps": caps,
"fcm_token": row["fcm_token"],
"ntfy_topic": row["ntfy_topic"],
"last_pushed_cursor": row["last_pushed_cursor"] or 0,
"last_seen": row["last_seen"],
"created": row["created"],
}
}