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

+31
View File
@@ -439,6 +439,11 @@ def _strip_streaming_cursor(text: str) -> str:
return text
# M5: coalesce back-to-back pushes for the same chat (a cron delivery parks
# a notification frame AND a message frame; only the first should push).
_PUSH_COALESCE_S = 5.0
def _push_preview(text: Any, limit: int = 120) -> str:
"""Short single-line preview for push bodies (lock-screen privacy: no
secrets, no full bodies -- full content arrives via ``sync``)."""
@@ -1019,6 +1024,9 @@ class AndroidAdapter(BasePlatformAdapter):
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
)
self._prune_notified_at = 0.0
# M5: per-chat push throttle (epoch seconds of the last successful
# push). The coalesced frame still reaches the app via sync.
self._last_push_at: Dict[str, float] = {}
def _turn_state(self, chat_id: str) -> _TurnState:
st = self._turns.get(chat_id)
@@ -1537,6 +1545,16 @@ class AndroidAdapter(BasePlatformAdapter):
summary = self._push_summary(frame)
if summary is None:
return
# M5: coalesce back-to-back pushes for the same chat (cron delivery
# = notification frame + message frame). The suppressed frame is
# still synced when the app reconnects.
now = time.time()
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
logger.info(
"android: push coalesced for %s (%s frame within %.0fs of last push)",
chat_id, frame.type, _PUSH_COALESCE_S,
)
return
title, body, kind, priority = summary
backend = self._push
if backend is None or not backend.token_field:
@@ -1578,6 +1596,15 @@ class AndroidAdapter(BasePlatformAdapter):
logger.warning("android: push via %s failed", backend.name, exc_info=True)
continue
if ok:
# M5: remember that this cursor reached the device via push,
# so the app can dedupe it on the next sync replay.
try:
self._devices.update_push_cursor(device_id, cursor)
except Exception:
logger.warning(
"android: push cursor update failed for %s", device_id, exc_info=True
)
self._last_push_at[chat_id] = time.time()
logger.info(
"android: push via %s -> %s (%s, chat=%s)",
backend.name, device_id, frame.type, chat_id,
@@ -2362,6 +2389,10 @@ class AndroidAdapter(BasePlatformAdapter):
id=raw.get("id") if isinstance(raw.get("id"), int) else None,
chat_id=raw.get("chat_id") if isinstance(raw.get("chat_id"), str) else e.get("chat_id"),
thread_id=raw.get("thread_id") if isinstance(raw.get("thread_id"), str) else None,
# M5: tag replayed frames with their outbox cursor so the app
# can skip re-notifying frames that already woke the device
# via push (cursor <= last_pushed_cursor, docs/08 §8.7).
cursor=e.get("cursor"),
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
)
await self._ws_server.send_to(device_id, replayed)