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