feat(notifications): the in-app inbox, and per-channel preferences (engagement Phase 8)

The app's half of the in-app channel. Phase 7 shipped four inbox routes with no
consumer on either platform; this is the Android one, plus the per-channel
preferences Phase 3 added and the shipped screen could not express.

The drawer's "Notifications" is the INBOX now, with the preferences one tap away
behind its gear — the arrangement Phase 7 shipped on the web, and what a person
means when they tap the word. The settings screen moved off
/notifications/subscriptions onto /notifications/channels: it renders a control
per channel that applies to each id (from the item's own `channels`, never a
hardcoded three) and per mode that channel accepts, which is how email's
`digest` reaches the app. The old endpoint is the push projection of the new
table server-side, so the shipped APK went on working the whole time.

A tapped tickle whose `ref` starts with `notification:` lands on the inbox
whatever its stream is — an engagement rule's stream id is a TRIGGER id in the
one namespace, and `forStream`'s fixed map would have sent most of them Home.
Every other tickle keeps the route it has always had. The ref is not decoded
beyond that prefix and never rendered: it is a hint that a row exists, and the
contract stays wake-and-pull.

PLAN.md §7's "no Room cache in v1" stands; the offline snapshot is its one named
exception, settled with the org lead. The inbox is a short, read-only,
newest-first list with a server-side cursor, 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. Every snapshot is scoped to (base URL, user id) and only
handed back to that pair: that, not the clear-on-logout, is what stops a cache
surviving into another account on the paths that never reach a logout at all.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-31 08:59:55 -05:00
parent 21e235a07f
commit d393cf022e
26 changed files with 2034 additions and 355 deletions

View File

@@ -58,9 +58,16 @@ class MainActivity : ComponentActivity() {
// consumed once by RunicApp which navigates to the stream's screen.
private var pendingStream by mutableStateOf<String?>(null)
// The tickle's other half: an opaque ref, carried since M7 and read since
// ENGAGEMENT.md phase 8, where a `notification:<id>` 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<String?>(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)
}

View File

@@ -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<Preferences> 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<NotificationItemDto>, 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<NotificationItemDto>,
val unread: Int,
val savedAt: Long,
)
private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("snapshot")
}
}

View File

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

View File

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

View File

@@ -72,3 +72,139 @@ data class NotificationStreamsDto(
data class NotificationSubscriptionsDto(
val streams: List<String>,
)
// ── 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=<the last item's id>`, 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<NotificationItemDto> = 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<String> = 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<String> = emptyList(),
val modes: Map<String, String> = emptyMap(),
)
/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */
@Serializable
data class NotificationChannelPrefsDto(
val channels: List<NotificationChannelDto> = emptyList(),
val items: List<NotificationChannelItemDto> = 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<NotificationChannelPrefDto>,
)

View File

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

View File

@@ -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<String>): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
// ── Per-channel preferences (phase 3) ──────────────────────────────────
suspend fun channelPrefs(): ApiResult<NotificationChannelPrefsDto> =
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<NotificationChannelPrefsDto> =
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<NotificationInboxDto> =
safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) }
suspend fun unreadCount(): ApiResult<NotificationUnreadDto> = safeApiCall { api.unreadCount() }
suspend fun markRead(id: Long): ApiResult<NotificationReadResultDto> = safeApiCall { api.markRead(id) }
suspend fun markAllRead(): ApiResult<NotificationReadResultDto> = safeApiCall { api.markAllRead() }
}

View File

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

View File

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

View File

@@ -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:<id>` 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:"
}

View File

@@ -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<Int> = _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 }
}
}
}
}

View File

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

View File

@@ -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)
// The url is the site's own page for the item, and most items
// have none — an inbox row is complete on its own. Anything
// that is not http(s) is not opened at all.
item.url?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
?.let { WebHandoff.open(context, it) }
},
)
}
}
}
}
@Composable
private fun InboxList(
items: List<NotificationItemDto>,
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),
)
}
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* 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<List<NotificationItemDto>> = 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> = _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()
}
/**
* 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)
}
}

View File

@@ -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<String, NotificationChannelDto>,
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
}

View File

@@ -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<NotificationChannelPrefsDto> = 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> = _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

View File

@@ -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<NotificationStreamDto>,
subscribed: Set<String>,
hasLinkedAccount: Boolean,
busy: Boolean,
onToggle: (NotificationStreamDto, Boolean) -> Unit,
) {
if (streams.isEmpty()) {
EmptyView(message = stringResource(R.string.notifications_empty))
return
}
val (personal, general) = streams.partition { it.personal }
if (general.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_general))
Spacer(Modifier.height(8.dp))
general.forEach { stream ->
StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
onToggle(stream, on)
}
HorizontalDivider()
}
Spacer(Modifier.height(20.dp))
}
if (personal.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_personal))
Spacer(Modifier.height(8.dp))
personal.forEach { stream ->
val selectable = streamSelectable(stream, hasLinkedAccount)
val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
onToggle(stream, on)
}
HorizontalDivider()
}
}
}
@Composable
private fun StreamRow(
stream: NotificationStreamDto,
checked: Boolean,
enabled: Boolean,
hint: String?,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = stream.label,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = hint ?: stream.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
)
}
Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
}
}

View File

@@ -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<List<NotificationStreamDto>> = UiState.Loading,
val subscribed: Set<String> = emptySet(),
/** Whether the user has ≥1 linked game account — personal streams need it. */
val hasLinkedAccount: Boolean = false,
/** Whether this shard advertises a push relay at all (else the screen says so). */
val supported: Boolean = true,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
}
load()
}
fun load() {
_state.update { it.copy(catalog = UiState.Loading) }
viewModelScope.launch {
val catalog = notifications.streams().let { result ->
when (result) {
is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
is ApiResult.HttpError -> result
is ApiResult.NetworkError -> result
}
}
_state.update { it.copy(catalog = catalog.toUiState()) }
when (val subs = notifications.subscriptions()) {
is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
else -> Unit
}
// A linked game account gates the personal streams; failure → treat as none.
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
_state.update { it.copy(hasLinkedAccount = linked) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/** Toggle [stream]; refuses a personal stream with no linked account. */
fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
val s = _state.value
if (s.busy) return
if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = notifications.setSubscriptions(next.toList())) {
is ApiResult.Ok -> {
val stored = result.data.streams.toSet()
_state.update { it.copy(subscribed = stored) }
reconcilePush(stored)
}
is ApiResult.NetworkError -> finish(false, R.string.error_network)
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
}
}
}
/**
* Register or unregister the device to match the opted-in set (PLAN.md §11:
* register when signed-in + subscribed, unregister when the set empties).
*/
private suspend fun reconcilePush(subscribed: Set<String>) {
if (subscribed.isEmpty()) {
pushManager.disable()
finish(true, R.string.notifications_all_off)
return
}
when (val res = pushManager.enable()) {
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
is PushManager.PushResult.Failed ->
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
}
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}
/**
* Whether a stream's toggle is selectable for a user: a personal stream needs a
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
*/
fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
!stream.requiresLinkedAccount || hasLinkedAccount

View File

@@ -484,7 +484,7 @@
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
<string name="menu_notifications">Notifications</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_subtitle">Choose what this shard notifies you about. Nothing is sent unless you turn it on.</string>
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
<string name="notifications_section_general">General</string>
<string name="notifications_section_personal">Your game account</string>
<string name="notifications_requires_link">Link a game account to enable this.</string>
@@ -495,6 +495,18 @@
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
<!-- The in-app inbox and the per-channel settings (ENGAGEMENT.md phase 8). -->
<string name="notifications_mode_off">Off</string>
<string name="notifications_mode_instant">As it happens</string>
<string name="notifications_mode_digest">Daily summary</string>
<string name="inbox_empty">Nothing here yet. Notifications you\'re sent will show up here.</string>
<string name="inbox_all_read">All caught up</string>
<string name="inbox_unread_count">%1$d unread</string>
<string name="inbox_mark_all_read">Mark all read</string>
<string name="inbox_open_settings">Notification settings</string>
<string name="inbox_loading_more">Loading more…</string>
<string name="inbox_offline_cached">Offline — showing what was saved on this device.</string>
<!-- Notification channels + the ongoing foreground-service notification. -->
<string name="push_channel_messages">Shard notifications</string>
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>

View File

@@ -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<NotificationInboxDto>(
"""{"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<NotificationItemDto>(
"""{"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<NotificationChannelPrefsDto>(
"""{"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)
}
}

View File

@@ -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<Long?, NotificationInboxDto> = mapOf(null to NotificationInboxDto())
var unread: NotificationUnreadDto = NotificationUnreadDto()
var readResult: NotificationReadResultDto = NotificationReadResultDto(ok = true)
var lastPrefsUpdate: List<NotificationChannelPrefDto>? = null
var markedRead: MutableList<Long> = mutableListOf()
var markAllReadCalls: Int = 0
var inboxCalls: MutableList<Long?> = 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<PushDeviceDto> {
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
}
}

View File

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

View File

@@ -0,0 +1,169 @@
/*
* 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)
}
}

View File

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

View File

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

View File

@@ -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<NotificationItemDto>, 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<NotificationItemDto>, unread: Int) {
write(owner, items, unread)
writes = 0
}
}