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 --- .../src/test/kotlin/org/meshbay/client/PushTest.kt | 108 +++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt (limited to 'packages/meshbay-android/app/src/test') diff --git a/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt new file mode 100644 index 0000000..c575906 --- /dev/null +++ b/packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt @@ -0,0 +1,108 @@ +package org.meshbay.client + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.meshbay.client.notify.Notifier +import org.meshbay.client.notify.PushState + +class PushTest { + private fun payload(vararg pairs: Pair) = + JSONObject().apply { put("v", 1); pairs.forEach { (k, v) -> put(k, v ?: JSONObject.NULL) } } + .toString().toByteArray() + + @Test fun `a conversation is one entry per group, anything else one per notification`() { + val chat = Notifier.parse(payload("id" to 7, "kind" to "chat_message", "title" to "a posted in b", + "group_id" to "g-1", "link" to "#/group/g-1"))!! + assertEquals(Notifier.CHANNEL_CHAT, chat.channel) + assertEquals("chat:g-1", chat.tag) + assertEquals("#/group/g-1", chat.link) + val invite = Notifier.parse(payload("id" to 8, "kind" to "group_invite", "title" to "x invited you", + "group_id" to null, "link" to "#/"))!! + assertEquals(Notifier.CHANNEL_OTHER, invite.channel) + assertEquals("n:8", invite.tag) + } + + @Test fun `a link that is not a route inside the page is dropped, not followed`() { + for (bad in listOf("https://elsewhere.example/", "javascript:alert(1)", "#/x\";alert(1)//", "//host/#/")) { + val shown = Notifier.parse(payload("id" to 1, "kind" to "k", "title" to "t", "link" to bad))!! + assertNull(bad, shown.link) + } + } + + @Test fun `what is not ours to draw is not drawn`() { + assertNull(Notifier.parse("not json".toByteArray())) + assertNull(Notifier.parse(JSONObject().put("v", 2).put("title", "t").toString().toByteArray())) + assertNull(Notifier.parse(payload("id" to 1, "kind" to "k", "title" to " "))) + } + + private val sub = "0f8fad5b-d9cb-469f-a165-70867728950e" + private val account = "3f2504e0-4f89-41d3-9a0c-0305e82c3301" + private val secret = "Zm9vYmFyYmF6cXV4X3NlY3JldF92YWx1ZQ" + private val since = "2026-10-09T10:00:00.123456+00:00" + + @Test fun `the hub is registered again when the distributor hands out a new endpoint`() { + val state = PushState(FakePrefs()) + state.endpoint("https://push.example.net/1", "k", "a") // not asked for: ignored + assertEquals(PushState.OFF, state.status().getString("state")) + state.requested() + state.endpoint("https://push.example.net/1", "k", "a") + state.remember(sub, account, secret, since) + val s = state.status() + assertEquals(s.getString("endpoint"), s.getString("registered")) + state.endpoint("https://push.example.net/2", "k", "a") + val moved = state.status() + assertEquals("https://push.example.net/1", moved.getString("registered")) + assertEquals("https://push.example.net/2", moved.getString("endpoint")) + } + + @Test fun `no distributor, or one that gives no keys, means fetching and not failing`() { + val state = PushState(FakePrefs()) + state.requested() + state.endpoint("https://push.example.net/1", null, null) + val s = state.status() + assertEquals(PushState.READY, s.getString("state")) + assertEquals("NO_KEYS", s.getString("reason")) + assertTrue(s.isNull("endpoint")) + state.remember(sub, account, secret, since) + assertEquals(PushState.PollTarget(sub, secret, since), state.pollTarget()) + } + + @Test fun `the fetch cursor only moves forward`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.advance("2026-10-09T09:00:00+00:00") + assertEquals(since, state.pollTarget()!!.since) + state.advance("2026-10-09T11:00:00+00:00") + assertEquals("2026-10-09T11:00:00+00:00", state.pollTarget()!!.since) + } + + @Test fun `a line pushed then fetched is drawn once, and a conversation moving on is news`() { + val state = PushState(FakePrefs()) + assertTrue(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:00:00+00:00")) + assertTrue(state.firstSight(7, "2026-10-09T10:05:00+00:00")) + assertFalse(state.firstSight(7, "2026-10-09T10:01:00+00:00")) + } + + @Test fun `a row the hub forgot stops the fetch until the page registers again`() { + val state = PushState(FakePrefs()) + state.requested() + state.remember(sub, account, secret, since) + state.forgetSubscription() + assertNull(state.pollTarget()) + } + + @Test fun `only ids are remembered`() { + val state = PushState(FakePrefs()) + assertThrows(IllegalArgumentException::class.java) { state.remember("../x", account, secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, "", secret, since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, "short", since) } + assertThrows(IllegalArgumentException::class.java) { state.remember(sub, account, secret, "yesterday") } + } +} -- cgit v1.2.3