Merge pull request 'feat(notifications): a Rust notification on a phone (module-rust phase 11)' (#50) from feat/rust-phase-11-notifications into edge
Reviewed-on: #50
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.push
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox id a tickle's `notification:<id>` ref names, or null.
|
||||||
|
*
|
||||||
|
* Prefix-exact, the same rule [Routes.forTickle] reads the ref by, and digits
|
||||||
|
* only. The relay is untrusted, so a ref is a hint, and anything that is not
|
||||||
|
* exactly a positive id is treated as no hint at all.
|
||||||
|
*/
|
||||||
|
internal fun inboxIdFromRef(ref: String?): Long? {
|
||||||
|
if (ref == null || !ref.startsWith(Routes.INBOX_REF_PREFIX)) return null
|
||||||
|
val digits = ref.substring(Routes.INBOX_REF_PREFIX.length)
|
||||||
|
if (digits.isEmpty() || digits.length > MAX_ID_DIGITS || !digits.all { it in '0'..'9' }) return null
|
||||||
|
return digits.toLongOrNull()?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item from [page] that [tickle] is about, or null.
|
||||||
|
*
|
||||||
|
* The item must be the one the ref names **and** come from the trigger the tickle
|
||||||
|
* names. Only the user's own inbox can be read, so a forged pair could only put
|
||||||
|
* one of their own items on their own phone. A tickle that disagrees with the row
|
||||||
|
* it points at is still not one to title from.
|
||||||
|
*/
|
||||||
|
internal fun itemForTickle(tickle: PushTickle, page: List<NotificationItemDto>): NotificationItemDto? {
|
||||||
|
val id = inboxIdFromRef(tickle.ref) ?: return null
|
||||||
|
return page.firstOrNull { it.id == id && it.triggerId == tickle.stream }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the inbox row a tickle points at, so the notification can say what
|
||||||
|
* happened (`docs/modules/rust/PLAN.md` D70).
|
||||||
|
*
|
||||||
|
* This is the wake-and-pull contract core's `pushChannel.js` describes: the relay
|
||||||
|
* carries `{ stream, ref }` and nothing else, and the content comes over the
|
||||||
|
* authenticated, ownership-checked inbox API. Core has no single-item read, so
|
||||||
|
* this reads the first page. The row was written moments ago, so it is on that
|
||||||
|
* page, and if it is not the caller falls back to the per-stream title.
|
||||||
|
*
|
||||||
|
* **Every failure is null**: no ref, a signed-out app, a dead network, a timeout,
|
||||||
|
* an item not on the page. A GET of the inbox marks nothing read, so the badge
|
||||||
|
* is left alone.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class PushContentResolver @Inject constructor(
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
|
) {
|
||||||
|
suspend fun itemFor(tickle: PushTickle): NotificationItemDto? {
|
||||||
|
if (inboxIdFromRef(tickle.ref) == null) return null
|
||||||
|
val page = withTimeoutOrNull(PULL_TIMEOUT_MS) {
|
||||||
|
(notifications.inbox() as? ApiResult.Ok)?.data?.items
|
||||||
|
} ?: return null
|
||||||
|
return itemForTickle(tickle, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Long enough for a phone waking on a slow network, and short enough that a
|
||||||
|
* notification is never held back noticeably for a title.
|
||||||
|
*/
|
||||||
|
const val PULL_TIMEOUT_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `BIGINT UNSIGNED` never runs past 20 digits. */
|
||||||
|
private const val MAX_ID_DIGITS = 20
|
||||||
@@ -13,6 +13,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.runicgateway.app.MainActivity
|
import com.runicgateway.app.MainActivity
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -20,10 +21,18 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the notification channels and posts a notification for a received tickle
|
* Builds the notification channels and posts a notification for a received tickle
|
||||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
|
* (PLAN.md §11, M7 Part 2 work items 2/3/7). Tapping deep-links into [MainActivity],
|
||||||
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
|
* which fetches fresh over the authenticated API.
|
||||||
* carries nothing to render, so nothing is fetched to display the notification; tapping
|
*
|
||||||
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
|
* **The title comes from the inbox row when there is one** (`docs/modules/rust/PLAN.md`
|
||||||
|
* D70). The tickle carries nothing to render. When its ref names an inbox row,
|
||||||
|
* [PushContentResolver] has already pulled that row over the authenticated API, and
|
||||||
|
* the notification says what the row says. Every other tickle keeps the M7 behaviour:
|
||||||
|
* a **generic per-stream** title from the fixed [PushStreams] catalog. Until D70 that
|
||||||
|
* was every tickle, and "New notification" is all a Rust raid alert ever said.
|
||||||
|
*
|
||||||
|
* **The lock screen shows only the generic title.** The row's text is content, and
|
||||||
|
* a locked phone on a table is not the place for "a door was destroyed in S16".
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class PushNotifier @Inject constructor(
|
class PushNotifier @Inject constructor(
|
||||||
@@ -65,17 +74,32 @@ class PushNotifier @Inject constructor(
|
|||||||
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
|
/**
|
||||||
fun notify(tickle: PushTickle) {
|
* Post a notification for a tickle, deep-linking to the stream's screen on tap.
|
||||||
|
*
|
||||||
|
* [item] is the inbox row the tickle points at, when it could be pulled; null
|
||||||
|
* falls back to the per-stream title.
|
||||||
|
*/
|
||||||
|
fun notify(tickle: PushTickle, item: NotificationItemDto? = null) {
|
||||||
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
||||||
val title = context.getString(PushStreams.titleRes(tickle.stream))
|
val generic = context.getString(PushStreams.titleRes(tickle.stream))
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
val content = notificationText(item, generic)
|
||||||
.setContentTitle(title)
|
val redacted = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||||
|
.setContentTitle(generic)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.build()
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
||||||
|
.setContentTitle(content.title)
|
||||||
.setSmallIcon(R.drawable.ic_stat_name)
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setPublicVersion(redacted)
|
||||||
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
||||||
.build()
|
content.body?.let { body ->
|
||||||
|
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
}
|
||||||
|
val notification = builder.build()
|
||||||
try {
|
try {
|
||||||
manager.notify(nextId.getAndIncrement(), notification)
|
manager.notify(nextId.getAndIncrement(), notification)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
@@ -108,3 +132,18 @@ class PushNotifier @Inject constructor(
|
|||||||
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a posted notification says: a title always, and a body when the row had one. */
|
||||||
|
internal data class NotificationText(val title: String, val body: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text for a notification about [item], falling back to [generic] (D70).
|
||||||
|
*
|
||||||
|
* A row with a blank title is treated as no row: a notification with an empty
|
||||||
|
* headline is worse than one that says only that something happened.
|
||||||
|
*/
|
||||||
|
internal fun notificationText(item: NotificationItemDto?, generic: String): NotificationText {
|
||||||
|
val title = item?.title?.trim().orEmpty()
|
||||||
|
if (title.isEmpty()) return NotificationText(generic, null)
|
||||||
|
return NotificationText(title, item?.body?.trim()?.takeIf { it.isNotEmpty() })
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -33,6 +32,7 @@ class PushService : Service() {
|
|||||||
|
|
||||||
@Inject lateinit var streamClient: NtfyStreamClient
|
@Inject lateinit var streamClient: NtfyStreamClient
|
||||||
@Inject lateinit var notifier: PushNotifier
|
@Inject lateinit var notifier: PushNotifier
|
||||||
|
@Inject lateinit var content: PushContentResolver
|
||||||
@Inject lateinit var prefs: PushPreferences
|
@Inject lateinit var prefs: PushPreferences
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -67,8 +67,15 @@ class PushService : Service() {
|
|||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
|
// Each tickle in its own job (D70). Titling one from its inbox row
|
||||||
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
|
// suspends on a network read, and inside `collectLatest` the next tickle
|
||||||
|
// would cancel it. A burst (two owners' raid alerts, then a restart) would
|
||||||
|
// then post only the last.
|
||||||
|
streamClient.events(snapshot.ntfyUrl, topic).collect { event ->
|
||||||
|
if (event is NtfyStreamClient.Event.Message) {
|
||||||
|
val tickle = event.tickle
|
||||||
|
scope.launch { notifier.notify(tickle, content.itemFor(tickle)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** A game module whose player surface can say whether this user has linked an account. */
|
||||||
|
enum class LinkSource { RUST, SHARD }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which module's link read answers for this site (`docs/modules/rust/PLAN.md` D69).
|
||||||
|
*
|
||||||
|
* **Read off the capabilities, never assumed.** Before phase 11 the answer was
|
||||||
|
* always `module-uo`'s `/player/shard/accounts`, which does not exist on a Rust
|
||||||
|
* site. That call failed, the failure read as "not linked", and the raid alert
|
||||||
|
* was locked in every channel.
|
||||||
|
*
|
||||||
|
* A host that has **never** answered asks both, and a failed read is simply no
|
||||||
|
* link. A site runs one module (§24.5), so at most one of the two answers. That
|
||||||
|
* is the same fail-open direction [canUse] takes. A host that answered and named
|
||||||
|
* neither module has no link to ask about.
|
||||||
|
*/
|
||||||
|
fun linkSourcesFor(capabilities: SiteCapabilities?): List<LinkSource> {
|
||||||
|
if (capabilities == null) return LinkSource.entries
|
||||||
|
return buildList {
|
||||||
|
if (Capability.RUST in capabilities) add(LinkSource.RUST)
|
||||||
|
if (Capability.SHARD in capabilities) add(LinkSource.SHARD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the signed-in user holds at least one linked game account on this site.
|
||||||
|
*
|
||||||
|
* It exists for the settings screen's one courtesy gate: a `requiresLinkedAccount`
|
||||||
|
* stream cannot have push switched **on** without a link. It is a courtesy and not
|
||||||
|
* a boundary. The server enforces no such flag, and who is actually alerted is the
|
||||||
|
* module's recipient computation (Rust D59), so this never needs to be more than
|
||||||
|
* a best guess.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LinkedAccountRepository @Inject constructor(
|
||||||
|
private val capabilities: SiteCapabilitiesRepository,
|
||||||
|
private val rust: PlayerRustRepository,
|
||||||
|
private val shard: PlayerShardRepository,
|
||||||
|
) {
|
||||||
|
suspend fun hasLinkedAccount(): Boolean =
|
||||||
|
linkSourcesFor(capabilities.capabilities.value).any { source ->
|
||||||
|
when (source) {
|
||||||
|
LinkSource.RUST -> (rust.links() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||||
|
LinkSource.SHARD -> (shard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -635,7 +635,14 @@ private fun RunicNavHost(
|
|||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.RUST_SERVER,
|
route = Routes.RUST_SERVER,
|
||||||
arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }),
|
arguments = listOf(
|
||||||
|
navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType },
|
||||||
|
navArgument(Routes.Args.TAB) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
|
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||||
|
import com.runicgateway.app.ui.rust.RustTab
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||||
@@ -257,7 +258,8 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||||
* /rust → RUST (module-rust's server list)
|
* /rust → RUST (module-rust's server list)
|
||||||
* /rust/servers/<id> → RUST_SERVER
|
* /rust/servers/<id>[?tab=<tab>] → RUST_SERVER (a tab the app has; Rust D71)
|
||||||
|
* /player/rust → PLAYER_RUST (Rust D71)
|
||||||
* /site/about → PAGE("about")
|
* /site/about → PAGE("about")
|
||||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||||
* anything else → null, i.e. the Custom Tab
|
* anything else → null, i.e. the Custom Tab
|
||||||
@@ -272,6 +274,12 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* string, any second parameter, and any fragment still hand off** — the carve-out
|
* string, any second parameter, and any fragment still hand off** — the carve-out
|
||||||
* is one key on one path, not a general "parse the query".
|
* is one key on one path, not a general "parse the query".
|
||||||
*
|
*
|
||||||
|
* **Rust D71 adds the second, on the same terms:** `tab` on a Rust server's path,
|
||||||
|
* which is what the *new leader* notice links to. It resolves only when the value
|
||||||
|
* is a tab the app has. `?tab=clans` still hands off, because the website has a
|
||||||
|
* Clans tab and the app does not, and opening the feed instead would be the quiet
|
||||||
|
* drop this rule exists to prevent.
|
||||||
|
*
|
||||||
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
||||||
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||||
* it.
|
* it.
|
||||||
@@ -309,6 +317,10 @@ fun resolveWebPath(path: String?): String? {
|
|||||||
val run = runParam(query) ?: return null
|
val run = runParam(query) ?: return null
|
||||||
return Routes.event(segments[2], run)
|
return Routes.event(segments[2], run)
|
||||||
}
|
}
|
||||||
|
if (segments.size == 3 && segments[0] == MODULE_RUST && segments[1] == "servers" && query.isNotEmpty()) {
|
||||||
|
val tab = rustTabParam(query) ?: return null
|
||||||
|
return Routes.rustServer(segments[2], tab)
|
||||||
|
}
|
||||||
if (query.isNotEmpty()) return null
|
if (query.isNotEmpty()) return null
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
@@ -326,6 +338,10 @@ fun resolveWebPath(path: String?): String? {
|
|||||||
// answered by the nav table above, before this fallback is reached.
|
// answered by the nav table above, before this fallback is reached.
|
||||||
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||||
Routes.rustServer(segments[2])
|
Routes.rustServer(segments[2])
|
||||||
|
// The player's own Rust account, which the *account linked* notice links
|
||||||
|
// to (D71). Not in the nav table above, which is built from the PUBLIC
|
||||||
|
// nav; a signed-out tap is caught by the player gate like any other.
|
||||||
|
segments.size == 2 && segments[0] == "player" && segments[1] == MODULE_RUST -> Routes.PLAYER_RUST
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,6 +362,17 @@ private fun runParam(query: String): String? {
|
|||||||
return value.takeIf { '&' !in it && '=' !in it }
|
return value.takeIf { '&' !in it && '=' !in it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab a query of **exactly** `tab=<something>` names, when the app has that
|
||||||
|
* tab (Rust D71), or null, which hands the link to the browser. Same shape as
|
||||||
|
* [runParam], and for the same reasons.
|
||||||
|
*/
|
||||||
|
private fun rustTabParam(query: String): RustTab? {
|
||||||
|
val value = query.removePrefix("tab=")
|
||||||
|
if (value.length == query.length || value.isEmpty() || '&' in value || '=' in value) return null
|
||||||
|
return RustTab.fromWire(value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The module id whose public pages this table maps.
|
* The module id whose public pages this table maps.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
import com.runicgateway.app.data.repository.ContentRepository
|
import com.runicgateway.app.data.repository.ContentRepository
|
||||||
|
import com.runicgateway.app.ui.rust.RustTab
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||||
@@ -84,7 +85,7 @@ object Routes {
|
|||||||
* two can be installed on the same backend, and then both trees exist at once.
|
* two can be installed on the same backend, and then both trees exist at once.
|
||||||
*/
|
*/
|
||||||
const val RUST = "rust"
|
const val RUST = "rust"
|
||||||
const val RUST_SERVER = "rust/servers/{serverId}"
|
const val RUST_SERVER = "rust/servers/{serverId}?tab={tab}"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
|
* The player's own Rust account (§9 M15) — the Steam accounts they hold and
|
||||||
@@ -149,6 +150,7 @@ object Routes {
|
|||||||
const val SERIAL = "serial"
|
const val SERIAL = "serial"
|
||||||
const val RUN = "run"
|
const val RUN = "run"
|
||||||
const val SERVER_ID = "serverId"
|
const val SERVER_ID = "serverId"
|
||||||
|
const val TAB = "tab"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
@@ -179,8 +181,14 @@ object Routes {
|
|||||||
* JVM unit test and throws "not mocked" — this object is pure and every test
|
* JVM unit test and throws "not mocked" — this object is pure and every test
|
||||||
* that builds a route would have to become an instrumented one to keep it
|
* that builds a route would have to become an instrumented one to keep it
|
||||||
* that way.
|
* that way.
|
||||||
|
*
|
||||||
|
* [tab] is what the *new leader* notice's link carries (Rust D71), and it is
|
||||||
|
* dropped when absent, as `run` is on [event].
|
||||||
*/
|
*/
|
||||||
fun rustServer(id: String) = "rust/servers/${encodePathSegment(id)}"
|
fun rustServer(id: String, tab: RustTab? = null): String {
|
||||||
|
val base = "rust/servers/${encodePathSegment(id)}"
|
||||||
|
return if (tab == null) base else "$base?tab=${tab.wire}"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Percent-encode one path segment, allowing only the unreserved set.
|
* Percent-encode one path segment, allowing only the unreserved set.
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ private fun ChannelPrefsList(
|
|||||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
general.forEach { item ->
|
general.forEach { item ->
|
||||||
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode)
|
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
@@ -163,15 +163,7 @@ private fun ChannelPrefsList(
|
|||||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
personal.forEach { item ->
|
personal.forEach { item ->
|
||||||
val selectable = itemSelectable(item, hasLinkedAccount)
|
ItemRow(item, channelsById, hasLinkedAccount, busy, pushSupported, onSetMode)
|
||||||
ItemRow(
|
|
||||||
item = item,
|
|
||||||
channelsById = channelsById,
|
|
||||||
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
|
|
||||||
enabled = !busy && selectable,
|
|
||||||
pushSupported = pushSupported,
|
|
||||||
onSetMode = onSetMode,
|
|
||||||
)
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,33 +173,40 @@ private fun ChannelPrefsList(
|
|||||||
private fun ItemRow(
|
private fun ItemRow(
|
||||||
item: NotificationChannelItemDto,
|
item: NotificationChannelItemDto,
|
||||||
channelsById: Map<String, NotificationChannelDto>,
|
channelsById: Map<String, NotificationChannelDto>,
|
||||||
hint: String?,
|
hasLinkedAccount: Boolean,
|
||||||
enabled: Boolean,
|
busy: Boolean,
|
||||||
pushSupported: Boolean,
|
pushSupported: Boolean,
|
||||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// The link holds back one control, push switching on, so the row stays live
|
||||||
|
// and the hint names that one control (Rust D69). Where this device has no
|
||||||
|
// push at all, there is no control to hold back and nothing to explain.
|
||||||
|
val pushHeld = pushSupported && pushNeedsLink(item, hasLinkedAccount)
|
||||||
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = hint ?: item.description,
|
text = if (pushHeld) stringResource(R.string.notifications_requires_link) else item.description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
fontStyle = if (pushHeld) FontStyle.Italic else FontStyle.Normal,
|
||||||
)
|
)
|
||||||
// The item's OWN channel list, in the registry's order. An id nothing can
|
// 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.
|
// push carries no push control at all, rather than a dead switch.
|
||||||
item.channels.forEach { channelId ->
|
item.channels.forEach { channelId ->
|
||||||
val channel = channelsById[channelId] ?: return@forEach
|
val channel = channelsById[channelId] ?: return@forEach
|
||||||
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
||||||
|
val mode = item.modes[channelId] ?: channel.defaultMode
|
||||||
ChannelControl(
|
ChannelControl(
|
||||||
channel = channel,
|
channel = channel,
|
||||||
mode = item.modes[channelId] ?: channel.defaultMode,
|
mode = mode,
|
||||||
enabled = enabled,
|
// A held push switch that is already ON stays enabled, because
|
||||||
onSetMode = { mode -> onSetMode(item, channelId, mode) },
|
// switching it off is never refused ([canSetMode]).
|
||||||
|
enabled = !busy && !(channelId == CHANNEL_PUSH && pushHeld && mode == MODE_OFF),
|
||||||
|
onSetMode = { next -> onSetMode(item, channelId, next) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
import com.runicgateway.app.data.repository.LinkedAccountRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -53,7 +53,7 @@ const val MODE_OFF = "off"
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NotificationSettingsViewModel @Inject constructor(
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
private val notifications: NotificationsRepository,
|
private val notifications: NotificationsRepository,
|
||||||
private val playerShard: PlayerShardRepository,
|
private val linkedAccounts: LinkedAccountRepository,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -62,7 +62,10 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
|
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
|
||||||
/** Whether the user has ≥1 linked game account — personal streams need it. */
|
/**
|
||||||
|
* Whether the user has ≥1 linked game account on this site's module, asked
|
||||||
|
* of that module (Rust D69). It only gates switching push ON (see [canSetMode]).
|
||||||
|
*/
|
||||||
val hasLinkedAccount: Boolean = false,
|
val hasLinkedAccount: Boolean = false,
|
||||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||||
val supported: Boolean = true,
|
val supported: Boolean = true,
|
||||||
@@ -94,9 +97,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
_state.update { it.copy(prefs = UiState.Loading) }
|
_state.update { it.copy(prefs = UiState.Loading) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||||
// A linked game account gates the personal streams; failure → treat as none.
|
_state.update { it.copy(hasLinkedAccount = linkedAccounts.hasLinkedAccount()) }
|
||||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
|
||||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
||||||
val current = _state.value
|
val current = _state.value
|
||||||
if (current.busy) return
|
if (current.busy) return
|
||||||
if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return
|
if (!canSetMode(item, channel, mode, current.hasLinkedAccount)) return
|
||||||
|
|
||||||
_state.update { it.copy(busy = true, feedback = null) }
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -160,8 +161,28 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether an item's controls are selectable for a user: a personal stream needs a
|
* Whether this item's push is held back for want of a linked game account
|
||||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
* (PLAN.md §11, `docs/modules/rust/PLAN.md` D69).
|
||||||
|
*
|
||||||
|
* **Push only.** Until phase 11 the whole row went dead, every channel, which
|
||||||
|
* made a personal stream impossible to switch *off* on any site where the link
|
||||||
|
* check failed. That was every Rust site, because the check asked `module-uo`.
|
||||||
|
* In-app and email have nothing to do with the link, and neither does turning
|
||||||
|
* anything off. Pure so the gating is unit-tested without Compose.
|
||||||
*/
|
*/
|
||||||
fun itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
fun pushNeedsLink(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||||
!item.requiresLinkedAccount || hasLinkedAccount
|
item.requiresLinkedAccount && !hasLinkedAccount && CHANNEL_PUSH in item.channels
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [mode] may be set on [channel] for [item].
|
||||||
|
*
|
||||||
|
* The one thing refused is switching push **on** for an item [pushNeedsLink] holds
|
||||||
|
* back. Switching anything off is never refused: a gate that can trap a switch in
|
||||||
|
* the on position is worse than no gate.
|
||||||
|
*/
|
||||||
|
fun canSetMode(
|
||||||
|
item: NotificationChannelItemDto,
|
||||||
|
channel: String,
|
||||||
|
mode: String,
|
||||||
|
hasLinkedAccount: Boolean,
|
||||||
|
): Boolean = !(channel == CHANNEL_PUSH && mode != MODE_OFF && pushNeedsLink(item, hasLinkedAccount))
|
||||||
|
|||||||
@@ -26,7 +26,20 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** The four sections of a server's page (D13). */
|
/** The four sections of a server's page (D13). */
|
||||||
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES }
|
enum class RustTab {
|
||||||
|
FEED, LEADERBOARD, ONLINE, WIPES;
|
||||||
|
|
||||||
|
/** The website's name for this tab, as `?tab=` carries it (`ServerDetail.jsx`). */
|
||||||
|
val wire: String get() = name.lowercase()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The tab the website calls [wire], or null for one the app does not have.
|
||||||
|
* The website also has `clans`, and a link to it is not a link to the feed.
|
||||||
|
*/
|
||||||
|
fun fromWire(wire: String?): RustTab? = entries.firstOrNull { it.wire == wire }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||||
object RustSort {
|
object RustSort {
|
||||||
@@ -91,11 +104,15 @@ class RustServerViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||||
|
|
||||||
|
/** The tab a link asked for (D71), or null for the feed every page opens on. */
|
||||||
|
private val initialTab: RustTab? = RustTab.fromWire(savedStateHandle[Routes.Args.TAB])
|
||||||
|
|
||||||
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||||
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
|
initialTab?.takeIf { it != RustTab.FEED }?.let(::selectTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A first load or a retry of the whole page. */
|
/** A first load or a retry of the whole page. */
|
||||||
|
|||||||
@@ -490,7 +490,7 @@
|
|||||||
<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_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 get this as a push notification.</string>
|
||||||
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
||||||
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
||||||
<string name="notifications_saved">Notification settings saved.</string>
|
<string name="notifications_saved">Notification settings saved.</string>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.push
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
|
||||||
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tickle titled from the inbox row it points at (`docs/modules/rust/PLAN.md`
|
||||||
|
* D70), and every way that falls back to the per-stream title.
|
||||||
|
*/
|
||||||
|
class PushContentTest {
|
||||||
|
|
||||||
|
private val raid = NotificationItemDto(
|
||||||
|
id = 42,
|
||||||
|
triggerId = "rust.base.destroyed",
|
||||||
|
title = "Your base is being raided",
|
||||||
|
body = "A door was destroyed in S16 on Oxide rig.",
|
||||||
|
)
|
||||||
|
private val raidTickle = PushTickle(stream = "rust.base.destroyed", ref = "notification:42")
|
||||||
|
|
||||||
|
// ── The ref ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun aRefNamesAnInboxIdOnlyWhenItIsExactlyOne() {
|
||||||
|
assertEquals(42L, inboxIdFromRef("notification:42"))
|
||||||
|
assertNull(inboxIdFromRef(null))
|
||||||
|
assertNull(inboxIdFromRef("notification:"))
|
||||||
|
assertNull(inboxIdFromRef("notification:0"))
|
||||||
|
assertNull(inboxIdFromRef("notification:-4"))
|
||||||
|
assertNull(inboxIdFromRef("notification:4a"))
|
||||||
|
assertNull(inboxIdFromRef("notification: 4"))
|
||||||
|
assertNull(inboxIdFromRef("xnotification:4"))
|
||||||
|
assertNull(inboxIdFromRef("0x24C"))
|
||||||
|
assertNull(inboxIdFromRef("notification:" + "9".repeat(40)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Which row ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theRowMustBeTheNamedOneFromTheNamedTrigger() {
|
||||||
|
val other = raid.copy(id = 41, title = "An older raid")
|
||||||
|
assertEquals(raid, itemForTickle(raidTickle, listOf(other, raid)))
|
||||||
|
// Same id, different trigger: the tickle disagrees with the row.
|
||||||
|
assertNull(itemForTickle(raidTickle.copy(stream = "rust.server.online"), listOf(raid)))
|
||||||
|
assertNull(itemForTickle(raidTickle.copy(ref = "notification:7"), listOf(raid)))
|
||||||
|
assertNull(itemForTickle(raidTickle.copy(ref = null), listOf(raid)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What the notification says ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theRowsTitleAndBodyAreUsed() {
|
||||||
|
val text = notificationText(raid, generic = "New notification")
|
||||||
|
assertEquals("Your base is being raided", text.title)
|
||||||
|
assertEquals("A door was destroyed in S16 on Oxide rig.", text.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun noRowOrABlankTitleFallsBackToTheStreamTitle() {
|
||||||
|
assertEquals(NotificationText("New notification", null), notificationText(null, "New notification"))
|
||||||
|
assertEquals(
|
||||||
|
NotificationText("New notification", null),
|
||||||
|
notificationText(raid.copy(title = " "), "New notification"),
|
||||||
|
)
|
||||||
|
assertEquals(null, notificationText(raid.copy(body = " "), "x").body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The pull ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private val api = FakeNotificationsApi()
|
||||||
|
private val resolver = PushContentResolver(NotificationsRepository(api))
|
||||||
|
|
||||||
|
@Test fun theResolverPullsTheFirstPage() = runTest {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid), unread = 1))
|
||||||
|
|
||||||
|
assertEquals(raid, resolver.itemFor(raidTickle))
|
||||||
|
assertEquals(listOf<Long?>(null), api.inboxCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aTickleWithNoInboxRefIsNeverPulled() = runTest {
|
||||||
|
// A classic M7 stream, or a push-only rule with no inbox row.
|
||||||
|
assertNull(resolver.itemFor(PushTickle(stream = "news.post", ref = "0x24C")))
|
||||||
|
assertTrue(api.inboxCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFailedPullIsNullNotAnError() = runTest {
|
||||||
|
api.error = IOException("offline")
|
||||||
|
assertNull(resolver.itemFor(raidTickle))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aRowNotOnThePageIsNull() = runTest {
|
||||||
|
api.pages = mapOf(null to NotificationInboxDto(items = listOf(raid.copy(id = 99))))
|
||||||
|
assertNull(resolver.itemFor(raidTickle))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.InstalledModuleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RustLinkListDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakePlayerRustApi
|
||||||
|
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
|
||||||
|
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||||
|
import com.runicgateway.app.util.httpError
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Has this user linked a game account" is asked of the site's own module
|
||||||
|
* (`docs/modules/rust/PLAN.md` D69).
|
||||||
|
*
|
||||||
|
* Before phase 11 it was always `module-uo`'s `/player/shard/accounts`. A Rust
|
||||||
|
* site has no such route, so the question always answered no, and the raid
|
||||||
|
* alert's row was locked in every channel.
|
||||||
|
*/
|
||||||
|
class LinkedAccountRepositoryTest {
|
||||||
|
|
||||||
|
private val publicApi = FakePublicApi()
|
||||||
|
private val capabilities = SiteCapabilitiesRepository(publicApi)
|
||||||
|
private val rustApi = FakePlayerRustApi()
|
||||||
|
private val shardApi = FakePlayerShardApi()
|
||||||
|
private val repository = LinkedAccountRepository(
|
||||||
|
capabilities,
|
||||||
|
PlayerRustRepository(rustApi),
|
||||||
|
PlayerShardRepository(shardApi),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun running(module: String, vararg caps: String) {
|
||||||
|
publicApi.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = module, capabilities = caps.toList())))
|
||||||
|
capabilities.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aRustSiteAsksTheRustModule() = runTest {
|
||||||
|
running("rust", "rust", "servers", "identity")
|
||||||
|
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "76561198000000001")))
|
||||||
|
// The UO read would say no, and it must not be the one that is asked.
|
||||||
|
shardApi.error = httpError(404)
|
||||||
|
|
||||||
|
assertTrue(repository.hasLinkedAccount())
|
||||||
|
assertEquals(1, rustApi.linksCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aRustSiteWithNoLinkSaysNo() = runTest {
|
||||||
|
running("rust", "rust")
|
||||||
|
assertFalse(repository.hasLinkedAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aUoSiteStillAsksTheShardModuleAndNeverTheRustOne() = runTest {
|
||||||
|
running("uo", "shard", "atlas")
|
||||||
|
shardApi.accounts = listOf(ShardLinkDto(account = "walker"))
|
||||||
|
|
||||||
|
assertTrue(repository.hasLinkedAccount())
|
||||||
|
assertEquals(0, rustApi.linksCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aSiteWithNeitherModuleHasNothingToAsk() = runTest {
|
||||||
|
running("other", "something")
|
||||||
|
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1")))
|
||||||
|
|
||||||
|
assertFalse(repository.hasLinkedAccount())
|
||||||
|
assertEquals(0, rustApi.linksCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aHostThatHasNeverAnsweredAsksBothAndAFailureIsNoLink() = runTest {
|
||||||
|
// One site runs one module, so one of the two 404s; that is not an error.
|
||||||
|
shardApi.error = httpError(404)
|
||||||
|
rustApi.links = RustLinkListDto(links = listOf(RustLinkDto(steamId = "1")))
|
||||||
|
|
||||||
|
assertTrue(repository.hasLinkedAccount())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theSourcesAreReadOffTheCapabilities() {
|
||||||
|
assertEquals(LinkSource.entries, linkSourcesFor(null))
|
||||||
|
assertEquals(listOf(LinkSource.RUST), linkSourcesFor(SiteCapabilities(emptySet(), setOf("rust"))))
|
||||||
|
assertEquals(listOf(LinkSource.SHARD), linkSourcesFor(SiteCapabilities(emptySet(), setOf("shard"))))
|
||||||
|
assertEquals(emptyList<LinkSource>(), linkSourcesFor(SiteCapabilities(setOf("events"), emptySet())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.runicgateway.app.core.auth.Session
|
|||||||
import com.runicgateway.app.core.auth.SessionUser
|
import com.runicgateway.app.core.auth.SessionUser
|
||||||
import com.runicgateway.app.data.repository.Capability
|
import com.runicgateway.app.data.repository.Capability
|
||||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
|
import com.runicgateway.app.ui.rust.RustTab
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -148,4 +149,38 @@ class RustNavigationTest {
|
|||||||
assertNull(resolveWebPath("/rust?tab=wipes"))
|
assertNull(resolveWebPath("/rust?tab=wipes"))
|
||||||
assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
|
assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The notification links (phase 11, D71) ────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theNewLeaderNoticeOpensTheLeaderboardTab() {
|
||||||
|
// `leaderboardPath` in module-rust's triggers.js.
|
||||||
|
assertEquals("rust/servers/main?tab=leaderboard", resolveWebPath("/rust/servers/main?tab=leaderboard"))
|
||||||
|
assertEquals(Routes.rustServer("main", RustTab.LEADERBOARD), resolveWebPath("/rust/servers/main?tab=leaderboard"))
|
||||||
|
assertEquals(Routes.rustServer("main", RustTab.WIPES), resolveWebPath("/rust/servers/main?tab=wipes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun onlyATabTheAppHasResolves() {
|
||||||
|
// The website has a Clans tab and the app does not: the browser, not the feed.
|
||||||
|
assertNull(resolveWebPath("/rust/servers/main?tab=clans"))
|
||||||
|
assertNull(resolveWebPath("/rust/servers/main?tab="))
|
||||||
|
assertNull(resolveWebPath("/rust/servers/main?tab=wipes&wipe=w1"))
|
||||||
|
assertNull(resolveWebPath("/rust/servers/main?sort=kills"))
|
||||||
|
assertNull(resolveWebPath("/rust/servers/main?tab=LEADERBOARD"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aTablessRouteStaysTheOneEveryOtherCallerBuilds() {
|
||||||
|
assertEquals("rust/servers/main", Routes.rustServer("main"))
|
||||||
|
assertEquals(RustTab.ONLINE, RustTab.fromWire("online"))
|
||||||
|
assertNull(RustTab.fromWire("clans"))
|
||||||
|
assertNull(RustTab.fromWire(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theAccountLinkedNoticeOpensThePlayersOwnScreen() {
|
||||||
|
// `PATHS.account` in module-rust's triggers.js. M15 deferred this to the
|
||||||
|
// phase that would need it.
|
||||||
|
assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust"))
|
||||||
|
assertEquals(Routes.PLAYER_RUST, resolveWebPath("/player/rust/"))
|
||||||
|
assertNull(resolveWebPath("/player/rust?x=1"))
|
||||||
|
assertNull(resolveWebPath("/player/shard"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import org.junit.Test
|
|||||||
/**
|
/**
|
||||||
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
||||||
* §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it,
|
* §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).
|
* and the personal-item gating (a personal id needs a linked game account to
|
||||||
|
* switch push on, and nothing else).
|
||||||
*/
|
*/
|
||||||
class NotificationRoutingTest {
|
class NotificationRoutingTest {
|
||||||
|
|
||||||
@@ -33,15 +34,23 @@ class NotificationRoutingTest {
|
|||||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun personalItemNeedsLinkedAccount() {
|
@Test fun personalItemNeedsALinkOnlyToSwitchPushOn() {
|
||||||
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
val personal = NotificationChannelItemDto(
|
||||||
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
|
id = "vendor.sale", personal = true, requiresLinkedAccount = true,
|
||||||
assertTrue(itemSelectable(personal, hasLinkedAccount = true))
|
channels = listOf("push", "inapp", "email"),
|
||||||
|
)
|
||||||
|
assertTrue(pushNeedsLink(personal, hasLinkedAccount = false))
|
||||||
|
assertFalse(pushNeedsLink(personal, hasLinkedAccount = true))
|
||||||
|
assertFalse(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
|
||||||
|
assertTrue(canSetMode(personal, CHANNEL_PUSH, "instant", hasLinkedAccount = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun generalItemIsAlwaysSelectable() {
|
@Test fun generalItemIsNeverHeldBack() {
|
||||||
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
val general = NotificationChannelItemDto(
|
||||||
assertTrue(itemSelectable(general, hasLinkedAccount = false))
|
id = "news.post", personal = false, requiresLinkedAccount = false, channels = listOf("push"),
|
||||||
|
)
|
||||||
|
assertFalse(pushNeedsLink(general, hasLinkedAccount = false))
|
||||||
|
assertTrue(canSetMode(general, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
|
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
|
||||||
|
|||||||
@@ -64,10 +64,36 @@ class NotificationSettingsViewModelTest {
|
|||||||
assertEquals(listOf("off", "instant"), push.modes)
|
assertEquals(listOf("off", "instant"), push.modes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun personalItemsStillNeedALinkedAccount() {
|
@Test fun aTriggerOnlyPersonalItemIsNotHeldBackAtAll() {
|
||||||
|
// Nothing can push it, so there is no push control for the link to hold,
|
||||||
|
// and the hint would describe a control that is not on the screen.
|
||||||
val personal = prefs().items.first { it.personal }
|
val personal = prefs().items.first { it.personal }
|
||||||
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false))
|
assertEquals(false, pushNeedsLink(personal, hasLinkedAccount = false))
|
||||||
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true))
|
assertEquals(true, canSetMode(personal, "email", "instant", hasLinkedAccount = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rust D69: a Rust site's raid alert ─────────────────────────────────
|
||||||
|
|
||||||
|
private val raid = NotificationChannelItemDto(
|
||||||
|
id = "rust.base.destroyed", label = "Your base was raided",
|
||||||
|
personal = true, requiresLinkedAccount = true,
|
||||||
|
channels = listOf("push", "inapp", "email"),
|
||||||
|
modes = mapOf("push" to "instant", "inapp" to "instant", "email" to "off"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun withALinkPushCanBeSwitchedOn() {
|
||||||
|
assertEquals(true, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun withoutALinkEveryChannelCanStillBeSwitchedOff() {
|
||||||
|
// Phase 10's criterion ends "and can be switched off there". Before phase
|
||||||
|
// 11 the whole row was disabled whenever the link check failed, and it
|
||||||
|
// always failed on a Rust site.
|
||||||
|
listOf(CHANNEL_PUSH, "inapp", "email").forEach { channel ->
|
||||||
|
assertEquals(channel, true, canSetMode(raid, channel, MODE_OFF, hasLinkedAccount = false))
|
||||||
|
}
|
||||||
|
assertEquals(true, canSetMode(raid, "inapp", "instant", hasLinkedAccount = false))
|
||||||
|
assertEquals(false, canSetMode(raid, CHANNEL_PUSH, "instant", hasLinkedAccount = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
|
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
|
||||||
|
|||||||
Reference in New Issue
Block a user