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

+51 -3
View File
@@ -18,8 +18,12 @@ Options:
--send TEXT send this message after pairing (default: "hello")
--upload F M4: upload F (chunked media.upload) and attach it to the
message.send via media_refs
--pull-offer M4: when a media.offer arrives during the turn, pull the
media (chunked) and verify the byte count
--pull-offer M4: when a media.offer arrives during the turn, pull the
media (chunked) and verify the byte count
--sync C M5: after pairing, send sync {cursor: C} and print the
replay + sync.done (no turn is driven)
--fcm-token M5: attach this FCM token to the hello payload
--fcm-reg M5: after pairing, send fcm.register with --fcm-token
--timeout S seconds to wait for the final reply (default 120)
--authfail expect an auth rejection (wrong token) and exit 0 on it
"""
@@ -94,6 +98,13 @@ def _print_frame(raw):
extra = f" ok={payload.get('ok')} ref={payload.get('media_ref')}"
elif ftype == "media.pull.end":
extra = f" ok={payload.get('ok')}"
elif ftype == "notification":
extra = (f" kind={payload.get('kind')} title={payload.get('title')!r} "
f"body={(payload.get('body') or '')[:100]!r}")
elif ftype == "sync":
extra = f" cursor={payload.get('cursor')}"
elif ftype == "sync.done":
extra = f" cursor={payload.get('cursor')}"
scope = f" chat={chat}" if chat else ""
idpart = f" id={fid}" if fid is not None else ""
print(f" <- {ftype}{idpart}{scope}{extra}")
@@ -200,8 +211,10 @@ async def run(args) -> int:
"caps": {"min_protocol": 1},
},
}
if args.fcm_token:
hello["payload"]["fcm_token"] = args.fcm_token
await ws.send(json.dumps(hello))
print(" -> hello")
print(" -> hello" + (f" fcm_token={args.fcm_token[:12]}…" if args.fcm_token else ""))
# First response must be hello.ack (or an auth error).
try:
@@ -224,6 +237,35 @@ async def run(args) -> int:
await ws.close()
return 5
# M5: optional fcm.register after pairing.
if args.fcm_reg:
reg_token = args.fcm_token or f"probe-{uuid.uuid4().hex[:12]}"
await ws.send(json.dumps({
"v": 1, "type": "fcm.register",
"payload": {"fcm_token": reg_token},
}))
print(f" -> fcm.register fcm_token={reg_token[:12]}…")
# M5: sync catch-up mode (no turn driven).
if args.sync is not None:
await ws.send(json.dumps({
"v": 1, "id": 1, "type": "sync", "payload": {"cursor": args.sync},
}))
print(f" -> sync cursor={args.sync}")
while True:
raw = await asyncio.wait_for(ws.recv(), timeout=30)
data = _print_frame(raw)
if data is None:
continue
if data.get("type") == "sync.done":
print(f"== sync done at cursor {data['payload'].get('cursor')}")
await ws.close()
return 0
if data.get("type") == "error":
print(f"!! sync failed: {data['payload']}")
await ws.close()
return 8
if not args.send and not args.upload:
print("== paired OK (no --send/--upload; exiting)")
await ws.close()
@@ -311,6 +353,12 @@ def main() -> int:
help="M4: file to upload (chunked) and attach via media_refs")
p.add_argument("--pull-offer", action="store_true",
help="M4: pull any media.offer that arrives during the turn")
p.add_argument("--sync", type=int, default=None,
help="M5: send sync {cursor} after pairing, print replay, exit")
p.add_argument("--fcm-token", default="",
help="M5: FCM token to attach to the hello payload")
p.add_argument("--fcm-reg", action="store_true",
help="M5: send fcm.register after pairing (uses --fcm-token)")
p.add_argument("--timeout", type=float, default=120.0)
p.add_argument("--authfail", action="store_true",
help="expect an auth rejection (wrong token)")