aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/test
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 12:08:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-09 12:08:31 +0200
commit6832df6177ad973ad0e1b4f0a49d7a6da06c6e04 (patch)
treed9040ce0da5d82400d1b973344615ca1c6b67b3c /packages/meshbay-android/app/src/test
parent2860f1de75af1d44d35292ecbf79c68f02409d19 (diff)
downloadmeshbay-6832df6177ad973ad0e1b4f0a49d7a6da06c6e04.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app/src/test')
-rw-r--r--packages/meshbay-android/app/src/test/kotlin/org/meshbay/client/PushTest.kt108
1 files changed, 108 insertions, 0 deletions
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<String, Any?>) =
+ 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") }
+ }
+}