From 6832df6177ad973ad0e1b4f0a49d7a6da06c6e04 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 9 Oct 2026 12:08:31 +0200 Subject: feat: notifications on Android while closed, with nothing to install The phone fetches what is new every fifteen minutes with a poll secret (POST /v1/push/poll) that reads notification lines and nothing else. When a UnifiedPush distributor is already installed, the hub also pushes at once, encrypted to the phone (RFC 8291); losing the distributor falls back to fetching. The hub now honours "disable all notifications" itself: create_notification creates nothing for that account, as it already did for a muted group, so neither switch lets anything reach a phone. The interface used to be the only reader of the account-wide switch. Push endpoints are member-supplied URLs: a send refuses non-public addresses, connects to the address it checked, and follows no redirect. Android build untested here (no SDK on this machine). Co-Authored-By: Claude Opus 5.5 --- docs/MESHBAY_DESIGN.md | 44 ++++++++++++++++++++++++++++++++++++++++---- docs/MESHBAY_HTTP_API.md | 8 ++++++++ docs/USERGUIDE.md | 14 +++++++++++++- 3 files changed, 61 insertions(+), 5 deletions(-) (limited to 'docs') diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 9c42c62..b874632 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1987,8 +1987,9 @@ by accident (§2.4). **Stores:** accounts (username, encrypted email, status, role), the group registry and membership, IP logs (one year, legal retention), node registrations, refresh -tokens, notifications, the moderation blocklist, instance policy, and per-account -device keys for hub login. +tokens, notifications, the moderation blocklist, instance policy, per-account +device keys for hub login, and the phones that asked to be notified — a poll +secret's hash and, for a phone with a push distributor, its endpoint (§11.3). **Does not store:** file content, file names, private-group indexes, message content, private keys, group keys, keypair bundles, user identity keys, node IPs @@ -3443,6 +3444,40 @@ narrow bridge — and Android has all three: lives in the page (WebRTC → decrypt → relay), so while one runs the shell keeps the WebView reported visible and holds a media-playback foreground service; nowhere else, because a page never hidden is never throttled. +- **Notifications reach a closed application with nothing to install, and + never through a vendor push service.** Android lets nothing hold a connection + for an application that is not running, so there are three ways to wake a + phone: a vendor push service (Google sees who is notified and when; not + F-Droid), a push distributor the owner installs, or the phone asking. **The + phone asks by default**, and uses a distributor when one is already there. + Turned on, the page registers the phone (`POST /v1/push/subscriptions`) and + gets a row id and a **poll secret**; a system job (`JobScheduler`, no library) + then fetches what is new with `POST /v1/push/poll` every fifteen minutes, + which Android stretches under Doze. The secret is the only credential the + background holds and it reads notification lines and nothing else — not a + session, so nothing renewing in the background can collide with the page's + rotating refresh token, and a sign-out, which deletes the row, ends it. **When + a UnifiedPush distributor is already installed** (ntfy, or an application + carrying one), the row also gets its endpoint and P-256 key and each + notification is sent there at once as one RFC 8291 record, so the push server + relays bytes it cannot read and learns only *when* — the metadata §7.1 already + concedes to the hub; the fetch then runs every four hours as a net under it. A + distributor that refuses, disappears or answers 404/410 is not an error: the + row loses its endpoint and the phone fetches again. Both paths carry the same + payload — the hub's own row (kind, title, group, a `#/` route, its date), + never a message, which the hub does not hold — and a line pushed then fetched + is drawn once. **Nothing reaches a phone that was not created**, and + `create_notification` creates nothing for a muted group *or for an account + that turned every notification off* — the second switch used to be read only + by the interface, which hid rows the hub went on writing, and a switch only a + renderer honours is no switch once a phone is told about every row. A push + endpoint is a URL a member chose and the hub fetches it, so a send resolves + it, refuses any non-public address and connects to the address it checked + (SNI and Host carry the name); no redirect is followed. The shell draws a + pushed message only if the connector decrypted it with the phone's key, and a + notification's link is a route in the page, applied as `location.hash`, never + loaded. No VAPID yet: a distributor that requires it refuses, and the phone + fetches instead. Release builds are signed with the release key, distributed as a direct APK; the key stays outside the repository and a release build without it fails @@ -3806,6 +3841,7 @@ had already been asked. | **AV30** | **What one member's offers cost a node is bounded per account and per node, and the bound admits the heaviest ordinary account** (§7.2). Each offer makes the node allocate a peer connection. A budget of 120 per node refilled at two a second bounds a member there without touching their other nodes, and it is counted by account because a mobile carrier shares one IPv4 address among many subscribers. Pending offers are capped at 32 per account. Both refusals carry `Retry-After` and the client retries them, because a refused offer otherwise reads as a node that is down. An offer carries at most 64 ICE candidates (32 KiB), and its IP-log row — kept a year — is written only once it goes to a node, so naming nodes that do not exist costs the hub nothing. A node's `update_groups`, a database read each, is budgeted like `chat_notify` (ten a minute) and claims at most 1000 groups | | **AV32** | **A node hosts a group because its owner said so, not because its account belongs to it** (§7.2). Every member holds the key, so a member's node passes the handshake like the real host and could be the one a client keeps. A node may claim the groups its account owns and those whose owner approved it; any other claim is a pending request the owner sees | | **AV33** | **Nobody is made a member without saying yes** (§7.3). A membership makes the account's client list the group, name it in its tokens and dial its nodes, so an owner's addition is an invitation until the invitee accepts it. The MNP token names only the group it is minted for | +| **AV34** | **What a member's chat costs other members' phones is bounded twice, and what a phone's fetching costs the hub once** (§11.3). A pushed notification is one outbound request per subscription of the recipient, so the fan-out of one chat line is members × phones. It is bounded where it starts (`chat_notify`, ten a minute per node), a conversation reaches each phone at most once per 30 s (the phone shows one line per group, so the pushes in between would only replace it), and an account holds ten subscriptions at most. A subscription the push server reports gone (404/410) loses its endpoint rather than being retried for ever. A phone fetching instead costs one indexed read per poll, refused below a minute per row, and returns at most twenty lines | | **AV31** | **What waits for a signature is bounded** (§5.4). Any authenticated member can ask for an admin challenge, since the signature is checked afterwards, and a pending challenge kept its whole request until answered — measured, 200 requests of 1 MiB held 400 MiB for the life of one connection. At most eight pending per connection, 64 KiB each, expired ones dropped | ### 13.6 Chat design findings @@ -4000,8 +4036,8 @@ Music and Photos, cross-group search with source merging, per-account playlists, casting to a Chromecast with subtitles rebased onto the relay's clock, the operator CLI and loopback control API, the desktop client through its identity and download stages, account recovery, the Windows port through packaging, and -the Android client — the shell, native keys, downloads and uploads, and casting -(§11.3). +the Android client — the shell, native keys, downloads and uploads, casting, +and notifications while it is closed, fetched or pushed (§11.3). The packages install: a machine has been taken from the built artefacts to a running hub and node on **Ubuntu 26.04 (`.deb`), Fedora 44 (`.rpm`) and diff --git a/docs/MESHBAY_HTTP_API.md b/docs/MESHBAY_HTTP_API.md index 74b9d77..1bcb55d 100644 --- a/docs/MESHBAY_HTTP_API.md +++ b/docs/MESHBAY_HTTP_API.md @@ -180,6 +180,14 @@ hub also serves the web application at `/` and `/app/`, which are not listed. | DELETE | `/v1/notifications` | user or node | Throw them all away. | | POST | `/v1/notifications/read-all` | user or node | Dismiss every one — the same thing as `DELETE ""`, under the name an older client knows it by. | +### Push + +| Method | Path | Auth | What | +|---|---|---|---| +| POST | `/v1/push/subscriptions` | user | Register this phone, or update its row: with an endpoint and keys when it has a push distributor, without them when it will fetch instead. | +| POST | `/v1/push/poll` | none | What is new for this phone since `since`: the same payloads a push carries, oldest first, at most twenty. | +| DELETE | `/v1/push/subscriptions/{subscription_id}` | user | Stop telling one phone anything: turned off there, or signed out of. | + ## Node control API `http://127.0.0.1:`, port 18000 unless `ui_port` in `node.toml` says diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 275a70d..eb659e1 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -447,6 +447,16 @@ designed and not built. **Settings → Defaults** — which tab a group opens on, how many items per page. **Settings → Appearance** — theme and language (ten languages ship). **Group menu → Mute** — stop notifications for one group. +**Settings → Groups → Disable all notifications** — none are kept for you at +all, and none reach a phone. +**Settings → Groups → Notifications on this phone** (Android application) — +be told about new messages and invitations while MeshBay is closed. Nothing to +install: the phone checks about every fifteen minutes (Android may wait longer +when the phone is asleep). If you want them to arrive at once, install a +UnifiedPush *distributor* app such as **ntfy** (Play Store or F-Droid) — optional; +MeshBay then uses it by itself, encrypted so the distributor cannot read what it +carries. Muting a group, or disabling all notifications, stops what reaches the +phone too. A busy conversation is one line per group. --- @@ -1008,7 +1018,9 @@ Better to know now than to go looking for it: - **The Android application is not released yet.** It works — groups, chat, films, downloads, casting — and is signed with the release key, but is installed by hand from an APK: there is no store page and no update channel. The - back button and the lock screen do not drive it yet. Music keeps playing + back button and the lock screen do not drive it yet. Notifications arrive within about + fifteen minutes, or at once with a UnifiedPush distributor app such as ntfy + — except one that demands a server key (VAPID), not supported yet. Music keeps playing with the screen off, from one track to the next, with a notification shown while it plays. A phone browser works too. - **A node cannot be hosted on Android**, and is not planned to be. -- cgit v1.2.3