diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c107df1..48e62da 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,13 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/runicgateway/app/MainActivity.kt b/app/src/main/java/com/runicgateway/app/MainActivity.kt
index 4aaf7ae..7fbfbdf 100644
--- a/app/src/main/java/com/runicgateway/app/MainActivity.kt
+++ b/app/src/main/java/com/runicgateway/app/MainActivity.kt
@@ -3,6 +3,7 @@
*/
package com.runicgateway.app
+import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -14,7 +15,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
@@ -35,8 +39,15 @@ import dagger.hilt.android.AndroidEntryPoint
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+
+ // The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
+ // Set from the launching intent and from onNewIntent (the activity is singleTop),
+ // consumed once by RunicApp which navigates to the stream's screen.
+ private var pendingStream by mutableStateOf(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
// Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme.
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
@@ -58,11 +69,23 @@ class MainActivity : ComponentActivity() {
AppState.NeedsConnection ->
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
- RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
+ RunicApp(
+ brand = s.brand,
+ onChangeServer = appViewModel::changeServer,
+ deepLinkStream = pendingStream,
+ onDeepLinkConsumed = { pendingStream = null },
+ )
}
}
}
}
}
}
+
+ /** A notification tapped while the activity is already running (singleTop). */
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
+ }
}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/NtfyStreamClient.kt b/app/src/main/java/com/runicgateway/app/core/push/NtfyStreamClient.kt
new file mode 100644
index 0000000..dd59f7c
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/NtfyStreamClient.kt
@@ -0,0 +1,116 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.isActive
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.sse.EventSource
+import okhttp3.sse.EventSourceListener
+import okhttp3.sse.EventSources
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * The embedded distributor's transport (PLAN.md §11, M7 Part 2 work item 1/3):
+ * a persistent connection to the shard's self-hosted ntfy that subscribes to the
+ * app's own topic and re-emits each content-free tickle. It reuses the same
+ * OkHttp-SSE + reconnect/backoff shape as [com.runicgateway.app.core.net.ShardStreamClient],
+ * but on a **bare** client — no host-retargeting or bearer interceptors — because it
+ * talks straight to ntfy (`//sse`), not the website API. Held open by
+ * [PushService]'s foreground service so tickles arrive in the background without
+ * Google Play Services.
+ */
+@Singleton
+class NtfyStreamClient @Inject constructor(
+ private val json: Json,
+) {
+ // A dedicated client with the read timeout disabled for the mostly-idle stream
+ // (ntfy sends keepalive frames); no interceptors so nothing rewrites the host or
+ // attaches a bearer to the relay.
+ private val client: OkHttpClient = OkHttpClient.Builder()
+ .readTimeout(0, TimeUnit.MILLISECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ private val factory = EventSources.createFactory(client)
+
+ /** Connection lifecycle + decoded tickles for a subscribed topic. */
+ sealed interface Event {
+ data object Open : Event
+ data object Closed : Event
+ data class Message(val tickle: PushTickle) : Event
+ }
+
+ /**
+ * A cold flow subscribing to `//sse`, reconnecting with
+ * backoff until the collector cancels. A dropped relay simply reconnects; a bad
+ * config (null URL) idles rather than spinning.
+ */
+ fun events(ntfyBaseUrl: String?, topic: String): Flow = channelFlow {
+ var backoffMs = INITIAL_BACKOFF_MS
+ while (isActive) {
+ val url = NtfyTopic.sseUrl(ntfyBaseUrl, topic)
+ if (url == null) {
+ trySend(Event.Closed)
+ delay(backoffMs)
+ backoffMs = grow(backoffMs)
+ continue
+ }
+
+ val request = Request.Builder()
+ .url(url)
+ .header("Accept", "text/event-stream")
+ .build()
+
+ val opened = AtomicBoolean(false)
+ val ended = CompletableDeferred()
+ val listener = object : EventSourceListener() {
+ override fun onOpen(eventSource: EventSource, response: Response) {
+ opened.set(true)
+ trySend(Event.Open)
+ }
+
+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
+ parseNtfyTickle(json, data)?.let { trySend(Event.Message(it)) }
+ }
+
+ override fun onClosed(eventSource: EventSource) {
+ trySend(Event.Closed)
+ ended.complete(Unit)
+ }
+
+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
+ trySend(Event.Closed)
+ ended.complete(Unit)
+ }
+ }
+
+ val source = factory.newEventSource(request, listener)
+ try {
+ ended.await()
+ } finally {
+ source.cancel()
+ }
+
+ backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
+ delay(backoffMs)
+ }
+ }
+
+ private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)
+
+ private companion object {
+ const val INITIAL_BACKOFF_MS = 2_000L
+ const val MAX_BACKOFF_MS = 30_000L
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/NtfyTopic.kt b/app/src/main/java/com/runicgateway/app/core/push/NtfyTopic.kt
new file mode 100644
index 0000000..c1194b7
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/NtfyTopic.kt
@@ -0,0 +1,48 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import java.security.SecureRandom
+
+/**
+ * The app's own ntfy topic — the heart of the embedded-distributor design
+ * (PLAN.md §11, M7 Part 2 work item 1). The app mints a **random, unguessable**
+ * topic and registers its public URL (`https:///`) as the device
+ * endpoint the backend POSTs tickles to; the app subscribes to the same topic's SSE
+ * stream to receive them. Security rests on the topic being unguessable plus the
+ * content-free tickle — a leaked topic name reveals nothing.
+ */
+object NtfyTopic {
+
+ // ntfy topic names allow [A-Za-z0-9_-]; keep to that set. The "up" prefix mirrors
+ // the UnifiedPush convention and makes topics recognizable in logs/relay.
+ private const val ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ private const val TOPIC_LEN = 24
+ private const val PREFIX = "up"
+
+ private val secureRandom by lazy { SecureRandom() }
+
+ /** Mint a fresh unguessable topic, e.g. "up7Qk3…" (≈143 bits of entropy). */
+ fun generate(random: java.util.Random = secureRandom): String {
+ val sb = StringBuilder(PREFIX.length + TOPIC_LEN)
+ sb.append(PREFIX)
+ repeat(TOPIC_LEN) { sb.append(ALPHABET[random.nextInt(ALPHABET.length)]) }
+ return sb.toString()
+ }
+
+ /**
+ * The endpoint URL the backend publishes to: `/`. [ntfyBaseUrl]
+ * is the client-facing base from `/public/settings.push.ntfyUrl`; a trailing slash
+ * is tolerated. Returns null for a blank base or topic.
+ */
+ fun endpointUrl(ntfyBaseUrl: String?, topic: String): String? {
+ val base = ntfyBaseUrl?.trim()?.trimEnd('/').orEmpty()
+ if (base.isEmpty() || topic.isBlank()) return null
+ return "$base/$topic"
+ }
+
+ /** The SSE subscribe URL the app connects to: `//sse`. */
+ fun sseUrl(ntfyBaseUrl: String?, topic: String): String? =
+ endpointUrl(ntfyBaseUrl, topic)?.let { "$it/sse" }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushManager.kt b/app/src/main/java/com/runicgateway/app/core/push/PushManager.kt
new file mode 100644
index 0000000..453b148
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/PushManager.kt
@@ -0,0 +1,156 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import android.content.Context
+import com.runicgateway.app.core.auth.Session
+import com.runicgateway.app.core.auth.SessionManager
+import com.runicgateway.app.core.result.ApiResult
+import com.runicgateway.app.data.repository.NotificationsRepository
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Orchestrates the app's opt-in push lifecycle (PLAN.md §11, M7 Part 2 work item 5):
+ * mint/keep the ntfy topic, register/unregister the device endpoint with the backend,
+ * and start/stop the foreground [PushService] — all keyed to the user's opt-in and
+ * the session. The endpoint the app registers is its own topic URL on the shard's
+ * ntfy (the embedded-distributor design, work item 1).
+ *
+ * Lifecycle rules:
+ * - register only when **signed in** and the shard advertises a relay (`ntfyUrl`);
+ * - a **sign-out** stops the service and forgets the ephemeral registration but keeps
+ * the opt-in intent, so push re-registers on the next sign-in (mirrors the M3 token
+ * teardown, and covers logout / dead-refresh / server switch uniformly via the
+ * session-state observer);
+ * - a **relay/base-URL change** re-registers on the new host with a fresh topic.
+ */
+@Singleton
+class PushManager @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+ private val prefs: PushPreferences,
+ private val notifications: NotificationsRepository,
+ private val sessionManager: SessionManager,
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ /** Whether the user has push turned on (drives the Notifications screen). */
+ val enabled: Flow = prefs.enabled
+
+ /** Whether this shard advertises a push relay at all (null ntfyUrl → unsupported). */
+ val supported: Flow = prefs.ntfyUrl.map { !it.isNullOrBlank() }
+
+ init {
+ // Uniform teardown/resume across every auth transition: logout, dead-refresh
+ // sign-out, and server switch all land on SignedOut; a fresh login re-asserts.
+ scope.launch {
+ sessionManager.state.collect { s ->
+ when (s) {
+ is Session.SignedOut -> localTeardown()
+ is Session.SignedIn -> maybeResume()
+ }
+ }
+ }
+ }
+
+ /** Record the shard's client-facing ntfy base URL (from `/public/settings`). */
+ suspend fun setNtfyUrl(url: String?) {
+ val previous = prefs.snapshot().ntfyUrl
+ prefs.setNtfyUrl(url)
+ // The relay host arriving (or changing) is what unblocks a pending resume.
+ if (!url.isNullOrBlank() && url != previous) maybeResume()
+ }
+
+ /**
+ * Turn push on (idempotent): ensure a topic on the current relay, register its
+ * endpoint with the backend, persist, and start the foreground service. Called
+ * when the user opts into ≥1 stream.
+ */
+ suspend fun enable(): PushResult = register(setIntent = true)
+
+ /** Turn push off (user opted out of every stream): clear intent + deregister. */
+ suspend fun disable() {
+ prefs.setEnabled(false)
+ deregisterDevice()
+ }
+
+ /**
+ * Deregister this device on an explicit sign-out / server switch, while the bearer
+ * is still valid, so no orphan device row is left behind. Keeps the opt-in intent
+ * (and ntfyUrl) so push re-registers on the next sign-in. Call this *before* the
+ * session is torn down.
+ */
+ suspend fun deregisterDevice() {
+ val snap = prefs.snapshot()
+ snap.deviceId?.let { notifications.deleteDevice(it) } // best-effort
+ stopService()
+ prefs.clearRegistration()
+ }
+
+ /** Re-assert registration if the user is opted in and the shard supports push. */
+ private suspend fun maybeResume() {
+ val snap = prefs.snapshot()
+ if (snap.enabled && sessionManager.isSignedIn && !snap.ntfyUrl.isNullOrBlank()) {
+ register(setIntent = false)
+ }
+ }
+
+ private suspend fun register(setIntent: Boolean): PushResult {
+ if (!sessionManager.isSignedIn) return PushResult.NotSignedIn
+ val snap = prefs.snapshot()
+ val ntfyUrl = snap.ntfyUrl
+ if (ntfyUrl.isNullOrBlank()) return PushResult.Unsupported
+
+ // Reuse an existing topic only if its endpoint still sits on the current relay
+ // origin; otherwise (first run, or a server switch) mint a fresh unguessable one.
+ val base = ntfyUrl.trimEnd('/')
+ val topic = snap.topic?.takeIf { snap.endpoint?.startsWith("$base/") == true }
+ ?: NtfyTopic.generate()
+ val endpoint = NtfyTopic.endpointUrl(ntfyUrl, topic) ?: return PushResult.Unsupported
+
+ return when (val res = notifications.registerDevice(endpoint, PLATFORM)) {
+ is ApiResult.Ok -> {
+ prefs.setRegistration(topic, endpoint, res.data.id)
+ if (setIntent) prefs.setEnabled(true)
+ startService()
+ PushResult.Enabled
+ }
+ // 400 = endpoint origin isn't on the shard's ntfy allow-set (misconfigured relay).
+ is ApiResult.HttpError -> PushResult.Failed(res.status)
+ is ApiResult.NetworkError -> PushResult.Failed(null)
+ }
+ }
+
+ /** Local-only teardown on sign-out — no backend DELETE (the bearer may be dead). */
+ private suspend fun localTeardown() {
+ stopService()
+ prefs.clearRegistration()
+ }
+
+ private fun startService() = runCatching { PushService.start(context) }
+ private fun stopService() = runCatching { PushService.stop(context) }
+
+ /** The outcome of enabling push, surfaced to the Notifications screen. */
+ sealed interface PushResult {
+ data object Enabled : PushResult
+
+ /** This shard advertises no push relay (`/public/settings.push.ntfyUrl` is null). */
+ data object Unsupported : PushResult
+ data object NotSignedIn : PushResult
+
+ /** Registration failed — [status] 400 = relay off the allow-set; null = network. */
+ data class Failed(val status: Int?) : PushResult
+ }
+
+ private companion object {
+ const val PLATFORM = "android"
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt b/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt
new file mode 100644
index 0000000..6e1acf3
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt
@@ -0,0 +1,110 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.runicgateway.app.MainActivity
+import com.runicgateway.app.R
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.concurrent.atomic.AtomicInteger
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Builds the notification channels and posts a notification for a received tickle
+ * (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
+ * notification titled from the fixed [PushStreams] catalog — the content-free tickle
+ * carries nothing to render, so nothing is fetched to display the notification; tapping
+ * deep-links into [MainActivity] (which fetches fresh over the authenticated API).
+ */
+@Singleton
+class PushNotifier @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+) {
+ private val manager = NotificationManagerCompat.from(context)
+ private val nextId = AtomicInteger(1)
+
+ /** Create both channels; safe to call repeatedly (creation is idempotent). */
+ fun ensureChannels() {
+ val system = context.getSystemService(NotificationManager::class.java) ?: return
+ system.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_MESSAGES,
+ context.getString(R.string.push_channel_messages),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ).apply { description = context.getString(R.string.push_channel_messages_desc) },
+ )
+ system.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_SERVICE,
+ context.getString(R.string.push_channel_service),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = context.getString(R.string.push_channel_service_desc)
+ setShowBadge(false)
+ },
+ )
+ }
+
+ /** The persistent low-importance notification the foreground service runs under. */
+ fun serviceNotification(): Notification =
+ NotificationCompat.Builder(context, CHANNEL_SERVICE)
+ .setContentTitle(context.getString(R.string.push_service_title))
+ .setContentText(context.getString(R.string.push_service_text))
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setContentIntent(deepLinkIntent(stream = null, ref = null))
+ .build()
+
+ /** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
+ fun notify(tickle: PushTickle) {
+ if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
+ val title = context.getString(PushStreams.titleRes(tickle.stream))
+ val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
+ .setContentTitle(title)
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
+ .build()
+ try {
+ manager.notify(nextId.getAndIncrement(), notification)
+ } catch (_: SecurityException) {
+ // Racing a permission revoke — drop silently rather than crash.
+ }
+ }
+
+ private fun deepLinkIntent(stream: String?, ref: String?): PendingIntent {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ if (stream != null) putExtra(EXTRA_STREAM, stream)
+ if (ref != null) putExtra(EXTRA_REF, ref)
+ }
+ // A distinct request code per stream so PendingIntents don't collapse into one.
+ val requestCode = stream?.hashCode() ?: 0
+ return PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ }
+
+ companion object {
+ const val CHANNEL_MESSAGES = "push_messages"
+ const val CHANNEL_SERVICE = "push_service"
+
+ /** Intent extras a tapped notification carries into [MainActivity] (§7 deep-links). */
+ const val EXTRA_STREAM = "com.runicgateway.app.push.STREAM"
+ const val EXTRA_REF = "com.runicgateway.app.push.REF"
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushPreferences.kt b/app/src/main/java/com/runicgateway/app/core/push/PushPreferences.kt
new file mode 100644
index 0000000..9da7c81
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/PushPreferences.kt
@@ -0,0 +1,91 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.pushDataStore: DataStore by preferencesDataStore(name = "push")
+
+/**
+ * Persists the app's push state (PLAN.md §11, M7 Part 2 work item 5). None of it is
+ * secret — the ntfy topic/endpoint's protection is being unguessable plus the
+ * content-free tickle — so plain DataStore is fine (tokens stay in the encrypted
+ * store). Holds the shard's ntfy base URL (from `/public/settings`), the minted
+ * topic + its endpoint URL, the backend-assigned device id (to unregister), and the
+ * user's opt-in flag (the source of truth for "push should be running").
+ */
+@Singleton
+class PushPreferences @Inject constructor(
+ @param:ApplicationContext private val context: Context,
+) {
+ private val store = context.pushDataStore
+
+ val enabled: Flow = store.data.map { it[KEY_ENABLED] ?: false }
+ val ntfyUrl: Flow = store.data.map { it[KEY_NTFY_URL] }
+
+ suspend fun snapshot(): Snapshot {
+ val p = store.data.first()
+ return Snapshot(
+ enabled = p[KEY_ENABLED] ?: false,
+ ntfyUrl = p[KEY_NTFY_URL],
+ topic = p[KEY_TOPIC],
+ endpoint = p[KEY_ENDPOINT],
+ deviceId = p[KEY_DEVICE_ID],
+ )
+ }
+
+ suspend fun setNtfyUrl(url: String?) = store.edit {
+ if (url.isNullOrBlank()) it.remove(KEY_NTFY_URL) else it[KEY_NTFY_URL] = url
+ }
+
+ suspend fun setEnabled(value: Boolean) = store.edit { it[KEY_ENABLED] = value }
+
+ /** Record the minted topic + its endpoint URL and the assigned device id together. */
+ suspend fun setRegistration(topic: String, endpoint: String, deviceId: Long) = store.edit {
+ it[KEY_TOPIC] = topic
+ it[KEY_ENDPOINT] = endpoint
+ it[KEY_DEVICE_ID] = deviceId
+ }
+
+ /**
+ * Forget the ephemeral device registration (topic/endpoint/device id) — used on
+ * sign-out and on an explicit disable. Deliberately leaves [KEY_ENABLED] and
+ * [KEY_NTFY_URL] intact so the user's opt-in intent survives a sign-out and push
+ * re-registers on the next sign-in; an explicit disable also calls [setEnabled]`(false)`.
+ */
+ suspend fun clearRegistration() = store.edit {
+ it.remove(KEY_TOPIC)
+ it.remove(KEY_ENDPOINT)
+ it.remove(KEY_DEVICE_ID)
+ }
+
+ data class Snapshot(
+ val enabled: Boolean,
+ val ntfyUrl: String?,
+ val topic: String?,
+ val endpoint: String?,
+ val deviceId: Long?,
+ )
+
+ private companion object {
+ val KEY_ENABLED = booleanPreferencesKey("enabled")
+ val KEY_NTFY_URL = stringPreferencesKey("ntfy_url")
+ val KEY_TOPIC = stringPreferencesKey("topic")
+ val KEY_ENDPOINT = stringPreferencesKey("endpoint")
+ val KEY_DEVICE_ID = longPreferencesKey("device_id")
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushService.kt b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt
new file mode 100644
index 0000000..71e7607
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/PushService.kt
@@ -0,0 +1,95 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ServiceCompat
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * The always-connected foreground service that IS the embedded distributor
+ * (PLAN.md §11, M7 Part 2 work item 1/3). It holds [NtfyStreamClient]'s persistent
+ * connection to the shard's ntfy open in the background — the price of Google-free,
+ * self-contained instant delivery — and posts a notification for each tickle. It runs
+ * under a low-importance ongoing notification and restarts sticky; [PushManager] starts
+ * and stops it as the user opts in/out or signs out.
+ */
+@AndroidEntryPoint
+class PushService : Service() {
+
+ @Inject lateinit var streamClient: NtfyStreamClient
+ @Inject lateinit var notifier: PushNotifier
+ @Inject lateinit var prefs: PushPreferences
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var connectionJob: Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ notifier.ensureChannels()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ startAsForeground()
+ if (connectionJob == null) connectionJob = scope.launch { run() }
+ return START_STICKY
+ }
+
+ private fun startAsForeground() {
+ val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ } else {
+ 0
+ }
+ ServiceCompat.startForeground(this, NOTIFICATION_ID, notifier.serviceNotification(), type)
+ }
+
+ private suspend fun run() {
+ val snapshot = prefs.snapshot()
+ val topic = snapshot.topic
+ if (topic.isNullOrBlank() || snapshot.ntfyUrl.isNullOrBlank()) {
+ // Nothing to subscribe to (should not happen — PushManager starts us only
+ // once a topic exists) — stop rather than hold a dead connection open.
+ stopSelf()
+ return
+ }
+ streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
+ if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
+ }
+ }
+
+ override fun onDestroy() {
+ connectionJob?.cancel()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ private const val NOTIFICATION_ID = 42
+
+ fun start(context: Context) {
+ val intent = Intent(context, PushService::class.java)
+ context.startForegroundService(intent)
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, PushService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushStreams.kt b/app/src/main/java/com/runicgateway/app/core/push/PushStreams.kt
new file mode 100644
index 0000000..4f63295
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/PushStreams.kt
@@ -0,0 +1,39 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import androidx.annotation.StringRes
+import com.runicgateway.app.R
+
+/**
+ * The known push stream ids (mirrors the backend catalog in
+ * `config/notificationStreams.js`) and their localized notification titles.
+ * The subscribable catalog itself is fetched from
+ * `GET /auth/me/notifications/streams`; this fixed set is only what the receiver
+ * needs to title a content-free tickle without a network round-trip (§11).
+ */
+object PushStreams {
+ const val NEWS_POST = "news.post"
+ const val SERVER_STATUS = "server.status"
+ const val IDOC_WARNING = "idoc.warning"
+ const val CHAMP_START = "champ.start"
+ const val GOVERNOR_ELECTION = "governor.election"
+ const val VENDOR_SALE = "vendor.sale"
+ const val HOUSE_IDOC = "house.idoc"
+ const val ACCOUNT_LOGIN = "account.login"
+
+ /** A short, localized notification title for [streamId]; a generic fallback otherwise. */
+ @StringRes
+ fun titleRes(streamId: String): Int = when (streamId) {
+ NEWS_POST -> R.string.push_stream_news_post
+ SERVER_STATUS -> R.string.push_stream_server_status
+ IDOC_WARNING -> R.string.push_stream_idoc_warning
+ CHAMP_START -> R.string.push_stream_champ_start
+ GOVERNOR_ELECTION -> R.string.push_stream_governor_election
+ VENDOR_SALE -> R.string.push_stream_vendor_sale
+ HOUSE_IDOC -> R.string.push_stream_house_idoc
+ ACCOUNT_LOGIN -> R.string.push_stream_account_login
+ else -> R.string.push_stream_generic
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/core/push/PushTickle.kt b/app/src/main/java/com/runicgateway/app/core/push/PushTickle.kt
new file mode 100644
index 0000000..380e5eb
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/core/push/PushTickle.kt
@@ -0,0 +1,54 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.core.push
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * The content-free push tickle the backend publishes (PLAN.md §11): `{ stream, ref }`
+ * and nothing sensitive. [ref] is an opaque hint (a serial / city / timestamp) the
+ * app *could* use to pull real content over the authenticated API; v1 just deep-links
+ * to the stream's screen, so it is carried but not otherwise interpreted.
+ */
+@Serializable
+data class PushTickle(
+ val stream: String,
+ val ref: String? = null,
+)
+
+/**
+ * Parse a tickle out of an ntfy SSE `data:` frame. ntfy wraps our published body in
+ * its own envelope — `{ event, topic, message, … }` — where `message` is the exact
+ * string we POSTed (our `{ stream, ref }` JSON). Only `event == "message"` frames
+ * carry a payload; `open` / `keepalive` frames return null, as does any malformed or
+ * unrecognized body (dropped, never thrown — §7). Pure + `internal` for unit testing.
+ */
+internal fun parseNtfyTickle(json: Json, data: String): PushTickle? {
+ val trimmed = data.trim()
+ if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
+ return try {
+ val envelope = json.parseToJsonElement(trimmed) as? JsonObject ?: return null
+ val event = envelope["event"]?.jsonPrimitive?.content
+ // ntfy lifecycle frames ("open", "keepalive", "poll_request") carry no message.
+ if (event != null && event != "message") return null
+ val messageEl = envelope["message"] ?: return null
+ if (messageEl is JsonNull) return null
+ val message = messageEl.jsonPrimitive.content
+ decodeTickle(json, message)
+ } catch (_: Exception) {
+ null
+ }
+}
+
+/** Decode our own `{ stream, ref }` body; a blank/missing stream is not a tickle. */
+internal fun decodeTickle(json: Json, body: String): PushTickle? = try {
+ val tickle = json.decodeFromString(PushTickle.serializer(), body.trim())
+ tickle.takeIf { it.stream.isNotBlank() }
+} catch (_: Exception) {
+ null
+}
diff --git a/app/src/main/java/com/runicgateway/app/data/api/NotificationsApi.kt b/app/src/main/java/com/runicgateway/app/data/api/NotificationsApi.kt
new file mode 100644
index 0000000..8e23716
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/data/api/NotificationsApi.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.data.api
+
+import com.runicgateway.app.data.api.dto.NotificationStreamsDto
+import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
+import com.runicgateway.app.data.api.dto.PushDeviceDto
+import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
+import retrofit2.http.Body
+import retrofit2.http.DELETE
+import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
+
+/**
+ * The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
+ * (endpoint) registration and per-user stream subscriptions. Every call rides the
+ * main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
+ * bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
+ * registration only ever succeeds while signed in.
+ */
+interface NotificationsApi {
+
+ @POST("api/v1/auth/me/devices")
+ suspend fun registerDevice(@Body body: RegisterDeviceRequest): PushDeviceDto
+
+ @GET("api/v1/auth/me/devices")
+ suspend fun listDevices(): List
+
+ @DELETE("api/v1/auth/me/devices/{id}")
+ suspend fun deleteDevice(@Path("id") id: Long): Unit
+
+ @GET("api/v1/auth/me/notifications/streams")
+ suspend fun streams(): NotificationStreamsDto
+
+ @GET("api/v1/auth/me/notifications/subscriptions")
+ suspend fun subscriptions(): NotificationSubscriptionsDto
+
+ @PUT("api/v1/auth/me/notifications/subscriptions")
+ suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
+}
diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt
new file mode 100644
index 0000000..9b01cd8
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/data/api/dto/NotificationsDto.kt
@@ -0,0 +1,67 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.data.api.dto
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Wire shapes for the opt-in push surface under `/auth/me` (PLAN.md §11, M7
+ * Part 2). Field names match the backend's `notifications.controller` /
+ * `pushDevices.model` exactly; every DTO ignores unknown keys (NetworkModule's
+ * lenient Json), so additive backend fields are safe (recorded for M1).
+ */
+
+/**
+ * `POST /auth/me/devices` body. [endpoint] is the ntfy topic URL the app's
+ * embedded distributor owns (`https:///`); the backend
+ * SSRF-validates it is HTTPS on the shard's allow-set before storing. [transport]
+ * is `unifiedpush` for the direct-ntfy relay (fcm reserved for a future flavor).
+ */
+@Serializable
+data class RegisterDeviceRequest(
+ val endpoint: String,
+ val transport: String = "unifiedpush",
+ val platform: String? = null,
+)
+
+/** `POST/GET /auth/me/devices` — one registered device (endpoint) for this user. */
+@Serializable
+data class PushDeviceDto(
+ val id: Long = 0,
+ val transport: String = "",
+ val endpoint: String = "",
+ val platform: String? = null,
+ val createdAt: String? = null,
+ val lastSeenAt: String? = null,
+)
+
+/**
+ * One subscribable stream from `GET /auth/me/notifications/streams`. A [personal]
+ * stream is delivered only to the owning user and [requiresLinkedAccount] — the app
+ * greys its toggle until a game account is linked (§11).
+ */
+@Serializable
+data class NotificationStreamDto(
+ val id: String = "",
+ val label: String = "",
+ val description: String = "",
+ val personal: Boolean = false,
+ val requiresLinkedAccount: Boolean = false,
+)
+
+/** `GET /auth/me/notifications/streams` — the catalog. */
+@Serializable
+data class NotificationStreamsDto(
+ val streams: List = emptyList(),
+)
+
+/**
+ * `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
+ * PUT replaces the full set; unknown ids are dropped server-side and the stored set
+ * echoed back.
+ */
+@Serializable
+data class NotificationSubscriptionsDto(
+ val streams: List = emptyList(),
+)
diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt
index eeff9f1..52655c9 100644
--- a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt
+++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt
@@ -55,6 +55,17 @@ data class RegistrationFlagsDto(
val sso: Boolean = false,
)
+/**
+ * Push-notification relay config (M7). [ntfyUrl] is the client-facing ntfy base
+ * URL the app's embedded distributor registers its device topic against; null (or
+ * absent, on an older backend) means push isn't configured for this shard and the
+ * Notifications screen shows it as unavailable.
+ */
+@Serializable
+data class PushConfigDto(
+ val ntfyUrl: String? = null,
+)
+
/**
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
* app consumes are modeled; other whitelisted keys are ignored.
@@ -67,4 +78,6 @@ data class SettingsDto(
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
val gameAccountSignup: Boolean = false,
val brand: BrandDto = BrandDto(),
+ /** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
+ val push: PushConfigDto = PushConfigDto(),
)
diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt
index 617d258..e8d58b3 100644
--- a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt
+++ b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt
@@ -4,6 +4,7 @@
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager
+import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
@@ -26,6 +27,7 @@ import javax.inject.Singleton
class AuthRepository @Inject constructor(
private val authApi: AuthApi,
private val sessionManager: SessionManager,
+ private val pushManager: PushManager,
private val json: Json,
) {
@@ -75,6 +77,16 @@ class AuthRepository @Inject constructor(
* network call fails, so the user is always signed out locally.
*/
suspend fun logout(allDevices: Boolean = false) {
+ // Deregister this device's push endpoint while the bearer is still valid, so
+ // no orphan device row is left behind (§11). Keeps the opt-in intent so push
+ // resumes on the next sign-in; best-effort, never blocks the logout.
+ try {
+ pushManager.deregisterDevice()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ // Ignore — local session teardown proceeds regardless.
+ }
val refreshToken = sessionManager.currentRefreshToken()
try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt
index 804b563..998c7cb 100644
--- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt
+++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt
@@ -26,6 +26,7 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
+ private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -96,6 +97,14 @@ class ConnectionRepository @Inject constructor(
* signed-out state against the new host.
*/
suspend fun disconnect() {
+ // Deregister the push endpoint on the current (old) host while still authed,
+ // then clear the shard's ntfy URL — the new host advertises its own (§11).
+ try {
+ pushManager.deregisterDevice()
+ } catch (_: Exception) {
+ // Best-effort; the reset proceeds regardless.
+ }
+ pushManager.setNtfyUrl(null)
sessionManager.onSignedOut()
prefs.clear()
baseUrlHolder.set(null)
diff --git a/app/src/main/java/com/runicgateway/app/data/repository/NotificationsRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/NotificationsRepository.kt
new file mode 100644
index 0000000..92bdecf
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/data/repository/NotificationsRepository.kt
@@ -0,0 +1,40 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.data.repository
+
+import com.runicgateway.app.core.result.ApiResult
+import com.runicgateway.app.core.result.safeApiCall
+import com.runicgateway.app.data.api.NotificationsApi
+import com.runicgateway.app.data.api.dto.NotificationStreamsDto
+import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
+import com.runicgateway.app.data.api.dto.PushDeviceDto
+import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Device registration + per-user stream subscriptions over the opt-in push surface
+ * (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
+ * and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
+ * (endpoint off the shard's allow-set) or a down backend never throws (§7).
+ */
+@Singleton
+class NotificationsRepository @Inject constructor(
+ private val api: NotificationsApi,
+) {
+ suspend fun registerDevice(endpoint: String, platform: String?): ApiResult =
+ safeApiCall { api.registerDevice(RegisterDeviceRequest(endpoint = endpoint, platform = platform)) }
+
+ suspend fun listDevices(): ApiResult> = safeApiCall { api.listDevices() }
+
+ suspend fun deleteDevice(id: Long): ApiResult = safeApiCall { api.deleteDevice(id) }
+
+ suspend fun streams(): ApiResult = safeApiCall { api.streams() }
+
+ suspend fun subscriptions(): ApiResult =
+ safeApiCall { api.subscriptions() }
+
+ suspend fun setSubscriptions(streams: List): ApiResult =
+ safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
+}
diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
index e76a735..2bf2471 100644
--- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
+++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
@@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.MeApi
+import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
import dagger.Module
@@ -106,6 +107,12 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java)
+ /** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
+ @Provides
+ @Singleton
+ fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
+ retrofit.create(NotificationsApi::class.java)
+
/**
* Token refresh runs on its own **bare** client — UA + host retargeting only,
* no auth interceptor and no authenticator — so a refresh can never recurse
diff --git a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
index 842cef6..885a397 100644
--- a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt
@@ -6,6 +6,7 @@ package com.runicgateway.app.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
+import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.repository.ConnectionRepository
@@ -27,6 +28,7 @@ class AppViewModel @Inject constructor(
private val connectionRepository: ConnectionRepository,
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
+ private val pushManager: PushManager,
) : ViewModel() {
sealed interface AppState {
@@ -66,8 +68,16 @@ class AppViewModel @Inject constructor(
}
}
- private suspend fun loadBrand(): BrandDto? =
- (settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
+ /**
+ * Load public settings for branding and feed the shard's push relay URL into the
+ * [PushManager] (§11) — its arrival is what lets push re-register after a restart
+ * or sign-in. Returns the brand block (null if settings couldn't be loaded).
+ */
+ private suspend fun loadBrand(): BrandDto? {
+ val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
+ pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
+ return settings?.brand
+ }
/**
* Resolve a possibly site-relative asset path (branding logos, post images)
diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
index 3856f96..63769ad 100644
--- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt
@@ -56,6 +56,7 @@ import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen
+import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen
@@ -75,6 +76,7 @@ import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
+ Routes.NOTIFICATIONS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
)
@@ -91,6 +93,8 @@ fun RunicApp(
brand: BrandDto?,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
+ deepLinkStream: String? = null,
+ onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
) {
val navController = rememberNavController()
@@ -105,6 +109,16 @@ fun RunicApp(
onPauseOrDispose { }
}
+ // A tapped push notification deep-links to its stream's screen (§11, item 7).
+ LaunchedEffect(deepLinkStream) {
+ val stream = deepLinkStream ?: return@LaunchedEffect
+ navController.navigate(Routes.forStream(stream)) {
+ popUpTo(Routes.HOME) { saveState = true }
+ launchSingleTop = true
+ }
+ onDeepLinkConsumed()
+ }
+
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
@@ -309,6 +323,14 @@ private fun RunicNavHost(
}
}
}
+ composable(Routes.NOTIFICATIONS) {
+ // Signed-in only; a sign-out (or demotion) sends the user home rather than
+ // leaving stale settings up. The backend gates every call regardless (§5).
+ when (session) {
+ is Session.SignedIn -> NotificationsScreen()
+ Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
+ }
+ }
// ── Player game data (§6.3) — reached from the player-only menu groups.
// The server enforces the player gate on every call; these screens simply
diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
index 0f97553..88d1b6a 100644
--- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
@@ -44,6 +44,7 @@ val APP_MENU: List = listOf(
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
+ MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt
index d3e39db..b508b12 100644
--- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt
+++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt
@@ -18,6 +18,9 @@ object Routes {
const val LOGIN = "login"
const val ACCOUNT = "account"
+ /** Opt-in push notification settings (§11, signed-in). */
+ const val NOTIFICATIONS = "notifications"
+
/** Public shard hub (§6.2). */
const val SHARD = "shard"
@@ -57,4 +60,23 @@ object Routes {
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
fun playerChar(serial: String) = "player/char/$serial"
+
+ /**
+ * The in-app destination a tapped push notification deep-links to (§11, M7
+ * Part 2 work item 7). Maps a stream id to the screen that shows its content;
+ * unknown streams land on Home. Personal streams route to the player groups
+ * (a signed-out/demoted tap is caught by [com.runicgateway.app.ui.PlayerGate]).
+ */
+ fun forStream(streamId: String): String = when (streamId) {
+ com.runicgateway.app.core.push.PushStreams.NEWS_POST -> NEWS
+ com.runicgateway.app.core.push.PushStreams.SERVER_STATUS,
+ com.runicgateway.app.core.push.PushStreams.CHAMP_START,
+ com.runicgateway.app.core.push.PushStreams.IDOC_WARNING,
+ com.runicgateway.app.core.push.PushStreams.GOVERNOR_ELECTION,
+ -> SHARD
+ com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
+ com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
+ com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
+ else -> HOME
+ }
}
diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsScreen.kt
new file mode 100644
index 0000000..0fe8ef4
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsScreen.kt
@@ -0,0 +1,182 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.ui.notifications
+
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.runicgateway.app.R
+import com.runicgateway.app.data.api.dto.NotificationStreamDto
+import com.runicgateway.app.ui.UiState
+import com.runicgateway.app.ui.components.EmptyView
+import com.runicgateway.app.ui.components.ErrorView
+import com.runicgateway.app.ui.components.LoadingView
+import com.runicgateway.app.ui.components.SectionLabel
+
+/**
+ * The Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6): the
+ * subscribable catalog with per-stream toggles. Personal streams are greyed until a
+ * game account is linked; turning a stream on requests the POST_NOTIFICATIONS
+ * permission (API 33+) and registers the device, turning them all off unregisters it.
+ */
+@Composable
+fun NotificationsScreen(
+ modifier: Modifier = Modifier,
+ viewModel: NotificationsViewModel = hiltViewModel(),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+
+ // Ask once for POST_NOTIFICATIONS when the user first enables a stream (API 33+).
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { /* granted or not, the subscription is already saved server-side */ }
+
+ fun ensureNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.notifications_title),
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.notifications_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(16.dp))
+
+ if (!state.supported) {
+ EmptyView(message = stringResource(R.string.notifications_unsupported))
+ return@Column
+ }
+
+ state.feedback?.let { fb ->
+ Text(
+ text = stringResource(fb.messageRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(bottom = 12.dp),
+ )
+ }
+
+ when (val catalog = state.catalog) {
+ is UiState.Loading -> LoadingView()
+ is UiState.Error -> ErrorView(kind = catalog.kind, onRetry = viewModel::load)
+ is UiState.Success -> StreamList(
+ streams = catalog.data,
+ subscribed = state.subscribed,
+ hasLinkedAccount = state.hasLinkedAccount,
+ busy = state.busy,
+ onToggle = { stream, on ->
+ if (on) ensureNotificationPermission()
+ viewModel.setSubscribed(stream, on)
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun StreamList(
+ streams: List,
+ subscribed: Set,
+ hasLinkedAccount: Boolean,
+ busy: Boolean,
+ onToggle: (NotificationStreamDto, Boolean) -> Unit,
+) {
+ if (streams.isEmpty()) {
+ EmptyView(message = stringResource(R.string.notifications_empty))
+ return
+ }
+ val (personal, general) = streams.partition { it.personal }
+
+ if (general.isNotEmpty()) {
+ SectionLabel(stringResource(R.string.notifications_section_general))
+ Spacer(Modifier.height(8.dp))
+ general.forEach { stream ->
+ StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
+ onToggle(stream, on)
+ }
+ HorizontalDivider()
+ }
+ Spacer(Modifier.height(20.dp))
+ }
+
+ if (personal.isNotEmpty()) {
+ SectionLabel(stringResource(R.string.notifications_section_personal))
+ Spacer(Modifier.height(8.dp))
+ personal.forEach { stream ->
+ val selectable = streamSelectable(stream, hasLinkedAccount)
+ val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
+ StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
+ onToggle(stream, on)
+ }
+ HorizontalDivider()
+ }
+ }
+}
+
+@Composable
+private fun StreamRow(
+ stream: NotificationStreamDto,
+ checked: Boolean,
+ enabled: Boolean,
+ hint: String?,
+ onToggle: (Boolean) -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
+ Text(
+ text = stream.label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = hint ?: stream.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
+ )
+ }
+ Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
+ }
+}
diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsViewModel.kt
new file mode 100644
index 0000000..9d408bf
--- /dev/null
+++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsViewModel.kt
@@ -0,0 +1,135 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.runicgateway.app.ui.notifications
+
+import androidx.annotation.StringRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.runicgateway.app.R
+import com.runicgateway.app.core.push.PushManager
+import com.runicgateway.app.core.result.ApiResult
+import com.runicgateway.app.data.api.dto.NotificationStreamDto
+import com.runicgateway.app.data.repository.NotificationsRepository
+import com.runicgateway.app.data.repository.PlayerShardRepository
+import com.runicgateway.app.ui.UiState
+import com.runicgateway.app.ui.toUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Drives the Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6):
+ * the stream catalog with per-stream toggles bound to
+ * `GET/PUT /auth/me/notifications/subscriptions`. A **personal** stream is greyed
+ * until the user has a linked game account (§11), and turning the opt-in set
+ * non-empty/empty drives the [PushManager] to register/unregister the device.
+ */
+@HiltViewModel
+class NotificationsViewModel @Inject constructor(
+ private val notifications: NotificationsRepository,
+ private val playerShard: PlayerShardRepository,
+ private val pushManager: PushManager,
+) : ViewModel() {
+
+ data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
+
+ data class State(
+ val catalog: UiState> = UiState.Loading,
+ val subscribed: Set = emptySet(),
+ /** Whether the user has ≥1 linked game account — personal streams need it. */
+ val hasLinkedAccount: Boolean = false,
+ /** Whether this shard advertises a push relay at all (else the screen says so). */
+ val supported: Boolean = true,
+ val busy: Boolean = false,
+ val feedback: Feedback? = null,
+ )
+
+ private val _state = MutableStateFlow(State())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
+ }
+ load()
+ }
+
+ fun load() {
+ _state.update { it.copy(catalog = UiState.Loading) }
+ viewModelScope.launch {
+ val catalog = notifications.streams().let { result ->
+ when (result) {
+ is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
+ is ApiResult.HttpError -> result
+ is ApiResult.NetworkError -> result
+ }
+ }
+ _state.update { it.copy(catalog = catalog.toUiState()) }
+
+ when (val subs = notifications.subscriptions()) {
+ is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
+ else -> Unit
+ }
+ // A linked game account gates the personal streams; failure → treat as none.
+ val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
+ _state.update { it.copy(hasLinkedAccount = linked) }
+ }
+ }
+
+ fun clearFeedback() = _state.update { it.copy(feedback = null) }
+
+ /** Toggle [stream]; refuses a personal stream with no linked account. */
+ fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
+ val s = _state.value
+ if (s.busy) return
+ if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
+ val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
+
+ _state.update { it.copy(busy = true, feedback = null) }
+ viewModelScope.launch {
+ when (val result = notifications.setSubscriptions(next.toList())) {
+ is ApiResult.Ok -> {
+ val stored = result.data.streams.toSet()
+ _state.update { it.copy(subscribed = stored) }
+ reconcilePush(stored)
+ }
+ is ApiResult.NetworkError -> finish(false, R.string.error_network)
+ is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
+ }
+ }
+ }
+
+ /**
+ * Register or unregister the device to match the opted-in set (PLAN.md §11:
+ * register when signed-in + subscribed, unregister when the set empties).
+ */
+ private suspend fun reconcilePush(subscribed: Set) {
+ if (subscribed.isEmpty()) {
+ pushManager.disable()
+ finish(true, R.string.notifications_all_off)
+ return
+ }
+ when (val res = pushManager.enable()) {
+ is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
+ is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
+ is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
+ is PushManager.PushResult.Failed ->
+ finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
+ }
+ }
+
+ private fun finish(ok: Boolean, @StringRes messageRes: Int) =
+ _state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
+}
+
+/**
+ * Whether a stream's toggle is selectable for a user: a personal stream needs a
+ * linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
+ */
+fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
+ !stream.requiresLinkedAccount || hasLinkedAccount
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a4d157a..44a16e7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -257,4 +257,37 @@
No houses are in danger right now.
A house
IDOC
+
+
+ Notifications
+ Notifications
+ Choose what this shard notifies you about. Nothing is sent unless you turn it on.
+ General
+ Your game account
+ Link a game account to enable this.
+ This shard offers no notification streams yet.
+ This shard hasn\'t set up push notifications yet.
+ Notification settings saved.
+ Notifications turned off.
+ Couldn\'t save your notification settings. Try again.
+ This shard\'s push relay isn\'t reachable right now.
+
+
+ Shard notifications
+ Alerts you opted into from this shard.
+ Background connection
+ Keeps the connection open to deliver notifications.
+ Notifications active
+ Listening for shard notifications.
+
+
+ New post
+ Shard status changed
+ A house is falling (IDOC)
+ Champion spawn started
+ New governor elected
+ Your vendor made a sale
+ Your house entered IDOC
+ Login to your account
+ New notification
diff --git a/app/src/test/java/com/runicgateway/app/core/push/NtfyTopicTest.kt b/app/src/test/java/com/runicgateway/app/core/push/NtfyTopicTest.kt
new file mode 100644
index 0000000..2498ece
--- /dev/null
+++ b/app/src/test/java/com/runicgateway/app/core/push/NtfyTopicTest.kt
@@ -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"))
+ }
+}
diff --git a/app/src/test/java/com/runicgateway/app/core/push/PushTickleTest.kt b/app/src/test/java/com/runicgateway/app/core/push/PushTickleTest.kt
new file mode 100644
index 0000000..9460a7c
--- /dev/null
+++ b/app/src/test/java/com/runicgateway/app/core/push/PushTickleTest.kt
@@ -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:"" }.
+ 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"}"""))
+ }
+}
diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt
new file mode 100644
index 0000000..b1ec4c4
--- /dev/null
+++ b/app/src/test/java/com/runicgateway/app/data/api/dto/NotificationsDtoTest.kt
@@ -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(
+ """{"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(
+ """{"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(
+ """{"streams":["news.post","champ.start"]}""",
+ )
+ assertEquals(listOf("news.post", "champ.start"), dto.streams)
+ }
+
+ @Test fun settingsPushBlockDecodes() {
+ val dto = json.decodeFromString(
+ """{"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(
+ """{"site_title":"Shard","brand":{"name":"Shard"}}""",
+ )
+ assertNull(dto.push.ntfyUrl)
+ }
+}
diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt
new file mode 100644
index 0000000..b993a61
--- /dev/null
+++ b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt
@@ -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))
+ }
+}