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