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:
1 parent
913ee91024
commit
2ecfe1c05c
28 files changed
+1739
-51
No files matched your search
@@ -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)")
|
||||
|
||||
Reference in new issue
Block a user