feat(push): M7 Part 2 — opt-in push notifications (embedded ntfy distributor) #15

Merged
whitlocktech merged 1 commits from feat/m7-push-notifications into main 2026-07-20 21:18:29 +00:00
28 changed files with 1559 additions and 3 deletions

View File

@@ -6,6 +6,13 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
and a foreground service that holds the persistent ntfy connection open — the
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".RunicGatewayApp" android:name=".RunicGatewayApp"
android:allowBackup="true" android:allowBackup="true"
@@ -26,6 +33,13 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
dataSync foreground type; not exported — started only by PushManager. -->
<service
android:name=".core.push.PushService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>

View File

@@ -3,6 +3,7 @@
*/ */
package com.runicgateway.app package com.runicgateway.app
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -14,7 +15,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel import com.runicgateway.app.ui.AppViewModel
@@ -35,8 +39,15 @@ import dagger.hilt.android.AndroidEntryPoint
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { 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<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
// Dark-only app (M5): force light system-bar icons over the transparent bars so // 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. // they stay legible on the deep blue-black surfaces regardless of system theme.
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT) val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
@@ -58,11 +69,23 @@ class MainActivity : ComponentActivity() {
AppState.NeedsConnection -> AppState.NeedsConnection ->
ConnectScreen(onConnected = appViewModel::onConnected) ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready -> 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 }
}
} }

View File

@@ -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 (`<ntfy>/<topic>/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 `<ntfyBaseUrl>/<topic>/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<Event> = 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<Unit>()
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
}
}

View File

@@ -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://<ntfy-host>/<topic>`) 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>/<topic>`. [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: `<ntfyBaseUrl>/<topic>/sse`. */
fun sseUrl(ntfyBaseUrl: String?, topic: String): String? =
endpointUrl(ntfyBaseUrl, topic)?.let { "$it/sse" }
}

View File

@@ -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<Boolean> = prefs.enabled
/** Whether this shard advertises a push relay at all (null ntfyUrl → unsupported). */
val supported: Flow<Boolean> = 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"
}
}

View File

@@ -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"
}
}

View File

@@ -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<Preferences> 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<Boolean> = store.data.map { it[KEY_ENABLED] ?: false }
val ntfyUrl: Flow<String?> = 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")
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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<PushDeviceDto>
@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
}

View File

@@ -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://<ntfy-host>/<topic>`); 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<NotificationStreamDto> = 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<String> = emptyList(),
)

View File

@@ -55,6 +55,17 @@ data class RegistrationFlagsDto(
val sso: Boolean = false, 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 * `GET /public/settings` — whitelisted settings + branding. Only the keys the
* app consumes are modeled; other whitelisted keys are ignored. * app consumes are modeled; other whitelisted keys are ignored.
@@ -67,4 +78,6 @@ data class SettingsDto(
val registration: RegistrationFlagsDto = RegistrationFlagsDto(), val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
val gameAccountSignup: Boolean = false, val gameAccountSignup: Boolean = false,
val brand: BrandDto = BrandDto(), val brand: BrandDto = BrandDto(),
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
val push: PushConfigDto = PushConfigDto(),
) )

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.data.repository package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager 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.AuthApi
import com.runicgateway.app.data.api.dto.MobileLoginRequest import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest import com.runicgateway.app.data.api.dto.MobileLogoutRequest
@@ -26,6 +27,7 @@ import javax.inject.Singleton
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val authApi: AuthApi, private val authApi: AuthApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val json: Json, private val json: Json,
) { ) {
@@ -75,6 +77,16 @@ class AuthRepository @Inject constructor(
* network call fails, so the user is always signed out locally. * network call fails, so the user is always signed out locally.
*/ */
suspend fun logout(allDevices: Boolean = false) { 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() val refreshToken = sessionManager.currentRefreshToken()
try { try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices)) authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))

View File

@@ -26,6 +26,7 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences, private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig, private val config: com.runicgateway.app.core.AppConfig,
) { ) {
@@ -96,6 +97,14 @@ class ConnectionRepository @Inject constructor(
* signed-out state against the new host. * signed-out state against the new host.
*/ */
suspend fun disconnect() { 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() sessionManager.onSignedOut()
prefs.clear() prefs.clear()
baseUrlHolder.set(null) baseUrlHolder.set(null)

View File

@@ -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<PushDeviceDto> =
safeApiCall { api.registerDevice(RegisterDeviceRequest(endpoint = endpoint, platform = platform)) }
suspend fun listDevices(): ApiResult<List<PushDeviceDto>> = safeApiCall { api.listDevices() }
suspend fun deleteDevice(id: Long): ApiResult<Unit> = safeApiCall { api.deleteDevice(id) }
suspend fun streams(): ApiResult<NotificationStreamsDto> = safeApiCall { api.streams() }
suspend fun subscriptions(): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.subscriptions() }
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
}

View File

@@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.MeApi 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.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.PublicApi
import dagger.Module import dagger.Module
@@ -106,6 +107,12 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi = fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java) 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, * Token refresh runs on its own **bare** client — UA + host retargeting only,
* no auth interceptor and no authenticator — so a refresh can never recurse * no auth interceptor and no authenticator — so a refresh can never recurse

View File

@@ -6,6 +6,7 @@ package com.runicgateway.app.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder 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.core.result.ApiResult
import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.repository.ConnectionRepository import com.runicgateway.app.data.repository.ConnectionRepository
@@ -27,6 +28,7 @@ class AppViewModel @Inject constructor(
private val connectionRepository: ConnectionRepository, private val connectionRepository: ConnectionRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val pushManager: PushManager,
) : ViewModel() { ) : ViewModel() {
sealed interface AppState { 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) * Resolve a possibly site-relative asset path (branding logos, post images)

View File

@@ -56,6 +56,7 @@ import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen 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.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen 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. */ /** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf( private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, 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, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
) )
@@ -91,6 +93,8 @@ fun RunicApp(
brand: BrandDto?, brand: BrandDto?,
onChangeServer: () -> Unit, onChangeServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
deepLinkStream: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel(),
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
@@ -105,6 +109,16 @@ fun RunicApp(
onPauseOrDispose { } 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 backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES 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. // ── Player game data (§6.3) — reached from the player-only menu groups.
// The server enforces the player gate on every call; these screens simply // The server enforces the player gate on every call; these screens simply

View File

@@ -44,6 +44,7 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), 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_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),

View File

@@ -18,6 +18,9 @@ object Routes {
const val LOGIN = "login" const val LOGIN = "login"
const val ACCOUNT = "account" const val ACCOUNT = "account"
/** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications"
/** Public shard hub (§6.2). */ /** Public shard hub (§6.2). */
const val SHARD = "shard" const val SHARD = "shard"
@@ -57,4 +60,23 @@ object Routes {
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */ /** The character-sheet route for an in-game serial (e.g. "0x24C"). */
fun playerChar(serial: String) = "player/char/$serial" 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
}
} }

View File

@@ -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<NotificationStreamDto>,
subscribed: Set<String>,
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)
}
}

View File

@@ -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<List<NotificationStreamDto>> = UiState.Loading,
val subscribed: Set<String> = 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> = _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<String>) {
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

View File

@@ -257,4 +257,37 @@
<string name="houses_empty">No houses are in danger right now.</string> <string name="houses_empty">No houses are in danger right now.</string>
<string name="houses_fallback_name">A house</string> <string name="houses_fallback_name">A house</string>
<string name="houses_idoc_badge">IDOC</string> <string name="houses_idoc_badge">IDOC</string>
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
<string name="menu_notifications">Notifications</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_subtitle">Choose what this shard notifies you about. Nothing is sent unless you turn it on.</string>
<string name="notifications_section_general">General</string>
<string name="notifications_section_personal">Your game account</string>
<string name="notifications_requires_link">Link a game account to enable this.</string>
<string name="notifications_empty">This shard offers no notification streams yet.</string>
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
<string name="notifications_saved">Notification settings saved.</string>
<string name="notifications_all_off">Notifications turned off.</string>
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
<!-- Notification channels + the ongoing foreground-service notification. -->
<string name="push_channel_messages">Shard notifications</string>
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
<string name="push_channel_service">Background connection</string>
<string name="push_channel_service_desc">Keeps the connection open to deliver notifications.</string>
<string name="push_service_title">Notifications active</string>
<string name="push_service_text">Listening for shard notifications.</string>
<!-- Per-stream notification titles (content-free tickle → generic title, §11). -->
<string name="push_stream_news_post">New post</string>
<string name="push_stream_server_status">Shard status changed</string>
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
<string name="push_stream_champ_start">Champion spawn started</string>
<string name="push_stream_governor_election">New governor elected</string>
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
<string name="push_stream_house_idoc">Your house entered IDOC</string>
<string name="push_stream_account_login">Login to your account</string>
<string name="push_stream_generic">New notification</string>
</resources> </resources>

View File

@@ -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"))
}
}

View File

@@ -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"}"""))
}
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}