feat(push): M7 Part 2 — opt-in push notifications (embedded ntfy distributor)
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m16s
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m16s
Implements the app side of M7 push (docs/android/PLAN.md §11). The app EMBEDS
its own distributor — ntfy is only the relay server, no second app installed,
no Google Play Services. New feature slice; no existing screen's data flow
changes.
- core/push: NtfyTopic (random unguessable topic + endpoint/SSE URL builders),
PushTickle (content-free { stream, ref } parser over ntfy's SSE envelope),
NtfyStreamClient (bare-client OkHttp SSE to <ntfy>/<topic>/sse, reconnect/
backoff cloned from ShardStreamClient), PushNotifier (channels + per-stream
deep-link notification), PushService (foreground service holding the
connection), PushManager (mint topic / register-unregister device / start-stop,
keyed to the session), PushPreferences (DataStore state).
- data: NotificationsApi + DTOs + NotificationsRepository over the merged
/auth/me/devices + /auth/me/notifications/* contract; push block on SettingsDto.
- ui/notifications: settings screen + VM — per-stream toggles, personal streams
greyed until a game account is linked, POST_NOTIFICATIONS request on enable.
- Navigation: Routes.NOTIFICATIONS + stream→route deep-link map, menu entry,
RunicApp + MainActivity intent handling; teardown wired into logout + server
switch (deregister while bearer valid) and every sign-out (local, via session
observer).
- Manifest: POST_NOTIFICATIONS + FOREGROUND_SERVICE(_DATA_SYNC) + the service.
Deviation (recorded in PLAN.md): direct-ntfy transport, no UnifiedPush library
— the plan's stated likely path; keeps the APK Google-free and dependency-light,
with a PushResult/transport seam for a future FCM Play flavor. Requires the small
companion push.ntfyUrl settings field (website#<pr>).
18 new JVM tests; :app:testDebugUnitTest + lintDebug + assembleDebug green.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests for the app's own ntfy topic + endpoint URL building (PLAN.md §11, work
|
||||
* item 1) — the heart of the embedded-distributor design.
|
||||
*/
|
||||
class NtfyTopicTest {
|
||||
|
||||
@Test fun generatesUnguessableTopicsInTheAllowedCharset() {
|
||||
val a = NtfyTopic.generate()
|
||||
val b = NtfyTopic.generate()
|
||||
assertNotEquals(a, b)
|
||||
assertTrue("prefixed", a.startsWith("up"))
|
||||
assertTrue("length", a.length >= 24)
|
||||
assertTrue("charset", a.all { it.isLetterOrDigit() })
|
||||
}
|
||||
|
||||
@Test fun buildsEndpointAndSseUrls() {
|
||||
assertEquals("https://ntfy.tld/up7", NtfyTopic.endpointUrl("https://ntfy.tld", "up7"))
|
||||
assertEquals("https://ntfy.tld/up7/sse", NtfyTopic.sseUrl("https://ntfy.tld", "up7"))
|
||||
}
|
||||
|
||||
@Test fun toleratesTrailingSlashOnBase() {
|
||||
assertEquals("https://ntfy.tld/up7", NtfyTopic.endpointUrl("https://ntfy.tld/", "up7"))
|
||||
}
|
||||
|
||||
@Test fun nullOrBlankInputsYieldNull() {
|
||||
assertNull(NtfyTopic.endpointUrl(null, "up7"))
|
||||
assertNull(NtfyTopic.endpointUrl("", "up7"))
|
||||
assertNull(NtfyTopic.endpointUrl("https://ntfy.tld", " "))
|
||||
assertNull(NtfyTopic.sseUrl(null, "up7"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.push
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Parsing tests for the content-free push tickle over ntfy's SSE envelope
|
||||
* (PLAN.md §11). A `message` frame yields `{ stream, ref }`; lifecycle frames and
|
||||
* malformed bodies are dropped (never thrown, §7).
|
||||
*/
|
||||
class PushTickleTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test fun parsesMessageFrame() {
|
||||
// ntfy wraps our POSTed body in { event:"message", message:"<our json>" }.
|
||||
val data = """{"id":"x","time":1,"event":"message","topic":"up1","message":"{\"stream\":\"vendor.sale\",\"ref\":\"0x40001\"}"}"""
|
||||
val tickle = parseNtfyTickle(json, data)
|
||||
assertEquals(PushTickle("vendor.sale", "0x40001"), tickle)
|
||||
}
|
||||
|
||||
@Test fun parsesMessageWithoutRef() {
|
||||
val data = """{"event":"message","message":"{\"stream\":\"server.status\"}"}"""
|
||||
val tickle = parseNtfyTickle(json, data)
|
||||
assertEquals("server.status", tickle?.stream)
|
||||
assertNull(tickle?.ref)
|
||||
}
|
||||
|
||||
@Test fun dropsOpenAndKeepaliveFrames() {
|
||||
assertNull(parseNtfyTickle(json, """{"event":"open","topic":"up1"}"""))
|
||||
assertNull(parseNtfyTickle(json, """{"event":"keepalive","topic":"up1"}"""))
|
||||
}
|
||||
|
||||
@Test fun dropsMalformedOrEmpty() {
|
||||
assertNull(parseNtfyTickle(json, ""))
|
||||
assertNull(parseNtfyTickle(json, ": keepalive comment"))
|
||||
assertNull(parseNtfyTickle(json, "not json"))
|
||||
// A message whose inner body isn't our shape → no stream → dropped.
|
||||
assertNull(parseNtfyTickle(json, """{"event":"message","message":"{}"}"""))
|
||||
assertNull(parseNtfyTickle(json, """{"event":"message","message":"garbage"}"""))
|
||||
}
|
||||
|
||||
@Test fun decodeTickleRejectsBlankStream() {
|
||||
assertNull(decodeTickle(json, """{"stream":"","ref":"x"}"""))
|
||||
assertEquals(PushTickle("news.post"), decodeTickle(json, """{"stream":"news.post"}"""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding tests for the opt-in push DTOs (PLAN.md §11, M7 Part 2). Shapes come
|
||||
* from the merged backend (`notifications.controller` / `pushDevices.model`);
|
||||
* unknown keys are ignored (additive fields, §8).
|
||||
*/
|
||||
class NotificationsDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun pushDeviceDecodes() {
|
||||
val dto = json.decodeFromString<PushDeviceDto>(
|
||||
"""{"id":9,"transport":"unifiedpush","endpoint":"https://ntfy.example.com/up123",
|
||||
"platform":"android","createdAt":"2026-07-20T00:00:00Z","lastSeenAt":null}""",
|
||||
)
|
||||
assertEquals(9L, dto.id)
|
||||
assertEquals("unifiedpush", dto.transport)
|
||||
assertEquals("https://ntfy.example.com/up123", dto.endpoint)
|
||||
assertEquals("android", dto.platform)
|
||||
assertNull(dto.lastSeenAt)
|
||||
}
|
||||
|
||||
@Test fun streamCatalogDecodesPersonalFlags() {
|
||||
val dto = json.decodeFromString<NotificationStreamsDto>(
|
||||
"""{"streams":[
|
||||
{"id":"news.post","label":"News posts","description":"New posts.","personal":false,"requiresLinkedAccount":false},
|
||||
{"id":"vendor.sale","label":"Your vendor sold","description":"A sale.","personal":true,"requiresLinkedAccount":true}
|
||||
]}""",
|
||||
)
|
||||
assertEquals(2, dto.streams.size)
|
||||
val news = dto.streams.first { it.id == "news.post" }
|
||||
assertFalse(news.personal)
|
||||
assertFalse(news.requiresLinkedAccount)
|
||||
val vendor = dto.streams.first { it.id == "vendor.sale" }
|
||||
assertTrue(vendor.personal)
|
||||
assertTrue(vendor.requiresLinkedAccount)
|
||||
}
|
||||
|
||||
@Test fun subscriptionsDecode() {
|
||||
val dto = json.decodeFromString<NotificationSubscriptionsDto>(
|
||||
"""{"streams":["news.post","champ.start"]}""",
|
||||
)
|
||||
assertEquals(listOf("news.post", "champ.start"), dto.streams)
|
||||
}
|
||||
|
||||
@Test fun settingsPushBlockDecodes() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",
|
||||
)
|
||||
assertEquals("https://ntfy.shard.tld", dto.push.ntfyUrl)
|
||||
}
|
||||
|
||||
@Test fun settingsPushDefaultsNullOnOlderBackend() {
|
||||
// A backend predating M7 omits `push` entirely — the app must still decode.
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"site_title":"Shard","brand":{"name":"Shard"}}""",
|
||||
)
|
||||
assertNull(dto.push.ntfyUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import com.runicgateway.app.core.push.PushStreams
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests the pure push helpers: the stream → deep-link route map (PLAN.md §11 work
|
||||
* item 7) and the personal-stream gating (a personal stream needs a linked account).
|
||||
*/
|
||||
class NotificationRoutingTest {
|
||||
|
||||
@Test fun deepLinkRoutesMapEachStreamToItsScreen() {
|
||||
assertEquals(Routes.NEWS, Routes.forStream(PushStreams.NEWS_POST))
|
||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.SERVER_STATUS))
|
||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.CHAMP_START))
|
||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.IDOC_WARNING))
|
||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.GOVERNOR_ELECTION))
|
||||
assertEquals(Routes.PLAYER_VENDORS, Routes.forStream(PushStreams.VENDOR_SALE))
|
||||
assertEquals(Routes.PLAYER_HOUSES, Routes.forStream(PushStreams.HOUSE_IDOC))
|
||||
assertEquals(Routes.ACCOUNT, Routes.forStream(PushStreams.ACCOUNT_LOGIN))
|
||||
}
|
||||
|
||||
@Test fun unknownStreamFallsBackToHome() {
|
||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||
}
|
||||
|
||||
@Test fun personalStreamNeedsLinkedAccount() {
|
||||
val personal = NotificationStreamDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||
assertFalse(streamSelectable(personal, hasLinkedAccount = false))
|
||||
assertTrue(streamSelectable(personal, hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun generalStreamIsAlwaysSelectable() {
|
||||
val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||
assertTrue(streamSelectable(general, hasLinkedAccount = false))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user