feat(push): M7 Part 2 — opt-in push notifications (embedded ntfy distributor) #15
@@ -6,6 +6,13 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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
|
||||
android:name=".RunicGatewayApp"
|
||||
android:allowBackup="true"
|
||||
@@ -26,6 +33,13 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</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>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -14,7 +15,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.runicgateway.app.core.push.PushNotifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.ui.AppViewModel
|
||||
@@ -35,8 +39,15 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
|
||||
// Set from the launching intent and from onNewIntent (the activity is singleTop),
|
||||
// consumed once by RunicApp which navigates to the stream's screen.
|
||||
private var pendingStream by mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||
@@ -58,11 +69,23 @@ class MainActivity : ComponentActivity() {
|
||||
AppState.NeedsConnection ->
|
||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||
is AppState.Ready ->
|
||||
RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
|
||||
RunicApp(
|
||||
brand = s.brand,
|
||||
onChangeServer = appViewModel::changeServer,
|
||||
deepLinkStream = pendingStream,
|
||||
onDeepLinkConsumed = { pendingStream = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A notification tapped while the activity is already running (singleTop). */
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Push-notification relay config (M7). [ntfyUrl] is the client-facing ntfy base
|
||||
* URL the app's embedded distributor registers its device topic against; null (or
|
||||
* absent, on an older backend) means push isn't configured for this shard and the
|
||||
* Notifications screen shows it as unavailable.
|
||||
*/
|
||||
@Serializable
|
||||
data class PushConfigDto(
|
||||
val ntfyUrl: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
|
||||
* app consumes are modeled; other whitelisted keys are ignored.
|
||||
@@ -67,4 +78,6 @@ data class SettingsDto(
|
||||
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
|
||||
val gameAccountSignup: Boolean = false,
|
||||
val brand: BrandDto = BrandDto(),
|
||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
||||
val push: PushConfigDto = PushConfigDto(),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
@@ -26,6 +27,7 @@ import javax.inject.Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val authApi: AuthApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
@@ -75,6 +77,16 @@ class AuthRepository @Inject constructor(
|
||||
* network call fails, so the user is always signed out locally.
|
||||
*/
|
||||
suspend fun logout(allDevices: Boolean = false) {
|
||||
// Deregister this device's push endpoint while the bearer is still valid, so
|
||||
// no orphan device row is left behind (§11). Keeps the opt-in intent so push
|
||||
// resumes on the next sign-in; best-effort, never blocks the logout.
|
||||
try {
|
||||
pushManager.deregisterDevice()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Ignore — local session teardown proceeds regardless.
|
||||
}
|
||||
val refreshToken = sessionManager.currentRefreshToken()
|
||||
try {
|
||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||
|
||||
@@ -26,6 +26,7 @@ class ConnectionRepository @Inject constructor(
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
|
||||
@@ -96,6 +97,14 @@ class ConnectionRepository @Inject constructor(
|
||||
* signed-out state against the new host.
|
||||
*/
|
||||
suspend fun disconnect() {
|
||||
// Deregister the push endpoint on the current (old) host while still authed,
|
||||
// then clear the shard's ntfy URL — the new host advertises its own (§11).
|
||||
try {
|
||||
pushManager.deregisterDevice()
|
||||
} catch (_: Exception) {
|
||||
// Best-effort; the reset proceeds regardless.
|
||||
}
|
||||
pushManager.setNtfyUrl(null)
|
||||
sessionManager.onSignedOut()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
|
||||
@@ -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.AuthRefreshApi
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.NotificationsApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import dagger.Module
|
||||
@@ -106,6 +107,12 @@ object NetworkModule {
|
||||
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
||||
retrofit.create(PlayerShardApi::class.java)
|
||||
|
||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
||||
retrofit.create(NotificationsApi::class.java)
|
||||
|
||||
/**
|
||||
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||
|
||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||
@@ -27,6 +28,7 @@ class AppViewModel @Inject constructor(
|
||||
private val connectionRepository: ConnectionRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val pushManager: PushManager,
|
||||
) : ViewModel() {
|
||||
|
||||
sealed interface AppState {
|
||||
@@ -66,8 +68,16 @@ class AppViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBrand(): BrandDto? =
|
||||
(settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
|
||||
/**
|
||||
* Load public settings for branding and feed the shard's push relay URL into the
|
||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
|
||||
*/
|
||||
private suspend fun loadBrand(): BrandDto? {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||
return settings?.brand
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a possibly site-relative asset path (branding logos, post images)
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||
import com.runicgateway.app.ui.player.CharactersScreen
|
||||
@@ -75,6 +76,7 @@ import kotlinx.coroutines.launch
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||
Routes.NOTIFICATIONS,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
)
|
||||
|
||||
@@ -91,6 +93,8 @@ fun RunicApp(
|
||||
brand: BrandDto?,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
deepLinkStream: String? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
@@ -105,6 +109,16 @@ fun RunicApp(
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
||||
LaunchedEffect(deepLinkStream) {
|
||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
||||
navController.navigate(Routes.forStream(stream)) {
|
||||
popUpTo(Routes.HOME) { saveState = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
onDeepLinkConsumed()
|
||||
}
|
||||
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
@@ -309,6 +323,14 @@ private fun RunicNavHost(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
||||
when (session) {
|
||||
is Session.SignedIn -> NotificationsScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Player game data (§6.3) — reached from the player-only menu groups.
|
||||
// The server enforces the player gate on every call; these screens simply
|
||||
|
||||
@@ -44,6 +44,7 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
||||
|
||||
@@ -18,6 +18,9 @@ object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** Opt-in push notification settings (§11, signed-in). */
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
@@ -57,4 +60,23 @@ object Routes {
|
||||
|
||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||
fun playerChar(serial: String) = "player/char/$serial"
|
||||
|
||||
/**
|
||||
* The in-app destination a tapped push notification deep-links to (§11, M7
|
||||
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
||||
* unknown streams land on Home. Personal streams route to the player groups
|
||||
* (a signed-out/demoted tap is caught by [com.runicgateway.app.ui.PlayerGate]).
|
||||
*/
|
||||
fun forStream(streamId: String): String = when (streamId) {
|
||||
com.runicgateway.app.core.push.PushStreams.NEWS_POST -> NEWS
|
||||
com.runicgateway.app.core.push.PushStreams.SERVER_STATUS,
|
||||
com.runicgateway.app.core.push.PushStreams.CHAMP_START,
|
||||
com.runicgateway.app.core.push.PushStreams.IDOC_WARNING,
|
||||
com.runicgateway.app.core.push.PushStreams.GOVERNOR_ELECTION,
|
||||
-> SHARD
|
||||
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
|
||||
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
|
||||
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
||||
else -> HOME
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_fallback_name">A house</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>
|
||||
|
||||
@@ -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