diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index b26a2b7..57dc269 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -1,5 +1,5 @@ -# Gate every pull request into `main` on lint + unit tests + a debug build, so a -# broken build can't reach the deployable branch. Debug builds are auto-signed, +# Gate every pull request into `main` or `edge` on lint + unit tests + a debug +# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed, # so this gate needs no secrets. The signed *release* APK + Gitea release come # later (release.yml, M6). See docs/android/PLAN.md §12. # @@ -18,9 +18,14 @@ name: PR Checks +# `edge` is here because a workstream that lands ten phase PRs onto it before one +# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is +# exactly what happened to all nine M12 phase PRs, and would have happened again +# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own +# PR, not inside the cutover window with a whole workstream's diff to bisect. on: pull_request: - branches: [main] + branches: [main, edge] concurrency: group: pr-checks-${{ github.ref }} diff --git a/README.md b/README.md index 88a3f31..5fc04e2 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host. ## CI -`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the -org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no -secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK -attached to a Gitea release comes at M6. +`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with +`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug +builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on +the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6. + +**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch +before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what +happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is +unaffected: it is a push-on-`main` analysis, not a PR gate. The workflow carries a few runner-specific accommodations (each explained in comments in the file), because this self-hosted runner differs from a stock GitHub runner: diff --git a/app/src/main/java/com/runicgateway/app/MainActivity.kt b/app/src/main/java/com/runicgateway/app/MainActivity.kt index 2a22b4b..5971390 100644 --- a/app/src/main/java/com/runicgateway/app/MainActivity.kt +++ b/app/src/main/java/com/runicgateway/app/MainActivity.kt @@ -58,9 +58,16 @@ class MainActivity : ComponentActivity() { // consumed once by RunicApp which navigates to the stream's screen. private var pendingStream by mutableStateOf(null) + // The tickle's other half: an opaque ref, carried since M7 and read since + // ENGAGEMENT.md phase 8, where a `notification:` ref means the engine wrote + // an inbox row and the tap should land there. Never rendered — it is a hint that + // something exists, and the app pulls the real item over the authenticated API. + private var pendingRef by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM) + pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF) handleSsoCallback(intent) // 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. @@ -99,7 +106,11 @@ class MainActivity : ComponentActivity() { appearance = s.appearance, onChangeServer = appViewModel::changeServer, deepLinkStream = pendingStream, - onDeepLinkConsumed = { pendingStream = null }, + deepLinkRef = pendingRef, + onDeepLinkConsumed = { + pendingStream = null + pendingRef = null + }, ) } } @@ -116,7 +127,13 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it } + intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { + pendingStream = it + // Cleared alongside, not conditionally: a tickle with no ref arriving + // after one with a ref must not inherit the earlier ref and land on the + // inbox instead of its own screen. + pendingRef = intent.getStringExtra(PushNotifier.EXTRA_REF) + } handleSsoCallback(intent) } diff --git a/app/src/main/java/com/runicgateway/app/core/inbox/DataStoreInboxCache.kt b/app/src/main/java/com/runicgateway/app/core/inbox/DataStoreInboxCache.kt new file mode 100644 index 0000000..021706f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/inbox/DataStoreInboxCache.kt @@ -0,0 +1,74 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.inbox + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.runicgateway.app.data.api.dto.NotificationItemDto +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.inboxDataStore: DataStore by preferencesDataStore(name = "inbox") + +/** + * [InboxCache] over the same plain DataStore the push state uses. Not secret — + * tokens stay in the encrypted store — but an inbox body is a person's own + * notifications, which is why the snapshot is owner-scoped and cleared on + * sign-out rather than left lying about. + */ +@Singleton +class DataStoreInboxCache @Inject constructor( + @param:ApplicationContext private val context: Context, + private val json: Json, +) : InboxCache { + + private val store = context.inboxDataStore + + override suspend fun read(owner: String): InboxCache.Snapshot? { + val raw = store.data.first()[KEY_SNAPSHOT] ?: return null + val stored = try { + json.decodeFromString(Stored.serializer(), raw) + } catch (_: Exception) { + // A snapshot this build can't parse is a snapshot from an older one; + // dropping it silently is right — it will be rewritten on the next pull. + return null + } + if (stored.owner != owner) return null + return InboxCache.Snapshot(items = stored.items, unread = stored.unread, savedAt = stored.savedAt) + } + + override suspend fun write(owner: String, items: List, unread: Int) { + val payload = Stored( + owner = owner, + items = items.take(InboxCache.MAX_ITEMS), + unread = unread, + savedAt = System.currentTimeMillis(), + ) + store.edit { it[KEY_SNAPSHOT] = json.encodeToString(Stored.serializer(), payload) } + } + + override suspend fun clear() { + store.edit { it.remove(KEY_SNAPSHOT) } + } + + @Serializable + private data class Stored( + val owner: String, + val items: List, + val unread: Int, + val savedAt: Long, + ) + + private companion object { + val KEY_SNAPSHOT = stringPreferencesKey("snapshot") + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/inbox/InboxCache.kt b/app/src/main/java/com/runicgateway/app/core/inbox/InboxCache.kt new file mode 100644 index 0000000..5036346 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/inbox/InboxCache.kt @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.core.inbox + +import com.runicgateway.app.data.api.dto.NotificationItemDto + +/** + * The inbox's offline snapshot (ENGAGEMENT.md phase 8). + * + * **PLAN.md §7 decided the app ships no Room cache, and that decision stands** — + * this is its one named exception, settled with the org lead 2026-08-31. The + * inbox is a short, read-only, newest-first list with a server-side cursor and no + * joins, so what "works offline" needs is the newest page and the badge, not a + * database: one JSON blob in the DataStore the push code already uses. Nothing + * here is a source of truth — a successful pull always replaces it, and the + * screen says out loud when it is showing this instead. + * + * **The [owner] key is the security property, not a convenience.** A snapshot is + * written under the base URL *and* the account id that produced it and is only + * ever handed back to that exact pair, so a cache cannot survive into another + * account or another shard — including the sign-out paths that never reach + * [clear] at all (a dead refresh token, a server switch). Clearing on logout is + * the tidy-up; this is what makes it safe. + * + * An interface for the same reason [com.runicgateway.app.core.auth.TokenStore] is + * one: the storage needs a `Context` and the view models that use it should be + * testable without one. + */ +interface InboxCache { + + /** + * What was cached for [owner], or null when nothing was — including when the + * stored snapshot belongs to a different account or shard, which is the same + * answer on purpose. + */ + suspend fun read(owner: String): Snapshot? + + /** + * Replace the snapshot with the newest page. + * + * Only the FIRST page is ever cached, capped at [MAX_ITEMS]: an offline inbox + * is there so the last things you were told are still readable on a train, not + * so the whole history is. Later pages come from the server or not at all. + */ + suspend fun write(owner: String, items: List, unread: Int) + + /** Forget everything. Called on sign-out, alongside the push deregistration. */ + suspend fun clear() + + /** What the screen renders from while offline, with the time it was captured. */ + data class Snapshot( + val items: List, + val unread: Int, + val savedAt: Long, + ) + + companion object { + /** The server's own default page size — caching more than it sends is pointless. */ + const val MAX_ITEMS = 30 + + /** The (shard, account) a snapshot belongs to. */ + fun ownerKey(baseUrl: String?, userId: Long): String = "${baseUrl.orEmpty()}|$userId" + } +} 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 index 8e23716..c9ca7e1 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/NotificationsApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/NotificationsApi.kt @@ -3,8 +3,13 @@ */ package com.runicgateway.app.data.api +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto +import com.runicgateway.app.data.api.dto.NotificationInboxDto +import com.runicgateway.app.data.api.dto.NotificationReadResultDto import com.runicgateway.app.data.api.dto.NotificationStreamsDto import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto +import com.runicgateway.app.data.api.dto.NotificationUnreadDto import com.runicgateway.app.data.api.dto.PushDeviceDto import com.runicgateway.app.data.api.dto.RegisterDeviceRequest import retrofit2.http.Body @@ -13,10 +18,13 @@ import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path +import retrofit2.http.Query /** - * 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 + * The notification surface under `/auth/me` (PLAN.md §11): device (endpoint) + * registration, per-user stream subscriptions, the per-channel preferences that + * supersede them (ENGAGEMENT.md phase 3), and the in-app **inbox** — the first + * of these that carries content rather than a preference (phase 7/8). 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. @@ -40,4 +48,41 @@ interface NotificationsApi { @PUT("api/v1/auth/me/notifications/subscriptions") suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto + + // ── Per-channel preferences (ENGAGEMENT.md phase 3) ──────────────────── + // + // The superset of the two calls above: `notification_subscriptions` is now + // the push projection of this table and the server fans every write to + // either one into the other, so the two cannot disagree. + + @GET("api/v1/auth/me/notifications/channels") + suspend fun channelPrefs(): NotificationChannelPrefsDto + + /** SPARSE — send only the pairs that changed; everything unnamed is untouched. */ + @PUT("api/v1/auth/me/notifications/channels") + suspend fun putChannelPrefs( + @Body body: NotificationChannelPrefsUpdateDto, + ): NotificationChannelPrefsDto + + // ── The inbox (ENGAGEMENT.md phase 7/8) ──────────────────────────────── + // + // Keyset-paged on `before`, never an offset. There is no way to name another + // user on any of these: the caller is the only account they can read or write. + + @GET("api/v1/auth/me/notifications") + suspend fun inbox( + @Query("limit") limit: Int? = null, + @Query("before") before: Long? = null, + @Query("unread") unread: Boolean? = null, + ): NotificationInboxDto + + @GET("api/v1/auth/me/notifications/unread-count") + suspend fun unreadCount(): NotificationUnreadDto + + /** Idempotent; 404 both for a missing item and for another account's. */ + @POST("api/v1/auth/me/notifications/{id}/read") + suspend fun markRead(@Path("id") id: Long): NotificationReadResultDto + + @POST("api/v1/auth/me/notifications/read-all") + suspend fun markAllRead(): NotificationReadResultDto } 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 index b99f513..e5ad40e 100644 --- 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 @@ -72,3 +72,139 @@ data class NotificationStreamsDto( data class NotificationSubscriptionsDto( val streams: List, ) + +// ── The in-app channel (ENGAGEMENT.md phase 7/8) ─────────────────────────── +// +// The inbox is the first notification surface that carries CONTENT. Everything +// above is a preference or a content-free tickle; these four shapes are the +// items themselves, pulled over the authenticated API after a tickle wakes the +// app. The wire names come from `userNotifications.db.js`'s `toItem`. + +/** + * One inbox item. [read] is the flag and [readAt] the stamp, sent side by side so + * a client renders one without parsing the other. + * + * [url] is where the item points on the site (rendered from the template's + * `email.button` block) and is **null on most items** — an inbox row is complete + * on its own. [triggerId] is the event that produced it, in §7.2's ONE namespace, + * so it is the same vocabulary a push tickle's `stream` speaks. + */ +@Serializable +data class NotificationItemDto( + val id: Long = 0, + val triggerId: String = "", + val title: String = "", + val body: String? = null, + val url: String? = null, + val read: Boolean = false, + val readAt: String? = null, + val createdAt: String? = null, +) + +/** + * `GET /auth/me/notifications` — one page, newest first. + * + * Keyset-paged: the next page is `?before=`, not an offset, + * because the list gains rows at the top while it is being read. [hasMore] comes + * from the server's take+1, so "is there another page" costs no second query. + * [unread] counts the WHOLE inbox, not the page — it rides along so a screen + * rendering both a badge and a list from one response cannot show the two + * disagreeing. + */ +@Serializable +data class NotificationInboxDto( + val items: List = emptyList(), + val hasMore: Boolean = false, + val unread: Int = 0, +) + +/** `GET /auth/me/notifications/unread-count` — the badge, on its own. */ +@Serializable +data class NotificationUnreadDto( + val unread: Int = 0, +) + +/** + * What both mark-read routes answer with. [unread] is the count AFTER the write, + * so the badge follows from the response rather than from a second call. + */ +@Serializable +data class NotificationReadResultDto( + val ok: Boolean = false, + val changed: Int = 0, + val unread: Int = 0, +) + +// ── Per-channel preferences (ENGAGEMENT.md phase 3) ──────────────────────── + +/** + * One delivery channel from the registry. [modes] is what this channel accepts — + * `["off","instant"]` for push and in-app, `["off","instant","digest"]` for email + * — and the UI renders its control from THIS, never from a hardcoded set, so a + * channel added server-side arrives without an app release. + * + * [carriesContent] is the tickle invariant stated on the wire: push is `false`, + * which is why a push item's title never leaves the server. + */ +@Serializable +data class NotificationChannelDto( + val id: String = "", + val label: String = "", + val carriesContent: Boolean = false, + val defaultMode: String = "off", + val supportsDigest: Boolean = false, + val modes: List = emptyList(), +) + +/** + * One subscribable id, from `GET /auth/me/notifications/channels`. The list is the + * UNION of push streams and event triggers in one namespace (§7.2), so an id may + * be a stream, a trigger, or both. + * + * [channels] is which channels apply to THIS id — a trigger-only id carries no + * `push` because nothing is registered to push it — and [modes] is the EFFECTIVE + * mode per channel: where the user has expressed nothing the server has already + * substituted that channel's default, and the client must not re-implement the + * defaulting. + */ +@Serializable +data class NotificationChannelItemDto( + val id: String = "", + val label: String = "", + val description: String = "", + val personal: Boolean = false, + val requiresLinkedAccount: Boolean = false, + val ceiling: String? = null, + val channels: List = emptyList(), + val modes: Map = emptyMap(), +) + +/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */ +@Serializable +data class NotificationChannelPrefsDto( + val channels: List = emptyList(), + val items: List = emptyList(), +) + +/** One (id, channel) → mode pair of a sparse update. */ +@Serializable +data class NotificationChannelPrefDto( + val id: String, + val channel: String, + val mode: String, +) + +/** + * `PUT /auth/me/notifications/channels` body — a SPARSE update: only the pairs + * named are written and every other pair is left alone, so one toggle saves + * without the screen holding the whole table. + * + * [prefs] has no default for the same reason [NotificationSubscriptionsDto.streams] + * has none — kotlinx omits a property equal to its default, and the validator + * requires the field. Unlike that DTO there is no empty-set case to get wrong + * here: `off` is a mode, never an omission. + */ +@Serializable +data class NotificationChannelPrefsUpdateDto( + val prefs: List, +) 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 f49da81..5b70045 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 @@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository import com.runicgateway.app.core.auth.DeviceNameProvider import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.TrustTokenStore +import com.runicgateway.app.core.inbox.InboxCache import com.runicgateway.app.core.push.PushManager import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.SsoApi @@ -35,6 +36,7 @@ class AuthRepository @Inject constructor( private val ssoApi: SsoApi, private val sessionManager: SessionManager, private val pushManager: PushManager, + private val inboxCache: InboxCache, private val trustTokenStore: TrustTokenStore, private val deviceNameProvider: DeviceNameProvider, private val json: Json, @@ -183,6 +185,18 @@ class AuthRepository @Inject constructor( } catch (_: Exception) { // Ignore — local session teardown proceeds regardless. } + // Drop the cached inbox with it: those are one person's notifications, and + // they have finished with this device. This is the tidy-up, not the + // safeguard — InboxCache scopes every snapshot to (base URL, user id), so + // the paths that never reach here (a dead refresh, a server switch) cannot + // surface one account's items under another's session either. + try { + inboxCache.clear() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Ignore — same reason. + } val refreshToken = sessionManager.currentRefreshToken() try { authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices)) 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 index 92bdecf..064e00d 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/NotificationsRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/NotificationsRepository.kt @@ -6,16 +6,23 @@ 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.NotificationChannelPrefDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto +import com.runicgateway.app.data.api.dto.NotificationInboxDto +import com.runicgateway.app.data.api.dto.NotificationReadResultDto import com.runicgateway.app.data.api.dto.NotificationStreamsDto import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto +import com.runicgateway.app.data.api.dto.NotificationUnreadDto 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 + * Device registration, stream subscriptions, per-channel preferences and the + * in-app inbox — the whole `/auth/me` notification surface (PLAN.md §11, + * ENGAGEMENT.md phases 3 and 7/8). 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). */ @@ -37,4 +44,33 @@ class NotificationsRepository @Inject constructor( suspend fun setSubscriptions(streams: List): ApiResult = safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) } + + // ── Per-channel preferences (phase 3) ────────────────────────────────── + + suspend fun channelPrefs(): ApiResult = + safeApiCall { api.channelPrefs() } + + /** + * Write ONE (id, channel) → mode pair. The endpoint is sparse, so a screen + * saving a single toggle sends a single row and cannot disturb the others — + * including the ones it does not render. + */ + suspend fun setChannelMode(id: String, channel: String, mode: String): ApiResult = + safeApiCall { + api.putChannelPrefs( + NotificationChannelPrefsUpdateDto(listOf(NotificationChannelPrefDto(id, channel, mode))), + ) + } + + // ── The inbox (phase 7/8) ────────────────────────────────────────────── + + /** One page, newest first. [before] is the previous page's last id, never an offset. */ + suspend fun inbox(before: Long? = null, unreadOnly: Boolean = false): ApiResult = + safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) } + + suspend fun unreadCount(): ApiResult = safeApiCall { api.unreadCount() } + + suspend fun markRead(id: Long): ApiResult = safeApiCall { api.markRead(id) } + + suspend fun markAllRead(): ApiResult = safeApiCall { api.markAllRead() } } diff --git a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt index 750d046..f847a9b 100644 --- a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt @@ -11,13 +11,16 @@ import com.runicgateway.app.core.auth.TokenStore import com.runicgateway.app.core.auth.TrustTokenStore import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore import com.runicgateway.app.core.auth.sso.PendingSsoStore +import com.runicgateway.app.core.inbox.DataStoreInboxCache +import com.runicgateway.app.core.inbox.InboxCache import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */ +/** Binds the at-rest stores to their implementations — EncryptedSharedPreferences + * for anything secret (§4.3), plain DataStore for the inbox snapshot. */ @Module @InstallIn(SingletonComponent::class) abstract class StorageModule { @@ -38,4 +41,9 @@ abstract class StorageModule { @Binds @Singleton abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider + + /** The inbox's offline snapshot — plain DataStore, not encrypted (ENGAGEMENT.md phase 8). */ + @Binds + @Singleton + abstract fun bindInboxCache(impl: DataStoreInboxCache): InboxCache } 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 059b530..6fa808f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -37,6 +37,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -75,7 +77,9 @@ import com.runicgateway.app.ui.admin.AdminContentScreen import com.runicgateway.app.ui.admin.AdminDashboardScreen import com.runicgateway.app.ui.admin.AdminModerationScreen import com.runicgateway.app.ui.admin.AdminSupportScreen -import com.runicgateway.app.ui.notifications.NotificationsScreen +import com.runicgateway.app.ui.notifications.InboxBadgeViewModel +import com.runicgateway.app.ui.notifications.InboxScreen +import com.runicgateway.app.ui.notifications.NotificationSettingsScreen import com.runicgateway.app.ui.page.PageScreen import com.runicgateway.app.ui.player.CharacterSheetScreen import com.runicgateway.app.ui.player.CharactersScreen @@ -124,8 +128,10 @@ fun RunicApp( onChangeServer: () -> Unit, modifier: Modifier = Modifier, deepLinkStream: String? = null, + deepLinkRef: String? = null, onDeepLinkConsumed: () -> Unit = {}, sessionViewModel: SessionViewModel = hiltViewModel(), + inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(), ) { val brand = appearance.brand val navController = rememberNavController() @@ -136,16 +142,26 @@ fun RunicApp( // What this shard publishes, independently of who the caller is (§5, M11). val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle() - // Re-validate the cached role each time the app returns to the foreground (§4.3). + // Re-validate the cached role each time the app returns to the foreground (§4.3), + // and re-read the unread count with it: a tickle that arrived while the app was + // away is exactly what brings someone back to it. LifecycleResumeEffect(Unit) { sessionViewModel.revalidate() + inboxBadgeViewModel.refresh() onPauseOrDispose { } } + val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle() + // The badge follows the session, so signing out clears it rather than leaving + // the previous account's count on the drawer. + LaunchedEffect(session) { inboxBadgeViewModel.refresh() } + // A tapped push notification deep-links to its stream's screen (§11, item 7). - LaunchedEffect(deepLinkStream) { + LaunchedEffect(deepLinkStream, deepLinkRef) { val stream = deepLinkStream ?: return@LaunchedEffect - navController.navigate(Routes.forStream(stream)) { + // Both halves of the tickle: a `notification:` ref means there is an inbox + // row waiting, and that is where the tap goes (ENGAGEMENT.md phase 8). + navController.navigate(Routes.forTickle(stream, deepLinkRef)) { popUpTo(Routes.HOME) { saveState = true } launchSingleTop = true } @@ -234,12 +250,16 @@ fun RunicApp( ), ) node.items.forEach { child -> - NavRow(child, currentRoute, drawerItemColors, indented = true) { - openNode(child) - } + NavRow( + node = child, + currentRoute = currentRoute, + colors = drawerItemColors, + indented = true, + unread = unread, + ) { openNode(child) } } } else { - NavRow(node, currentRoute, drawerItemColors) { openNode(node) } + NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) } } } @@ -354,6 +374,7 @@ private fun NavRow( currentRoute: String?, colors: NavigationDrawerItemColors, indented: Boolean = false, + unread: Int = 0, onClick: () -> Unit, ) { val route = when (node) { @@ -369,21 +390,38 @@ private fun NavRow( is NavNode.Section -> return } val handsOff = node is NavNode.Link && node.route == null + // The unread count rides on whichever row leads to the inbox — including an + // admin's own nav override pointing at it, since the badge belongs to the + // destination, not to the bundled entry. + val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS NavigationDrawerItem( label = { Text(label) }, selected = route != null && currentRoute == route.substringBefore('?'), onClick = onClick, - badge = if (!handsOff) { - null - } else { - { - Icon( - Icons.AutoMirrored.Filled.ExitToApp, - contentDescription = stringResource(R.string.nav_opens_in_browser), - modifier = Modifier.size(18.dp), - ) + badge = when { + handsOff -> { + { + Icon( + Icons.AutoMirrored.Filled.ExitToApp, + contentDescription = stringResource(R.string.nav_opens_in_browser), + modifier = Modifier.size(18.dp), + ) + } } + showsUnread -> { + { + // Named for a screen reader: "7" beside "Notifications" reads as + // a count to a sighted user and as a bare number to everyone else. + val spoken = stringResource(R.string.inbox_unread_count, unread) + Text( + text = unread.toString(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.semantics { contentDescription = spoken }, + ) + } + } + else -> null }, colors = colors, // Like Card's elevation, NavigationDrawerItem takes its shape as a default @@ -541,9 +579,20 @@ 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). + // leaving another account's items up. The backend gates every call + // regardless, and the inbox routes are role-agnostic (§5) — staff have an + // inbox for the same reason players do, which on the web took a second + // mount to be true. when (session) { - is Session.SignedIn -> NotificationsScreen() + is Session.SignedIn -> InboxScreen( + onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) }, + ) + Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } + } + } + composable(Routes.NOTIFICATIONS_SETTINGS) { + when (session) { + is Session.SignedIn -> NotificationSettingsScreen() Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } } 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 ac0dc4d..5f4e8b4 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 @@ -37,8 +37,15 @@ object Routes { const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices" const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes" - /** Opt-in push notification settings (§11, signed-in). */ + /** + * The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings. + * + * The bare route is the CONTENT and the named sub-route the preferences, which + * is exactly how the web surface is laid out (`/account/notifications` and + * `…/settings`) — and what a person means when they tap "Notifications". + */ const val NOTIFICATIONS = "notifications" + const val NOTIFICATIONS_SETTINGS = "notifications/settings" /** Public shard hub (§6.2). */ const val SHARD = "shard" @@ -130,4 +137,23 @@ object Routes { com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT else -> HOME } + + /** + * Where a tapped tickle lands, given both halves of `{ stream, ref }`. + * + * **A `notification:` ref means the engine wrote this user an inbox row** + * (`pushChannel.js` builds it), so the tap goes to the inbox whatever the + * stream is — an engagement rule's stream id is a TRIGGER id in §7.2's one + * namespace, and [forStream]'s fixed map would send most of them to Home. + * Every other tickle keeps the route it has always had, so no shipped stream + * changes where it lands. + * + * The ref is not decoded beyond that prefix and is never rendered: it is a + * hint that a row exists, and the app's contract is wake-and-pull. + */ + fun forTickle(streamId: String, ref: String?): String = + if (ref != null && ref.startsWith(INBOX_REF_PREFIX)) NOTIFICATIONS else forStream(streamId) + + /** What `pushChannel.js` prefixes an inbox row's id with. */ + const val INBOX_REF_PREFIX = "notification:" } diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxBadgeViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxBadgeViewModel.kt new file mode 100644 index 0000000..6e2454b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxBadgeViewModel.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.inbox.InboxCache +import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.data.repository.NotificationsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The drawer's unread badge (ENGAGEMENT.md phase 8). + * + * Its own view model, and its own endpoint: `/notifications/unread-count` exists + * precisely because this is the question asked most often and it should not make + * the server assemble a page of bodies to answer with one integer. Refreshed when + * the app resumes rather than on a timer — the tickle is what says "something + * happened", so polling would be a second, worse copy of push. + * + * Falls back to the cached count while offline, for the same reason the inbox + * does: a badge that dropped to zero because the train went into a tunnel would + * be telling the user they have read something they have not. + */ +@HiltViewModel +class InboxBadgeViewModel @Inject constructor( + private val notifications: NotificationsRepository, + private val cache: InboxCache, + private val sessionManager: SessionManager, + private val baseUrlHolder: BaseUrlHolder, +) : ViewModel() { + + private val _unread = MutableStateFlow(0) + val unread: StateFlow = _unread.asStateFlow() + + /** Ask the server, falling back to the snapshot. A signed-out session is zero. */ + fun refresh() = viewModelScope.launch { + val user = (sessionManager.state.value as? Session.SignedIn)?.user + if (user == null) { + _unread.value = 0 + return@launch + } + when (val result = notifications.unreadCount()) { + is ApiResult.Ok -> _unread.value = result.data.unread + else -> { + val owner = InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id) + cache.read(owner)?.let { _unread.value = it.unread } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt new file mode 100644 index 0000000..63156eb --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * Render an inbox item's `createdAt` for display, in the device's own zone and + * locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device. + * + * **Two shapes have to be accepted, and which one arrives is not the app's to + * decide.** Express serializes a `Date` to ISO-8601 with a `Z`, but the value + * starts life as a MariaDB `DATETIME`, and a column read back as a string reaches + * the wire as `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read + * as UTC — that is what the server stores — rather than as local time, which + * would silently shift every timestamp by the device's offset. + * + * Anything unparseable returns null and the row simply shows no stamp: a + * notification with an odd date is still worth reading. + */ +fun inboxTimestamp( + raw: String, + zone: ZoneId = ZoneId.systemDefault(), + formatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT), +): String? { + val instant = parseInstant(raw) ?: return null + return try { + formatter.withZone(zone).format(instant) + } catch (_: Exception) { + null + } +} + +private fun parseInstant(raw: String): Instant? { + val text = raw.trim() + if (text.isEmpty()) return null + return try { + Instant.parse(text) + } catch (_: Exception) { + try { + // No zone on the wire → UTC, because that is what the server stored. + LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant() + } catch (_: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt new file mode 100644 index 0000000..8679694 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt @@ -0,0 +1,206 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.core.web.WebHandoff +import com.runicgateway.app.data.api.dto.NotificationItemDto +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.ShardCard + +/** + * The in-app inbox (ENGAGEMENT.md phase 8): what the engine's `inapp` channel + * wrote for this user, newest first. + * + * This is the drawer's "Notifications" — the settings that used to live there are + * one tap away behind the gear, mirroring exactly what phase 7 shipped on the web + * (the bare path is the inbox, `…/settings` is the preferences). It is what a + * tapped push tickle deep-links to, and the pull that follows the wake. + * + * **The list carries content, so it is deliberately plain text.** An item's body + * is the server's `toText` render, never the email HTML — that markup is table + * rows and inline hex with a light-only `color-scheme`, which in a themed app + * would be a pale card in a dark one. It also means there is no operator markup + * on this surface to sanitize. + */ +@Composable +fun InboxScreen( + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, + viewModel: InboxViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + Column(modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (state.unread > 0) { + stringResource(R.string.inbox_unread_count, state.unread) + } else { + stringResource(R.string.inbox_all_read) + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (state.unread > 0) { + TextButton(onClick = viewModel::markAllRead) { + Text(stringResource(R.string.inbox_mark_all_read)) + } + } + IconButton(onClick = onOpenSettings) { + Icon(Icons.Filled.Settings, stringResource(R.string.inbox_open_settings)) + } + } + + // Showing the snapshot rather than the server's answer is said out loud: a + // notification surface that quietly showed a stale list would be lying + // about the one thing it exists to be — current. + if (state.fromCache) { + Text( + text = stringResource(R.string.inbox_offline_cached), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + when (val items = state.items) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load) + is UiState.Success -> if (items.data.isEmpty()) { + EmptyView(stringResource(R.string.inbox_empty)) + } else { + InboxList( + items = items.data, + hasMore = state.hasMore && !state.fromCache, + onEndReached = viewModel::loadMore, + onOpen = { item -> + viewModel.markRead(item.id) + // Most items have no url at all — an inbox row is complete on + // its own — and the ones that do carry a SITE-RELATIVE path, + // so the view model resolves it against the configured shard + // before anything is opened. + viewModel.linkFor(item)?.let { WebHandoff.open(context, it) } + }, + ) + } + } + } +} + +@Composable +private fun InboxList( + items: List, + hasMore: Boolean, + onEndReached: () -> Unit, + onOpen: (NotificationItemDto) -> Unit, +) { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + items(items, key = { it.id }) { item -> InboxCard(item, onOpen) } + if (hasMore) { + item { + // Paging by "the last row came into view" rather than a button: the + // cursor is the last id on screen, so reaching the end IS the request. + LaunchedEffect(items.lastOrNull()?.id) { onEndReached() } + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.inbox_loading_more), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun InboxCard(item: NotificationItemDto, onOpen: (NotificationItemDto) -> Unit) { + ShardCard(Modifier.fillMaxWidth().clickable { onOpen(item) }) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (!item.read) { + // The unread mark is a dot beside the title AND a heavier weight + // on it: colour alone would carry the whole signal, which is not + // a distinction everyone can see. + Box( + Modifier + .padding(end = 8.dp) + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (item.read) FontWeight.Normal else FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + } + item.body?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp), + ) + } + val stamp = item.createdAt?.let { inboxTimestamp(it) } + if (stamp != null) { + Text( + text = stamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt new file mode 100644 index 0000000..7bee565 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt @@ -0,0 +1,241 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.inbox.InboxCache +import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.core.result.ApiResult +import com.runicgateway.app.core.result.map +import com.runicgateway.app.data.api.dto.NotificationItemDto +import com.runicgateway.app.data.repository.NotificationsRepository +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 in-app inbox (ENGAGEMENT.md phase 8): the items the engine's `inapp` + * channel wrote for this user, newest first, with the unread badge and the two + * mark-read writes. + * + * **The tickle contract is wake-and-pull, and this is the pull.** A push tickle + * carries `{ stream, ref }` and nothing else by design; `ref` is a HINT that an + * inbox row exists, never content, and `pushChannel.js` says so in as many words — + * the two rows are independent and either can be retried, so a client that + * rendered the ref would show nothing the first time a retry reordered them. So a + * tap deep-links here and this refreshes; the ref is not read. + * + * **Paging is keyset, not offset.** The next page is `before = the last id on + * screen`, because the list gains rows at the top while it is being read and an + * offset would show the same item twice or skip one. + */ +@HiltViewModel +class InboxViewModel @Inject constructor( + private val notifications: NotificationsRepository, + private val cache: InboxCache, + private val sessionManager: SessionManager, + private val baseUrlHolder: BaseUrlHolder, +) : ViewModel() { + + data class State( + val items: UiState> = UiState.Loading, + val unread: Int = 0, + val hasMore: Boolean = false, + val loadingMore: Boolean = false, + val refreshing: Boolean = false, + /** + * True while what is on screen came from [InboxCache] rather than the + * server. The screen says so — an inbox that quietly showed a stale list + * would be a notification surface that lies about being current. + */ + val fromCache: Boolean = false, + /** When that snapshot was captured; only meaningful with [fromCache]. */ + val cachedAt: Long? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + /** + * Show the cached page immediately, then refresh from the server. + * + * The cache is painted first rather than after a failure so a cold open on a + * slow connection shows the last known inbox instead of a spinner; a + * successful pull replaces it, and a network failure leaves it up with + * [State.fromCache] set. A *server* error is a different thing from being + * offline and is not papered over with stale rows — unless there is nothing + * else to show, in which case the error is still what the screen reports. + */ + fun load() = viewModelScope.launch { + val owner = ownerKey() + if (owner != null && _state.value.items !is UiState.Success) { + cache.read(owner)?.let { snapshot -> + _state.update { + it.copy( + items = UiState.Success(snapshot.items), + unread = snapshot.unread, + fromCache = true, + cachedAt = snapshot.savedAt, + ) + } + } + } + refresh() + } + + /** Pull the newest page. Keeps whatever is on screen until it succeeds. */ + fun refresh() = viewModelScope.launch { + _state.update { it.copy(refreshing = true) } + when (val result = notifications.inbox()) { + is ApiResult.Ok -> { + val page = result.data + _state.update { + it.copy( + items = UiState.Success(page.items), + unread = page.unread, + hasMore = page.hasMore, + refreshing = false, + fromCache = false, + cachedAt = null, + ) + } + ownerKey()?.let { cache.write(it, page.items, page.unread) } + } + else -> { + // Nothing cached to fall back on → the error IS the screen. Something + // cached → keep it up and label it, which is the whole point of §7's + // "the app degrades, it does not fail". + val holdCache = _state.value.items is UiState.Success && _state.value.fromCache + _state.update { + it.copy( + items = if (holdCache) it.items else result.map { page -> page.items }.toUiState(), + refreshing = false, + ) + } + } + } + } + + /** + * Append the next page. + * + * A no-op while one is in flight, when the server said there is no next page, + * or while the list is the cached snapshot — paging a cache we know to be one + * page long would ask the server for `before` an id it may no longer have. + */ + fun loadMore() = viewModelScope.launch { + val current = _state.value + val shown = (current.items as? UiState.Success)?.data ?: return@launch + if (current.loadingMore || !current.hasMore || current.fromCache) return@launch + val cursor = shown.lastOrNull()?.id ?: return@launch + + _state.update { it.copy(loadingMore = true) } + when (val result = notifications.inbox(before = cursor)) { + is ApiResult.Ok -> { + // Guard the same id arriving twice: a keyset window can shift under + // a concurrent write, and a duplicate id in a LazyColumn key crashes. + val seen = shown.mapTo(mutableSetOf()) { it.id } + val appended = result.data.items.filterNot { it.id in seen } + _state.update { + it.copy( + items = UiState.Success(shown + appended), + unread = result.data.unread, + hasMore = result.data.hasMore, + loadingMore = false, + ) + } + } + // A failed "more" leaves the pages already read alone — losing them + // because the fourth page timed out would be worse than stopping. + else -> _state.update { it.copy(loadingMore = false, hasMore = false) } + } + } + + /** + * Mark one item read, optimistically. + * + * The row flips locally before the call so the tap feels immediate, and the + * server's post-write `unread` replaces the local guess when it lands. A + * failure is not rolled back: read-ness is the least consequential thing in + * the app to get briefly wrong, and un-reading a row under the user's finger + * looks like a bug. The next refresh corrects it. + */ + fun markRead(id: Long) = viewModelScope.launch { + val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch + if (shown.firstOrNull { it.id == id }?.read != false) return@launch + + _state.update { current -> + current.copy( + items = UiState.Success(shown.map { if (it.id == id) it.copy(read = true) else it }), + unread = (current.unread - 1).coerceAtLeast(0), + ) + } + when (val result = notifications.markRead(id)) { + is ApiResult.Ok -> _state.update { it.copy(unread = result.data.unread) } + else -> Unit + } + cacheCurrent() + } + + /** Mark the whole inbox read. Same optimism, and the same reason for it. */ + fun markAllRead() = viewModelScope.launch { + val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch + _state.update { + it.copy(items = UiState.Success(shown.map { item -> item.copy(read = true) }), unread = 0) + } + notifications.markAllRead() + cacheCurrent() + } + + /** + * The absolute link for an item, or null when it has none this app can open. + * + * **An item's `url` is SITE-RELATIVE** — `/guilds/the-silver-anvil/forum/403` + * is what the server writes, because it is rendered from the template's button + * block for a browser that is already on the site. A phone is not, so it has to + * be resolved against the configured base or every link in the inbox is dead; + * the live rig is what caught that. + * + * `HttpUrl.resolve` does both jobs: it absolutises a relative path and it + * returns null for anything that would not end up as http(s) — a `javascript:` + * or `intent:` url in a notification body opens nothing at all. + */ + fun linkFor(item: NotificationItemDto): String? { + val raw = item.url?.trim().orEmpty() + if (raw.isEmpty()) return null + return baseUrlHolder.current?.resolve(raw)?.toString() + } + + /** + * Keep the snapshot in step with a local read. + * + * Without this, going offline right after reading everything would bring the + * badge back on the next cold open. Only ever written for the account that + * owns it — [InboxCache] scopes by (base URL, user id). + */ + private suspend fun cacheCurrent() { + val owner = ownerKey() ?: return + val current = _state.value + val shown = (current.items as? UiState.Success)?.data ?: return + cache.write(owner, shown, current.unread) + } + + private fun ownerKey(): String? { + val user = (sessionManager.state.value as? Session.SignedIn)?.user ?: return null + return InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt new file mode 100644 index 0000000..0a1faf8 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsScreen.kt @@ -0,0 +1,271 @@ +/* + * 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.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +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.FilterChip +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.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.NotificationChannelDto +import com.runicgateway.app.data.api.dto.NotificationChannelItemDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto +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 notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8): every + * subscribable id with a control per channel that applies to it. + * + * It used to be the drawer's "Notifications"; that entry is the inbox now and this + * is behind its gear, which is the arrangement phase 7 shipped on the web. What + * changed underneath is bigger than the move: the screen asks + * `/notifications/channels` and so can express email and on-site preferences, not + * just whether a stream pushes. + * + * **A channel with two modes gets a switch and one with three gets chips**, and + * which is which comes off the wire — `email` is the one that supports `digest` + * today, and a fourth channel with its own modes would render correctly here + * without an app release. + */ +@Composable +fun NotificationSettingsScreen( + modifier: Modifier = Modifier, + viewModel: NotificationSettingsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + // Ask once for POST_NOTIFICATIONS when the user first switches a push mode on + // (API 33+). Email and in-app need no permission — only push posts anything. + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* granted or not, the preference 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)) + + 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), + ) + } + + // A shard with no push relay still has email and on-site preferences worth + // setting, so this is a note beside the list now rather than the whole + // screen — which is what it had to be when push was all there was. + if (!state.supported) { + Text( + text = stringResource(R.string.notifications_unsupported), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontStyle = FontStyle.Italic, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + + when (val prefs = state.prefs) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(kind = prefs.kind, onRetry = viewModel::load) + is UiState.Success -> ChannelPrefsList( + prefs = prefs.data, + hasLinkedAccount = state.hasLinkedAccount, + pushSupported = state.supported, + busy = state.busy, + onSetMode = { item, channel, mode -> + if (channel == CHANNEL_PUSH && mode != MODE_OFF) ensureNotificationPermission() + viewModel.setMode(item, channel, mode) + }, + ) + } + } +} + +@Composable +private fun ChannelPrefsList( + prefs: NotificationChannelPrefsDto, + hasLinkedAccount: Boolean, + pushSupported: Boolean, + busy: Boolean, + onSetMode: (NotificationChannelItemDto, String, String) -> Unit, +) { + if (prefs.items.isEmpty()) { + EmptyView(message = stringResource(R.string.notifications_empty)) + return + } + val channelsById = prefs.channels.associateBy { it.id } + val (personal, general) = prefs.items.partition { it.personal } + + if (general.isNotEmpty()) { + SectionLabel(stringResource(R.string.notifications_section_general)) + Spacer(Modifier.height(8.dp)) + general.forEach { item -> + ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode) + HorizontalDivider() + } + Spacer(Modifier.height(20.dp)) + } + + if (personal.isNotEmpty()) { + SectionLabel(stringResource(R.string.notifications_section_personal)) + Spacer(Modifier.height(8.dp)) + personal.forEach { item -> + val selectable = itemSelectable(item, hasLinkedAccount) + ItemRow( + item = item, + channelsById = channelsById, + hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null, + enabled = !busy && selectable, + pushSupported = pushSupported, + onSetMode = onSetMode, + ) + HorizontalDivider() + } + } +} + +@Composable +private fun ItemRow( + item: NotificationChannelItemDto, + channelsById: Map, + hint: String?, + enabled: Boolean, + pushSupported: Boolean, + onSetMode: (NotificationChannelItemDto, String, String) -> Unit, +) { + Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Text( + text = item.label, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = hint ?: item.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal, + ) + // The item's OWN channel list, in the registry's order. An id nothing can + // push carries no push control at all, rather than a dead switch. + item.channels.forEach { channelId -> + val channel = channelsById[channelId] ?: return@forEach + if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach + ChannelControl( + channel = channel, + mode = item.modes[channelId] ?: channel.defaultMode, + enabled = enabled, + onSetMode = { mode -> onSetMode(item, channelId, mode) }, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChannelControl( + channel: NotificationChannelDto, + mode: String, + enabled: Boolean, + onSetMode: (String) -> Unit, +) { + val modes = channel.modes.ifEmpty { listOf(MODE_OFF) } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = channel.label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f).padding(end = 12.dp), + ) + // Two modes is a yes/no question and reads best as a switch; three is a + // choice and needs its options named — `digest` means nothing as an + // unlabelled third state. + if (modes.size == 2 && modes.contains(MODE_OFF)) { + val on = modes.first { it != MODE_OFF } + Switch( + checked = mode != MODE_OFF, + onCheckedChange = { checked -> onSetMode(if (checked) on else MODE_OFF) }, + enabled = enabled, + ) + } else { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + modes.forEach { candidate -> + FilterChip( + selected = candidate == mode, + onClick = { if (candidate != mode) onSetMode(candidate) }, + enabled = enabled, + label = { Text(modeLabel(candidate)) }, + ) + } + } + } + } +} + +/** + * Copy for a delivery mode. A mode this build has never heard of is labelled with + * its own wire name rather than hidden — the server accepts it, so a chip reading + * `weekly` is more use to the person in front of it than a control that vanished. + */ +@Composable +private fun modeLabel(mode: String): String = when (mode) { + MODE_OFF -> stringResource(R.string.notifications_mode_off) + "instant" -> stringResource(R.string.notifications_mode_instant) + "digest" -> stringResource(R.string.notifications_mode_digest) + else -> mode +} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt new file mode 100644 index 0000000..c522809 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt @@ -0,0 +1,152 @@ +/* + * 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.NotificationChannelItemDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto +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 + +/** The push channel's id — the one channel that also drives a device registration. */ +const val CHANNEL_PUSH = "push" + +/** The mode every channel accepts, and the one that means "do not deliver". */ +const val MODE_OFF = "off" + +/** + * Drives the notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8). + * + * **This screen moved off `/notifications/subscriptions` onto + * `/notifications/channels`.** The old endpoint asked one question — is push on + * for this stream — and there are now three channels to ask it of. The server + * keeps `notification_subscriptions` as the push projection of the new table and + * fans every write to either into the other, so the shipped APK's screen went on + * working the whole time and this one is not a migration anybody has to run. + * + * **The controls are rendered from the wire, never from a hardcoded three.** Each + * item names the channels that apply to it — a trigger-only id carries no `push` + * because nothing is registered to push it — and each channel names the modes it + * accepts, which is how `email`'s `digest` reaches the app without an app release. + * The modes the server sends are the EFFECTIVE ones (it has already substituted + * each channel's default), so this class never re-implements the defaulting. + */ +@HiltViewModel +class NotificationSettingsViewModel @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 prefs: UiState = UiState.Loading, + /** 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(prefs = UiState.Loading) } + viewModelScope.launch { + _state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) } + // 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) } + + /** + * Set one (item, channel) pair. + * + * One pair, one sparse PUT: the endpoint writes only what it is given, so a + * toggle cannot disturb a channel this screen is not showing — and the + * response is the full stored truth, which is what the screen re-renders + * from. An entry the server drops (an unknown id, an inapplicable channel) + * therefore shows up as the control springing back, not as a silent lie. + */ + fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) { + val current = _state.value + if (current.busy) return + if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return + + _state.update { it.copy(busy = true, feedback = null) } + viewModelScope.launch { + when (val result = notifications.setChannelMode(item.id, channel, mode)) { + is ApiResult.Ok -> { + _state.update { it.copy(prefs = UiState.Success(result.data)) } + if (channel == CHANNEL_PUSH) reconcilePush(result.data) else finish(true, R.string.notifications_saved) + } + 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 stored push set (PLAN.md §11). + * + * Read from the RESPONSE rather than from what was just sent, because the + * server may have dropped the entry — and because "is any push mode on" is a + * question about the whole table, not about the row that changed. + */ + private suspend fun reconcilePush(prefs: NotificationChannelPrefsDto) { + val anyPushOn = prefs.items.any { item -> + val mode = item.modes[CHANNEL_PUSH] + mode != null && mode != MODE_OFF + } + if (!anyPushOn) { + 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 an item's controls are 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 itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean = + !item.requiresLinkedAccount || hasLinkedAccount 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 deleted file mode 100644 index 0fe8ef4..0000000 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsScreen.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * 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 deleted file mode 100644 index 9d408bf..0000000 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationsViewModel.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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 6f04826..710cd54 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -484,7 +484,7 @@ Notifications Notifications - Choose what this shard notifies you about. Nothing is sent unless you turn it on. + Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on. General Your game account Link a game account to enable this. @@ -495,6 +495,18 @@ Couldn\'t save your notification settings. Try again. This shard\'s push relay isn\'t reachable right now. + + Off + As it happens + Daily summary + Nothing here yet. Notifications you\'re sent will show up here. + All caught up + %1$d unread + Mark all read + Notification settings + Loading more… + Offline — showing what was saved on this device. + Shard notifications Alerts you opted into from this shard. 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 index 783676b..efa5acd 100644 --- 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 @@ -82,4 +82,71 @@ class NotificationsDtoTest { ) assertNull(dto.push.ntfyUrl) } + + // ── The inbox + per-channel prefs (ENGAGEMENT.md phases 3, 7/8) ──────── + + @Test fun inboxPageDecodesWithItsUnreadCount() { + val dto = json.decodeFromString( + """{"items":[{"id":42,"triggerId":"team.post.created","title":"New post", + "body":"Someone posted in your team.","url":"https://shard.example/teams/1", + "read":false,"readAt":null,"createdAt":"2026-08-31T12:30:00.000Z"}], + "hasMore":true,"unread":3}""", + ) + assertEquals(1, dto.items.size) + assertEquals(42L, dto.items.first().id) + assertEquals("team.post.created", dto.items.first().triggerId) + assertFalse(dto.items.first().read) + assertTrue(dto.hasMore) + // The whole inbox, not the page — the badge and the list come from one response. + assertEquals(3, dto.unread) + } + + @Test fun anItemWithNoBodyOrUrlDecodes() { + // Most items have neither: an inbox row is complete on its own. + val dto = json.decodeFromString( + """{"id":7,"triggerId":"news.post","title":"Patch notes","body":null,"url":null, + "read":true,"readAt":"2026-08-31T13:00:00.000Z","createdAt":"2026-08-31T12:30:00.000Z"}""", + ) + assertNull(dto.body) + assertNull(dto.url) + assertTrue(dto.read) + } + + @Test fun channelPrefsDecodeTheirModesAndPerItemChannels() { + val dto = json.decodeFromString( + """{"channels":[ + {"id":"push","label":"Push","carriesContent":false,"defaultMode":"off", + "supportsDigest":false,"modes":["off","instant"]}, + {"id":"email","label":"Email","carriesContent":true,"defaultMode":"off", + "supportsDigest":true,"modes":["off","instant","digest"]}], + "items":[ + {"id":"uo.house.idoc_warning","label":"House in danger","description":"", + "personal":true,"requiresLinkedAccount":true,"ceiling":"authenticated", + "channels":["email","inapp"],"modes":{"email":"digest","inapp":"instant"}}]}""", + ) + assertFalse(dto.channels.first { it.id == "push" }.carriesContent) + assertTrue(dto.channels.first { it.id == "email" }.supportsDigest) + val item = dto.items.single() + // A trigger-only id carries no push facet at all — the UI renders controls + // from THIS list, never from a hardcoded three. + assertFalse(item.channels.contains("push")) + assertEquals("digest", item.modes["email"]) + assertNull(item.modes["push"]) + } + + @Test fun theSparseUpdateAlwaysCarriesItsPrefsField() { + // Same reasoning as the subscriptions DTO: kotlinx omits a property equal + // to its default, and the validator requires the field. + val body = json.encodeToString(NotificationChannelPrefsUpdateDto(emptyList())) + assertEquals("""{"prefs":[]}""", body) + } + + @Test fun theSparseUpdateSendsOnlyThePairItNames() { + val body = json.encodeToString( + NotificationChannelPrefsUpdateDto( + listOf(NotificationChannelPrefDto(id = "news.post", channel = "email", mode = "digest")), + ), + ) + assertEquals("""{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}""", body) + } } diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeNotificationsApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeNotificationsApi.kt new file mode 100644 index 0000000..f7ab3a5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeNotificationsApi.kt @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.data.api.fake + +import com.runicgateway.app.data.api.NotificationsApi +import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto +import com.runicgateway.app.data.api.dto.NotificationInboxDto +import com.runicgateway.app.data.api.dto.NotificationReadResultDto +import com.runicgateway.app.data.api.dto.NotificationStreamsDto +import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto +import com.runicgateway.app.data.api.dto.NotificationUnreadDto +import com.runicgateway.app.data.api.dto.PushDeviceDto +import com.runicgateway.app.data.api.dto.RegisterDeviceRequest + +/** + * A configurable fake of [NotificationsApi] for the inbox and settings ViewModel + * tests. Read endpoints return their `var`; [error] makes every call throw, which + * is how the offline and server-error branches are driven. + * + * [pages] keys the inbox by its cursor — `null` is the first page — so a test can + * describe a two-page inbox without a callback, and [lastPrefsUpdate] records the + * body of the sparse PUT so a test can assert that ONE pair was sent. + */ +class FakeNotificationsApi : NotificationsApi { + + var error: Throwable? = null + + var streams: NotificationStreamsDto = NotificationStreamsDto() + var subscriptions: NotificationSubscriptionsDto = NotificationSubscriptionsDto(emptyList()) + var channelPrefs: NotificationChannelPrefsDto = NotificationChannelPrefsDto() + var pages: Map = mapOf(null to NotificationInboxDto()) + var unread: NotificationUnreadDto = NotificationUnreadDto() + var readResult: NotificationReadResultDto = NotificationReadResultDto(ok = true) + + var lastPrefsUpdate: List? = null + var markedRead: MutableList = mutableListOf() + var markAllReadCalls: Int = 0 + var inboxCalls: MutableList = mutableListOf() + + private fun failIfSet() { error?.let { throw it } } + + override suspend fun registerDevice(body: RegisterDeviceRequest): PushDeviceDto { + failIfSet() + return PushDeviceDto(id = 1) + } + + override suspend fun listDevices(): List { + failIfSet() + return emptyList() + } + + override suspend fun deleteDevice(id: Long) = failIfSet() + + override suspend fun streams(): NotificationStreamsDto { + failIfSet() + return streams + } + + override suspend fun subscriptions(): NotificationSubscriptionsDto { + failIfSet() + return subscriptions + } + + override suspend fun putSubscriptions(body: NotificationSubscriptionsDto): NotificationSubscriptionsDto { + failIfSet() + subscriptions = body + return body + } + + override suspend fun channelPrefs(): NotificationChannelPrefsDto { + failIfSet() + return channelPrefs + } + + override suspend fun putChannelPrefs(body: NotificationChannelPrefsUpdateDto): NotificationChannelPrefsDto { + failIfSet() + lastPrefsUpdate = body.prefs + return channelPrefs + } + + override suspend fun inbox(limit: Int?, before: Long?, unread: Boolean?): NotificationInboxDto { + failIfSet() + inboxCalls.add(before) + return pages[before] ?: NotificationInboxDto() + } + + override suspend fun unreadCount(): NotificationUnreadDto { + failIfSet() + return unread + } + + override suspend fun markRead(id: Long): NotificationReadResultDto { + failIfSet() + markedRead.add(id) + return readResult + } + + override suspend fun markAllRead(): NotificationReadResultDto { + failIfSet() + markAllReadCalls++ + return readResult + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxFormattingTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxFormattingTest.kt new file mode 100644 index 0000000..26bb60c --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxFormattingTest.kt @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * The inbox timestamp (ENGAGEMENT.md phase 8). Both wire shapes have to be read, + * and the zoneless one has to be read as UTC — reading it as local time would + * shift every stamp by the device's offset and nobody would notice until they + * travelled. + */ +class InboxFormattingTest { + + private val zone = ZoneId.of("America/New_York") + private val format: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US) + + @Test fun readsAnIsoStampWithAZone() { + // 12:30 UTC is 08:30 in New York on that date (EDT). + assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00.000Z", zone, format)) + } + + @Test fun readsAZonelessStampAsUtc() { + assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31 12:30:00", zone, format)) + } + + @Test fun readsAZonelessStampWithATSeparator() { + assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00", zone, format)) + } + + @Test fun anUnparseableStampShowsNothingRatherThanFailing() { + assertNull(inboxTimestamp("sometime last week", zone, format)) + assertNull(inboxTimestamp("", zone, format)) + assertNull(inboxTimestamp(" ", zone, format)) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt new file mode 100644 index 0000000..4353c12 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt @@ -0,0 +1,196 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import com.runicgateway.app.core.auth.SessionManager +import com.runicgateway.app.core.auth.StoredSession +import com.runicgateway.app.core.auth.TokenStore +import com.runicgateway.app.core.inbox.InboxCache +import com.runicgateway.app.core.net.BaseUrlHolder +import com.runicgateway.app.data.api.dto.NotificationInboxDto +import com.runicgateway.app.data.api.dto.NotificationItemDto +import com.runicgateway.app.data.api.dto.NotificationReadResultDto +import com.runicgateway.app.data.api.fake.FakeNotificationsApi +import com.runicgateway.app.data.repository.NotificationsRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.util.FakeInboxCache +import com.runicgateway.app.util.MainDispatcherRule +import com.runicgateway.app.util.httpError +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.io.IOException + +/** + * The inbox view model (ENGAGEMENT.md phase 8): the pull behind the tickle, the + * keyset paging, the optimistic reads, and the offline snapshot — including the + * one property that makes caching a person's notifications safe at all, that a + * snapshot never crosses an account. + */ +class InboxViewModelTest { + + @get:Rule val mainDispatcher = MainDispatcherRule() + + private class FakeTokenStore(private var stored: StoredSession?) : TokenStore { + override fun load(): StoredSession? = stored + override fun save(session: StoredSession) { stored = session } + override fun clear() { stored = null } + } + + private val api = FakeNotificationsApi() + private val cache = FakeInboxCache() + private val baseUrl = BaseUrlHolder().apply { set("https://shard.example/".toHttpUrl()) } + + private fun session(userId: Long = 7) = + SessionManager(FakeTokenStore(StoredSession("access", "refresh", userId, "alice", "player"))) + + private fun viewModel(sessionManager: SessionManager = session()) = + InboxViewModel(NotificationsRepository(api), cache, sessionManager, baseUrl) + + private fun item(id: Long, read: Boolean = false) = + NotificationItemDto(id = id, triggerId = "team.post.created", title = "Post $id", read = read) + + private fun shown(vm: InboxViewModel) = (vm.state.value.items as? UiState.Success)?.data + + @Test fun loadsTheFirstPageAndCachesIt() { + api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2)) + val vm = viewModel() + + assertEquals(listOf(2L, 1L), shown(vm)?.map { it.id }) + assertEquals(2, vm.state.value.unread) + assertFalse(vm.state.value.fromCache) + assertEquals(1, cache.writes) + } + + @Test fun offlineFallsBackToTheSnapshotAndSaysSo() { + runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) } + api.error = IOException("offline") + val vm = viewModel() + + assertEquals(listOf(9L), shown(vm)?.map { it.id }) + assertEquals(1, vm.state.value.unread) + assertTrue(vm.state.value.fromCache) + } + + @Test fun aSnapshotIsNeverShownToAnotherAccount() { + // The property the whole cache design rests on: user 7's items must not + // appear under user 8's session, on a device both have signed into. + runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) } + api.error = IOException("offline") + val vm = viewModel(session(userId = 8)) + + assertNull(shown(vm)) + assertTrue(vm.state.value.items is UiState.Error) + assertEquals(0, vm.state.value.unread) + } + + @Test fun aServerErrorWithNothingCachedIsTheScreen() { + api.error = httpError(500) + val vm = viewModel() + + assertTrue(vm.state.value.items is UiState.Error) + assertFalse(vm.state.value.fromCache) + } + + @Test fun loadMorePagesOnTheLastIdNotAnOffset() { + api.pages = mapOf( + null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true, unread = 2), + 8L to NotificationInboxDto(items = listOf(item(7)), hasMore = false, unread = 2), + ) + val vm = viewModel() + vm.loadMore() + + assertEquals(listOf(null, 8L), api.inboxCalls) + assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id }) + assertFalse(vm.state.value.hasMore) + } + + @Test fun loadMoreDropsAnIdAlreadyOnScreen() { + // A keyset window can shift under a concurrent write; a duplicate id in a + // LazyColumn key is a crash, not a cosmetic problem. + api.pages = mapOf( + null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true), + 8L to NotificationInboxDto(items = listOf(item(8), item(7))), + ) + val vm = viewModel() + vm.loadMore() + + assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id }) + } + + @Test fun loadMoreDoesNothingWhileShowingTheCache() { + runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) } + api.error = IOException("offline") + val vm = viewModel() + api.inboxCalls.clear() + vm.loadMore() + + assertTrue(api.inboxCalls.isEmpty()) + } + + @Test fun markReadFlipsTheRowAndTakesTheServersCount() { + api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2)) + api.readResult = NotificationReadResultDto(ok = true, unread = 1) + val vm = viewModel() + vm.markRead(2) + + assertEquals(listOf(2L), api.markedRead) + assertTrue(shown(vm)!!.first { it.id == 2L }.read) + assertEquals(1, vm.state.value.unread) + } + + @Test fun markReadIsNotSentTwiceForAnItemAlreadyRead() { + api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2, read = true)), unread = 0)) + val vm = viewModel() + vm.markRead(2) + + assertTrue(api.markedRead.isEmpty()) + } + + @Test fun markAllReadEmptiesTheBadgeAndUpdatesTheSnapshot() { + api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2)) + val vm = viewModel() + val writesAfterLoad = cache.writes + vm.markAllRead() + + assertEquals(1, api.markAllReadCalls) + assertEquals(0, vm.state.value.unread) + assertTrue(shown(vm)!!.all { it.read }) + // Without this write, going offline right after reading everything would + // bring the badge back on the next cold open. + assertEquals(writesAfterLoad + 1, cache.writes) + } + + // ── The item link (found by the live rig, not by a test) ────────────── + + @Test fun aSiteRelativeUrlIsResolvedAgainstTheShard() { + // What the server actually writes: the template's button block renders a + // path, because on the web the reader is already on the site. + val vm = viewModel() + val item = item(1).copy(url = "/guilds/the-silver-anvil/forum/403") + assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(item)) + } + + @Test fun anAbsoluteUrlIsLeftAlone() { + val vm = viewModel() + assertEquals("https://elsewhere.example/x", vm.linkFor(item(1).copy(url = "https://elsewhere.example/x"))) + } + + @Test fun anItemWithNoUrlHasNoLink() { + val vm = viewModel() + assertNull(vm.linkFor(item(1))) + assertNull(vm.linkFor(item(1).copy(url = " "))) + } + + @Test fun aUrlThatCouldNotBeOpenedSafelyResolvesToNothing() { + val vm = viewModel() + assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)"))) + assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end"))) + } +} 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 index b993a61..269425a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationRoutingTest.kt @@ -4,7 +4,7 @@ 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.data.api.dto.NotificationChannelItemDto import com.runicgateway.app.ui.navigation.Routes import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -12,8 +12,9 @@ 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). + * Tests the pure notification helpers: the stream → deep-link route map (PLAN.md + * §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it, + * and the personal-item gating (a personal id needs a linked game account). */ class NotificationRoutingTest { @@ -32,14 +33,41 @@ class NotificationRoutingTest { 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 personalItemNeedsLinkedAccount() { + val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true) + assertFalse(itemSelectable(personal, hasLinkedAccount = false)) + assertTrue(itemSelectable(personal, hasLinkedAccount = true)) } - @Test fun generalStreamIsAlwaysSelectable() { - val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false) - assertTrue(streamSelectable(general, hasLinkedAccount = false)) + @Test fun generalItemIsAlwaysSelectable() { + val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false) + assertTrue(itemSelectable(general, hasLinkedAccount = false)) + } + + // ── The tickle → destination map (ENGAGEMENT.md phase 8) ─────────────── + + @Test fun inboxRefLandsOnTheInboxWhateverTheStream() { + // The engine's stream id is a TRIGGER id in the one namespace, so most of + // them are strangers to `forStream` — and every one of those would have + // dropped the user on Home if the ref were not read. + assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("team.post.created", "notification:42")) + assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("uo.house.idoc_warning", "notification:7")) + } + + @Test fun anInboxRefWinsOverAStreamThatHasItsOwnScreen() { + assertEquals(Routes.NOTIFICATIONS, Routes.forTickle(PushStreams.NEWS_POST, "notification:1")) + } + + @Test fun everyOtherTickleKeepsTheRouteItAlwaysHad() { + assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, null)) + assertEquals(Routes.SHARD, Routes.forTickle(PushStreams.CHAMP_START, "0x40001234")) + assertEquals(Routes.PLAYER_HOUSES, Routes.forTickle(PushStreams.HOUSE_IDOC, "britain-2026-08-31")) + assertEquals(Routes.HOME, Routes.forTickle("something.new", null)) + } + + @Test fun aRefThatMerelyMentionsNotificationIsNotAnInboxRef() { + // Prefix, not `contains`: a ref is opaque and another producer's could + // easily carry the word without being a row id. + assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, "post-notification:3")) } } diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt new file mode 100644 index 0000000..b0dcd20 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModelTest.kt @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.notifications + +import com.runicgateway.app.data.api.dto.NotificationChannelDto +import com.runicgateway.app.data.api.dto.NotificationChannelItemDto +import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto +import com.runicgateway.app.data.api.fake.FakeNotificationsApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The per-channel preferences the settings screen renders (ENGAGEMENT.md phases 3 + * and 8). These are the wire-shape properties the UI is built ON rather than + * around, so they are asserted here rather than trusted: the controls come from + * each item's own `channels`, and the modes from each channel's own `modes`. + * + * The view model itself needs a [com.runicgateway.app.core.push.PushManager], + * which owns a foreground service and a `Context`; the sparse-PUT shape it sends + * is asserted through the repository instead, which is the part that could be + * wrong on the wire. + */ +class NotificationSettingsViewModelTest { + + private val push = NotificationChannelDto( + id = "push", label = "Push", carriesContent = false, + defaultMode = "off", supportsDigest = false, modes = listOf("off", "instant"), + ) + private val email = NotificationChannelDto( + id = "email", label = "Email", carriesContent = true, + defaultMode = "off", supportsDigest = true, modes = listOf("off", "instant", "digest"), + ) + + private fun prefs() = NotificationChannelPrefsDto( + channels = listOf(push, email), + items = listOf( + NotificationChannelItemDto( + id = "news.post", label = "News posts", + channels = listOf("push", "email"), + modes = mapOf("push" to "instant", "email" to "off"), + ), + NotificationChannelItemDto( + id = "uo.house.idoc_warning", label = "House in danger", + personal = true, requiresLinkedAccount = true, + // A trigger-only id: nothing is registered to push it, so it carries + // no push key at all — the screen must render no push control rather + // than a dead switch. + channels = listOf("email"), + modes = mapOf("email" to "digest"), + ), + ), + ) + + @Test fun aTriggerOnlyIdOffersNoPushControl() { + val item = prefs().items.first { it.id == "uo.house.idoc_warning" } + assertEquals(listOf("email"), item.channels) + assertNull(item.modes[CHANNEL_PUSH]) + } + + @Test fun emailIsTheChannelThatCarriesDigest() { + assertEquals(listOf("off", "instant", "digest"), email.modes) + assertEquals(listOf("off", "instant"), push.modes) + } + + @Test fun personalItemsStillNeedALinkedAccount() { + val personal = prefs().items.first { it.personal } + assertEquals(false, itemSelectable(personal, hasLinkedAccount = false)) + assertEquals(true, itemSelectable(personal, hasLinkedAccount = true)) + } + + @Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking { + // The sparse PUT is the whole reason this screen can save a single control + // without holding the table: anything more in the body could clobber a + // channel it is not showing. + val api = FakeNotificationsApi() + api.channelPrefs = prefs() + val repo = com.runicgateway.app.data.repository.NotificationsRepository(api) + + repo.setChannelMode("news.post", "email", "digest") + + assertEquals(1, api.lastPrefsUpdate?.size) + assertEquals("news.post", api.lastPrefsUpdate?.first()?.id) + assertEquals("email", api.lastPrefsUpdate?.first()?.channel) + assertEquals("digest", api.lastPrefsUpdate?.first()?.mode) + } +} diff --git a/app/src/test/java/com/runicgateway/app/util/FakeInboxCache.kt b/app/src/test/java/com/runicgateway/app/util/FakeInboxCache.kt new file mode 100644 index 0000000..b04c25a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/util/FakeInboxCache.kt @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.util + +import com.runicgateway.app.core.inbox.InboxCache +import com.runicgateway.app.data.api.dto.NotificationItemDto + +/** + * In-memory [InboxCache] for the inbox view-model tests — the same shape of stand-in + * `SessionManagerTest` uses for the encrypted token store. + * + * It keeps the real implementation's ONE load-bearing rule: a snapshot is handed + * back only to the owner that wrote it. A fake that ignored the key would let the + * cross-account test pass against a cache that leaks. + */ +class FakeInboxCache : InboxCache { + + private var owner: String? = null + private var snapshot: InboxCache.Snapshot? = null + + var writes: Int = 0 + var cleared: Int = 0 + + override suspend fun read(owner: String): InboxCache.Snapshot? = + if (this.owner == owner) snapshot else null + + override suspend fun write(owner: String, items: List, unread: Int) { + writes++ + this.owner = owner + snapshot = InboxCache.Snapshot( + items = items.take(InboxCache.MAX_ITEMS), + unread = unread, + savedAt = 1_700_000_000_000, + ) + } + + override suspend fun clear() { + cleared++ + owner = null + snapshot = null + } + + /** Seed a snapshot as if a previous session had pulled one. */ + suspend fun seed(owner: String, items: List, unread: Int) { + write(owner, items, unread) + writes = 0 + } +}