feat(notifications): the in-app inbox — cutover 6 of 7 (edge → main)
#43
@@ -1,5 +1,5 @@
|
|||||||
# Gate every pull request into `main` on lint + unit tests + a debug build, so a
|
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
|
||||||
# broken build can't reach the deployable branch. Debug builds are auto-signed,
|
# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed,
|
||||||
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
||||||
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
||||||
#
|
#
|
||||||
@@ -18,9 +18,14 @@
|
|||||||
|
|
||||||
name: PR Checks
|
name: PR Checks
|
||||||
|
|
||||||
|
# `edge` is here because a workstream that lands ten phase PRs onto it before one
|
||||||
|
# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is
|
||||||
|
# exactly what happened to all nine M12 phase PRs, and would have happened again
|
||||||
|
# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own
|
||||||
|
# PR, not inside the cutover window with a whole workstream's diff to bisect.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, edge]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-checks-${{ github.ref }}
|
group: pr-checks-${{ github.ref }}
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
|
||||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
|
||||||
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
|
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
|
||||||
attached to a Gitea release comes at M6.
|
the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6.
|
||||||
|
|
||||||
|
**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch
|
||||||
|
before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what
|
||||||
|
happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is
|
||||||
|
unaffected: it is a push-on-`main` analysis, not a PR gate.
|
||||||
|
|
||||||
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
||||||
because this self-hosted runner differs from a stock GitHub runner:
|
because this self-hosted runner differs from a stock GitHub runner:
|
||||||
|
|||||||
@@ -58,9 +58,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
// consumed once by RunicApp which navigates to the stream's screen.
|
// consumed once by RunicApp which navigates to the stream's screen.
|
||||||
private var pendingStream by mutableStateOf<String?>(null)
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||||
|
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
|
||||||
handleSsoCallback(intent)
|
handleSsoCallback(intent)
|
||||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||||
@@ -99,7 +106,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
appearance = s.appearance,
|
appearance = s.appearance,
|
||||||
onChangeServer = appViewModel::changeServer,
|
onChangeServer = appViewModel::changeServer,
|
||||||
deepLinkStream = pendingStream,
|
deepLinkStream = pendingStream,
|
||||||
onDeepLinkConsumed = { pendingStream = null },
|
deepLinkRef = pendingRef,
|
||||||
|
onDeepLinkConsumed = {
|
||||||
|
pendingStream = null
|
||||||
|
pendingRef = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +127,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(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)
|
handleSsoCallback(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.data.api
|
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.NotificationStreamsDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
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.PushDeviceDto
|
||||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -13,10 +18,13 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
|
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
|
||||||
* (endpoint) registration and per-user stream subscriptions. Every call rides the
|
* 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
|
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
||||||
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
||||||
* registration only ever succeeds while signed in.
|
* registration only ever succeeds while signed in.
|
||||||
@@ -40,4 +48,41 @@ interface NotificationsApi {
|
|||||||
|
|
||||||
@PUT("api/v1/auth/me/notifications/subscriptions")
|
@PUT("api/v1/auth/me/notifications/subscriptions")
|
||||||
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,3 +72,139 @@ data class NotificationStreamsDto(
|
|||||||
data class NotificationSubscriptionsDto(
|
data class NotificationSubscriptionsDto(
|
||||||
val streams: List<String>,
|
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>,
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository
|
|||||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
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.core.push.PushManager
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
@@ -35,6 +36,7 @@ class AuthRepository @Inject constructor(
|
|||||||
private val ssoApi: SsoApi,
|
private val ssoApi: SsoApi,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
|
private val inboxCache: InboxCache,
|
||||||
private val trustTokenStore: TrustTokenStore,
|
private val trustTokenStore: TrustTokenStore,
|
||||||
private val deviceNameProvider: DeviceNameProvider,
|
private val deviceNameProvider: DeviceNameProvider,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
@@ -183,6 +185,18 @@ class AuthRepository @Inject constructor(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Ignore — local session teardown proceeds regardless.
|
// 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()
|
val refreshToken = sessionManager.currentRefreshToken()
|
||||||
try {
|
try {
|
||||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ package com.runicgateway.app.data.repository
|
|||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
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.NotificationStreamsDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
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.PushDeviceDto
|
||||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device registration + per-user stream subscriptions over the opt-in push surface
|
* Device registration, stream subscriptions, per-channel preferences and the
|
||||||
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
|
* 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`
|
* 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).
|
* (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> =
|
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
|
||||||
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
|
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() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ import com.runicgateway.app.core.auth.TokenStore
|
|||||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
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.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
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
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class StorageModule {
|
abstract class StorageModule {
|
||||||
@@ -38,4 +41,9 @@ abstract class StorageModule {
|
|||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.AdminDashboardScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
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.page.PageScreen
|
||||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||||
import com.runicgateway.app.ui.player.CharactersScreen
|
import com.runicgateway.app.ui.player.CharactersScreen
|
||||||
@@ -124,8 +128,10 @@ fun RunicApp(
|
|||||||
onChangeServer: () -> Unit,
|
onChangeServer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
deepLinkStream: String? = null,
|
deepLinkStream: String? = null,
|
||||||
|
deepLinkRef: String? = null,
|
||||||
onDeepLinkConsumed: () -> Unit = {},
|
onDeepLinkConsumed: () -> Unit = {},
|
||||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
|
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val brand = appearance.brand
|
val brand = appearance.brand
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@@ -136,16 +142,26 @@ fun RunicApp(
|
|||||||
// What this shard publishes, independently of who the caller is (§5, M11).
|
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||||
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
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) {
|
LifecycleResumeEffect(Unit) {
|
||||||
sessionViewModel.revalidate()
|
sessionViewModel.revalidate()
|
||||||
|
inboxBadgeViewModel.refresh()
|
||||||
onPauseOrDispose { }
|
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).
|
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
||||||
LaunchedEffect(deepLinkStream) {
|
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
||||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
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 }
|
popUpTo(Routes.HOME) { saveState = true }
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
@@ -234,12 +250,16 @@ fun RunicApp(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
node.items.forEach { child ->
|
node.items.forEach { child ->
|
||||||
NavRow(child, currentRoute, drawerItemColors, indented = true) {
|
NavRow(
|
||||||
openNode(child)
|
node = child,
|
||||||
}
|
currentRoute = currentRoute,
|
||||||
|
colors = drawerItemColors,
|
||||||
|
indented = true,
|
||||||
|
unread = unread,
|
||||||
|
) { openNode(child) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
|
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +374,7 @@ private fun NavRow(
|
|||||||
currentRoute: String?,
|
currentRoute: String?,
|
||||||
colors: NavigationDrawerItemColors,
|
colors: NavigationDrawerItemColors,
|
||||||
indented: Boolean = false,
|
indented: Boolean = false,
|
||||||
|
unread: Int = 0,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val route = when (node) {
|
val route = when (node) {
|
||||||
@@ -369,21 +390,38 @@ private fun NavRow(
|
|||||||
is NavNode.Section -> return
|
is NavNode.Section -> return
|
||||||
}
|
}
|
||||||
val handsOff = node is NavNode.Link && node.route == null
|
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(
|
NavigationDrawerItem(
|
||||||
label = { Text(label) },
|
label = { Text(label) },
|
||||||
selected = route != null && currentRoute == route.substringBefore('?'),
|
selected = route != null && currentRoute == route.substringBefore('?'),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
badge = if (!handsOff) {
|
badge = when {
|
||||||
null
|
handsOff -> {
|
||||||
} else {
|
{
|
||||||
{
|
Icon(
|
||||||
Icon(
|
Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
Icons.AutoMirrored.Filled.ExitToApp,
|
contentDescription = stringResource(R.string.nav_opens_in_browser),
|
||||||
contentDescription = stringResource(R.string.nav_opens_in_browser),
|
modifier = Modifier.size(18.dp),
|
||||||
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,
|
colors = colors,
|
||||||
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
|
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
|
||||||
@@ -541,9 +579,20 @@ private fun RunicNavHost(
|
|||||||
}
|
}
|
||||||
composable(Routes.NOTIFICATIONS) {
|
composable(Routes.NOTIFICATIONS) {
|
||||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
// 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) {
|
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) }
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ object Routes {
|
|||||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
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 = "notifications"
|
||||||
|
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
|
||||||
|
|
||||||
/** Public shard hub (§6.2). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
@@ -130,4 +137,23 @@ object Routes {
|
|||||||
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
||||||
else -> HOME
|
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:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.web.WebHandoff
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-app inbox (ENGAGEMENT.md phase 8): what the engine's `inapp` channel
|
||||||
|
* wrote for this user, newest first.
|
||||||
|
*
|
||||||
|
* This is the drawer's "Notifications" — the settings that used to live there are
|
||||||
|
* one tap away behind the gear, mirroring exactly what phase 7 shipped on the web
|
||||||
|
* (the bare path is the inbox, `…/settings` is the preferences). It is what a
|
||||||
|
* tapped push tickle deep-links to, and the pull that follows the wake.
|
||||||
|
*
|
||||||
|
* **The list carries content, so it is deliberately plain text.** An item's body
|
||||||
|
* is the server's `toText` render, never the email HTML — that markup is table
|
||||||
|
* rows and inline hex with a light-only `color-scheme`, which in a themed app
|
||||||
|
* would be a pale card in a dark one. It also means there is no operator markup
|
||||||
|
* on this surface to sanitize.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InboxScreen(
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: InboxViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (state.unread > 0) {
|
||||||
|
stringResource(R.string.inbox_unread_count, state.unread)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.inbox_all_read)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (state.unread > 0) {
|
||||||
|
TextButton(onClick = viewModel::markAllRead) {
|
||||||
|
Text(stringResource(R.string.inbox_mark_all_read))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Icon(Icons.Filled.Settings, stringResource(R.string.inbox_open_settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Showing the snapshot rather than the server's answer is said out loud: a
|
||||||
|
// notification surface that quietly showed a stale list would be lying
|
||||||
|
// about the one thing it exists to be — current.
|
||||||
|
if (state.fromCache) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.inbox_offline_cached),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val items = state.items) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success -> if (items.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.inbox_empty))
|
||||||
|
} else {
|
||||||
|
InboxList(
|
||||||
|
items = items.data,
|
||||||
|
hasMore = state.hasMore && !state.fromCache,
|
||||||
|
onEndReached = viewModel::loadMore,
|
||||||
|
onOpen = { item ->
|
||||||
|
viewModel.markRead(item.id)
|
||||||
|
// Most items have no url at all — an inbox row is complete on
|
||||||
|
// its own — and the ones that do carry a SITE-RELATIVE path,
|
||||||
|
// so the view model resolves it against the configured shard
|
||||||
|
// before anything is opened.
|
||||||
|
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InboxList(
|
||||||
|
items: List<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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the in-app inbox (ENGAGEMENT.md phase 8): the items the engine's `inapp`
|
||||||
|
* channel wrote for this user, newest first, with the unread badge and the two
|
||||||
|
* mark-read writes.
|
||||||
|
*
|
||||||
|
* **The tickle contract is wake-and-pull, and this is the pull.** A push tickle
|
||||||
|
* carries `{ stream, ref }` and nothing else by design; `ref` is a HINT that an
|
||||||
|
* inbox row exists, never content, and `pushChannel.js` says so in as many words —
|
||||||
|
* the two rows are independent and either can be retried, so a client that
|
||||||
|
* rendered the ref would show nothing the first time a retry reordered them. So a
|
||||||
|
* tap deep-links here and this refreshes; the ref is not read.
|
||||||
|
*
|
||||||
|
* **Paging is keyset, not offset.** The next page is `before = the last id on
|
||||||
|
* screen`, because the list gains rows at the top while it is being read and an
|
||||||
|
* offset would show the same item twice or skip one.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class InboxViewModel @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
private val cache: InboxCache,
|
||||||
|
private val sessionManager: SessionManager,
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val items: UiState<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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The absolute link for an item, or null when it has none this app can open.
|
||||||
|
*
|
||||||
|
* **An item's `url` is SITE-RELATIVE** — `/guilds/the-silver-anvil/forum/403`
|
||||||
|
* is what the server writes, because it is rendered from the template's button
|
||||||
|
* block for a browser that is already on the site. A phone is not, so it has to
|
||||||
|
* be resolved against the configured base or every link in the inbox is dead;
|
||||||
|
* the live rig is what caught that.
|
||||||
|
*
|
||||||
|
* `HttpUrl.resolve` does both jobs: it absolutises a relative path and it
|
||||||
|
* returns null for anything that would not end up as http(s) — a `javascript:`
|
||||||
|
* or `intent:` url in a notification body opens nothing at all.
|
||||||
|
*/
|
||||||
|
fun linkFor(item: NotificationItemDto): String? {
|
||||||
|
val raw = item.url?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
|
return baseUrlHolder.current?.resolve(raw)?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the snapshot in step with a local read.
|
||||||
|
*
|
||||||
|
* Without this, going offline right after reading everything would bring the
|
||||||
|
* badge back on the next cold open. Only ever written for the account that
|
||||||
|
* owns it — [InboxCache] scopes by (base URL, user id).
|
||||||
|
*/
|
||||||
|
private suspend fun cacheCurrent() {
|
||||||
|
val owner = ownerKey() ?: return
|
||||||
|
val current = _state.value
|
||||||
|
val shown = (current.items as? UiState.Success)?.data ?: return
|
||||||
|
cache.write(owner, shown, current.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ownerKey(): String? {
|
||||||
|
val user = (sessionManager.state.value as? Session.SignedIn)?.user ?: return null
|
||||||
|
return InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -484,7 +484,7 @@
|
|||||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
||||||
<string name="menu_notifications">Notifications</string>
|
<string name="menu_notifications">Notifications</string>
|
||||||
<string name="notifications_title">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_general">General</string>
|
||||||
<string name="notifications_section_personal">Your game account</string>
|
<string name="notifications_section_personal">Your game account</string>
|
||||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
<string name="notifications_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_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>
|
<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. -->
|
<!-- Notification channels + the ongoing foreground-service notification. -->
|
||||||
<string name="push_channel_messages">Shard notifications</string>
|
<string name="push_channel_messages">Shard notifications</string>
|
||||||
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
||||||
|
|||||||
@@ -82,4 +82,71 @@ class NotificationsDtoTest {
|
|||||||
)
|
)
|
||||||
assertNull(dto.push.ntfyUrl)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.auth.StoredSession
|
||||||
|
import com.runicgateway.app.core.auth.TokenStore
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.util.FakeInboxCache
|
||||||
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.httpError
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox view model (ENGAGEMENT.md phase 8): the pull behind the tickle, the
|
||||||
|
* keyset paging, the optimistic reads, and the offline snapshot — including the
|
||||||
|
* one property that makes caching a person's notifications safe at all, that a
|
||||||
|
* snapshot never crosses an account.
|
||||||
|
*/
|
||||||
|
class InboxViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||||
|
|
||||||
|
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
|
||||||
|
override fun load(): StoredSession? = stored
|
||||||
|
override fun save(session: StoredSession) { stored = session }
|
||||||
|
override fun clear() { stored = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val api = FakeNotificationsApi()
|
||||||
|
private val cache = FakeInboxCache()
|
||||||
|
private val baseUrl = BaseUrlHolder().apply { set("https://shard.example/".toHttpUrl()) }
|
||||||
|
|
||||||
|
private fun session(userId: Long = 7) =
|
||||||
|
SessionManager(FakeTokenStore(StoredSession("access", "refresh", userId, "alice", "player")))
|
||||||
|
|
||||||
|
private fun viewModel(sessionManager: SessionManager = session()) =
|
||||||
|
InboxViewModel(NotificationsRepository(api), cache, sessionManager, baseUrl)
|
||||||
|
|
||||||
|
private fun item(id: Long, read: Boolean = false) =
|
||||||
|
NotificationItemDto(id = id, triggerId = "team.post.created", title = "Post $id", read = read)
|
||||||
|
|
||||||
|
private fun shown(vm: InboxViewModel) = (vm.state.value.items as? UiState.Success)?.data
|
||||||
|
|
||||||
|
@Test fun loadsTheFirstPageAndCachesIt() {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
assertEquals(listOf(2L, 1L), shown(vm)?.map { it.id })
|
||||||
|
assertEquals(2, vm.state.value.unread)
|
||||||
|
assertFalse(vm.state.value.fromCache)
|
||||||
|
assertEquals(1, cache.writes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun offlineFallsBackToTheSnapshotAndSaysSo() {
|
||||||
|
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||||
|
api.error = IOException("offline")
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
assertEquals(listOf(9L), shown(vm)?.map { it.id })
|
||||||
|
assertEquals(1, vm.state.value.unread)
|
||||||
|
assertTrue(vm.state.value.fromCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aSnapshotIsNeverShownToAnotherAccount() {
|
||||||
|
// The property the whole cache design rests on: user 7's items must not
|
||||||
|
// appear under user 8's session, on a device both have signed into.
|
||||||
|
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||||
|
api.error = IOException("offline")
|
||||||
|
val vm = viewModel(session(userId = 8))
|
||||||
|
|
||||||
|
assertNull(shown(vm))
|
||||||
|
assertTrue(vm.state.value.items is UiState.Error)
|
||||||
|
assertEquals(0, vm.state.value.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aServerErrorWithNothingCachedIsTheScreen() {
|
||||||
|
api.error = httpError(500)
|
||||||
|
val vm = viewModel()
|
||||||
|
|
||||||
|
assertTrue(vm.state.value.items is UiState.Error)
|
||||||
|
assertFalse(vm.state.value.fromCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun loadMorePagesOnTheLastIdNotAnOffset() {
|
||||||
|
api.pages = mapOf(
|
||||||
|
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true, unread = 2),
|
||||||
|
8L to NotificationInboxDto(items = listOf(item(7)), hasMore = false, unread = 2),
|
||||||
|
)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.loadMore()
|
||||||
|
|
||||||
|
assertEquals(listOf(null, 8L), api.inboxCalls)
|
||||||
|
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
|
||||||
|
assertFalse(vm.state.value.hasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun loadMoreDropsAnIdAlreadyOnScreen() {
|
||||||
|
// A keyset window can shift under a concurrent write; a duplicate id in a
|
||||||
|
// LazyColumn key is a crash, not a cosmetic problem.
|
||||||
|
api.pages = mapOf(
|
||||||
|
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true),
|
||||||
|
8L to NotificationInboxDto(items = listOf(item(8), item(7))),
|
||||||
|
)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.loadMore()
|
||||||
|
|
||||||
|
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun loadMoreDoesNothingWhileShowingTheCache() {
|
||||||
|
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||||
|
api.error = IOException("offline")
|
||||||
|
val vm = viewModel()
|
||||||
|
api.inboxCalls.clear()
|
||||||
|
vm.loadMore()
|
||||||
|
|
||||||
|
assertTrue(api.inboxCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun markReadFlipsTheRowAndTakesTheServersCount() {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||||
|
api.readResult = NotificationReadResultDto(ok = true, unread = 1)
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.markRead(2)
|
||||||
|
|
||||||
|
assertEquals(listOf(2L), api.markedRead)
|
||||||
|
assertTrue(shown(vm)!!.first { it.id == 2L }.read)
|
||||||
|
assertEquals(1, vm.state.value.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun markReadIsNotSentTwiceForAnItemAlreadyRead() {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2, read = true)), unread = 0))
|
||||||
|
val vm = viewModel()
|
||||||
|
vm.markRead(2)
|
||||||
|
|
||||||
|
assertTrue(api.markedRead.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun markAllReadEmptiesTheBadgeAndUpdatesTheSnapshot() {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||||
|
val vm = viewModel()
|
||||||
|
val writesAfterLoad = cache.writes
|
||||||
|
vm.markAllRead()
|
||||||
|
|
||||||
|
assertEquals(1, api.markAllReadCalls)
|
||||||
|
assertEquals(0, vm.state.value.unread)
|
||||||
|
assertTrue(shown(vm)!!.all { it.read })
|
||||||
|
// Without this write, going offline right after reading everything would
|
||||||
|
// bring the badge back on the next cold open.
|
||||||
|
assertEquals(writesAfterLoad + 1, cache.writes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The item link (found by the live rig, not by a test) ──────────────
|
||||||
|
|
||||||
|
@Test fun aSiteRelativeUrlIsResolvedAgainstTheShard() {
|
||||||
|
// What the server actually writes: the template's button block renders a
|
||||||
|
// path, because on the web the reader is already on the site.
|
||||||
|
val vm = viewModel()
|
||||||
|
val item = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
|
||||||
|
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anAbsoluteUrlIsLeftAlone() {
|
||||||
|
val vm = viewModel()
|
||||||
|
assertEquals("https://elsewhere.example/x", vm.linkFor(item(1).copy(url = "https://elsewhere.example/x")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anItemWithNoUrlHasNoLink() {
|
||||||
|
val vm = viewModel()
|
||||||
|
assertNull(vm.linkFor(item(1)))
|
||||||
|
assertNull(vm.linkFor(item(1).copy(url = " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aUrlThatCouldNotBeOpenedSafelyResolvesToNothing() {
|
||||||
|
val vm = viewModel()
|
||||||
|
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
|
||||||
|
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.notifications
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
import com.runicgateway.app.core.push.PushStreams
|
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 com.runicgateway.app.ui.navigation.Routes
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -12,8 +12,9 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests the pure push helpers: the stream → deep-link route map (PLAN.md §11 work
|
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
||||||
* item 7) and the personal-stream gating (a personal stream needs a linked account).
|
* §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 {
|
class NotificationRoutingTest {
|
||||||
|
|
||||||
@@ -32,14 +33,41 @@ class NotificationRoutingTest {
|
|||||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun personalStreamNeedsLinkedAccount() {
|
@Test fun personalItemNeedsLinkedAccount() {
|
||||||
val personal = NotificationStreamDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||||
assertFalse(streamSelectable(personal, hasLinkedAccount = false))
|
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
|
||||||
assertTrue(streamSelectable(personal, hasLinkedAccount = true))
|
assertTrue(itemSelectable(personal, hasLinkedAccount = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun generalStreamIsAlwaysSelectable() {
|
@Test fun generalItemIsAlwaysSelectable() {
|
||||||
val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||||
assertTrue(streamSelectable(general, hasLinkedAccount = 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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user