1 Commits

Author SHA1 Message Date
f6deeb9624 ci(sonarqube): add non-blocking SonarQube analysis on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m48s
Mirrors the website repo's setup: a source-based scan of app/src/main
(Kotlin) that reports to the self-hosted SonarQube server after merge,
never gating PRs. Project key runic-gateway-android-app.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:13:39 -05:00
213 changed files with 734 additions and 19121 deletions

3
.gitattributes vendored
View File

@@ -17,6 +17,3 @@ gradlew text eol=lf
*.png binary
*.webp binary
*.ico binary
# Bundled type families (res/font). `text=auto` already detects these as binary,
# but a font is too easy to corrupt silently to leave to a heuristic.
*.ttf binary

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,5 @@
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed,
# Gate every pull request into `main` on lint + unit tests + a debug build, so a
# broken build can't reach the deployable branch. Debug builds are auto-signed,
# so this gate needs no secrets. The signed *release* APK + Gitea release come
# later (release.yml, M6). See docs/android/PLAN.md §12.
#
@@ -18,14 +18,9 @@
name: PR Checks
# `edge` is here because a workstream that lands ten phase PRs onto it before one
# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is
# exactly what happened to all nine M12 phase PRs, and would have happened again
# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own
# PR, not inside the cutover window with a whole workstream's diff to bisect.
on:
pull_request:
branches: [main, edge]
branches: [main]
concurrency:
group: pr-checks-${{ github.ref }}

View File

@@ -8,7 +8,7 @@
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the
# Runic-Gateway-Android-app project (or a global one).
# runic-gateway-android-app project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.)
@@ -18,12 +18,11 @@
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
#
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
# reports 0% and the coverage gate fails despite the test suite existing. That
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
# the runner container is bare, so base tools are apt-installed first.
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
# enrichment" note in sonar-project.properties for wiring in Android Lint /
# coverage reports later.
name: SonarQube
@@ -41,16 +40,6 @@ jobs:
analysis:
runs-on: ubuntu-latest
steps:
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
# them) and we install JDK 17 from the Ubuntu archive rather than
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
# pr-checks.yml — see its header note.
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
@@ -58,31 +47,6 @@ jobs:
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
# variant (matches enableUnitTestCoverage) to keep peak memory down.
- name: Unit tests + JaCoCo coverage
run: |
chmod +x ./gradlew
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:

View File

@@ -1,111 +0,0 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/Android-app
DOCS_PATH: android/PROJECT_TREE.md
TREE_TITLE: Android App
ROOT_LABEL: android-app
PR_BRANCH: chore/sync-android-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

View File

@@ -48,15 +48,10 @@ any shard's website — there is no compiled-in API host.
## CI
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6.
**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch
before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what
happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is
unaffected: it is a push-on-`main` analysis, not a PR gate.
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
attached to a Gitea release comes at M6.
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:

View File

@@ -10,11 +10,6 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
jacoco
}
jacoco {
toolVersion = "0.8.12"
}
// Release signing material (PLAN.md §12) is never committed. It is read from, in
@@ -83,11 +78,6 @@ android {
}
buildTypes {
debug {
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
enableUnitTestCoverage = true
}
release {
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
// release APK). Keep rules live in proguard-rules.pro.
@@ -180,35 +170,3 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
// JVM unit tests can't execute composable bodies without Robolectric.
tasks.register<JacocoReport>("jacocoTestReport") {
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
reports {
xml.required.set(true)
html.required.set(true)
}
val coverageExcludes = listOf(
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
)
val buildDirFile = layout.buildDirectory.get().asFile
classDirectories.setFrom(
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
)
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
executionData.setFrom(
fileTree(buildDirFile) {
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
},
)
}

View File

@@ -1,93 +0,0 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,93 +0,0 @@
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Debug-only override of the main network_security_config.xml. Keeps the secure
base posture (no cleartext) but re-permits cleartext to loopback so debug builds
can reach a local website backend at http://127.0.0.1:3000 / http://localhost:3000
(ServerUrl allows plain HTTP only when allowInsecureHttp = BuildConfig.DEBUG).
Because the platform default already blocks cleartext at targetSdk 28+, this
domain-config is what actually makes the debug local-dev path work at runtime.
This file is compiled only into debug builds; release builds use the main
source set's config and permit no cleartext at all.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -20,7 +20,6 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RunicGateway">

View File

@@ -23,7 +23,6 @@ import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
import com.runicgateway.app.ui.AppViewModel.AppState
@@ -31,8 +30,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.RunicApp
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -40,8 +39,8 @@ import javax.inject.Inject
/**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
* connect screen until a shard site is configured (§3), then the main app.
* The Material theme is resolved from the shard's published appearance (M12),
* and asset-path resolution is provided to the whole tree.
* The Material theme is seeded from the per-shard brand accent, and asset-path
* resolution is provided to the whole tree.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -58,16 +57,9 @@ class MainActivity : ComponentActivity() {
// consumed once by RunicApp which navigates to the stream's screen.
private var pendingStream by mutableStateOf<String?>(null)
// The tickle's other half: an opaque ref, carried since M7 and read since
// ENGAGEMENT.md phase 8, where a `notification:<id>` ref means the engine wrote
// an inbox row and the tap should land there. Never rendered — it is a hint that
// something exists, and the app pulls the real item over the authenticated API.
private var pendingRef by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
handleSsoCallback(intent)
// Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme.
@@ -77,21 +69,9 @@ class MainActivity : ComponentActivity() {
val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle()
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
// resolved token map is applied field by field over the shipped palette,
// so NONE — before the site is connected, or when settings can't be
// read — is the app exactly as it shipped.
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
// The admin's theme and nav can change while the app is backgrounded
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
// re-validation RunicApp already does. Best-effort and silent.
LifecycleResumeEffect(Unit) {
appViewModel.refreshAppearance()
onPauseOrDispose { }
}
RunicGatewayTheme(appearance = appearance) {
RunicGatewayTheme(accent = accent) {
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -103,14 +83,10 @@ class MainActivity : ComponentActivity() {
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
RunicApp(
appearance = s.appearance,
brand = s.brand,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
deepLinkRef = pendingRef,
onDeepLinkConsumed = {
pendingStream = null
pendingRef = null
},
onDeepLinkConsumed = { pendingStream = null },
)
}
}
@@ -127,13 +103,7 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
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)
}
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
handleSsoCallback(intent)
}

View File

@@ -1,34 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.os.Build
import javax.inject.Inject
import javax.inject.Singleton
/**
* Supplies a friendly label for this device, sent as `device_name` at login so a
* trusted-device / active-session row is recognizable in the account lists
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
* `android.os.Build` and unit-testable on the JVM.
*/
fun interface DeviceNameProvider {
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
fun deviceName(): String?
}
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
@Singleton
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
override fun deviceName(): String? {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
val label = when {
model.isEmpty() -> manufacturer
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
else -> "$manufacturer $model"
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return label.take(100).ifBlank { null }
}
}

View File

@@ -1,65 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
* minted for so [tokenFor] only returns it for a matching login.
*
* The prefs handle is lazy so a device that never trusts pays the keystore cost
* only if a token is actually stored or read.
*/
@Singleton
class EncryptedTrustTokenStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TrustTokenStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun tokenFor(username: String): String? {
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
// Case-insensitive: usernames are matched case-insensitively server-side.
return if (owner.equals(username, ignoreCase = true)) token else null
}
override fun save(username: String, token: String) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_trust"
const val KEY_TOKEN = "trust_token"
const val KEY_USERNAME = "trust_username"
}
}

View File

@@ -16,15 +16,6 @@ data class SessionUser(
val role: Role,
) {
val isPlayer: Boolean get() = role == Role.PLAYER
/** Any staff role (moderator/editor/admin) — the staff-operations surface (§1, M10). */
val isStaff: Boolean get() = role.isStaff
/** Admin or moderator — moderation actions + the support queue (`modAccess`). */
val isModerator: Boolean get() = role == Role.ADMIN || role == Role.MODERATOR
/** Admin only — site-mode and other `adminOnly` controls. */
val isAdmin: Boolean get() = role == Role.ADMIN
}
/**

View File

@@ -1,31 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
/**
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
* token skips the TOTP step on its next login (never the password).
*
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
* the token must **survive logout and a dead-refresh sign-out**, because it is only
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
* Clearing it there would make the feature a no-op. It is scoped to the username it
* was minted for so it is never replayed for a different account on a shared device,
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
*
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
*/
interface TrustTokenStore {
/** The stored trust token for [username], or null if this device isn't trusted for them. */
fun tokenFor(username: String): String?
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
fun save(username: String, token: String)
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
fun clear()
}

View File

@@ -1,61 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [PendingSsoStore] backed by Jetpack Security's [EncryptedSharedPreferences]
* (Tink/AES-256-GCM), so the PKCE verifier is encrypted at rest for the brief
* window a flow is in progress. Separate prefs file from the session token store —
* this holds only the transient SSO handshake, cleared as soon as the callback is
* consumed. Lazy, so a device that never signs in via SSO pays no keystore cost.
*/
@Singleton
class EncryptedPendingSsoStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : PendingSsoStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun save(state: String, verifier: String) {
prefs.edit()
.putString(KEY_STATE, state)
.putString(KEY_VERIFIER, verifier)
.apply()
}
override fun load(): PendingSso? {
val state = prefs.getString(KEY_STATE, null) ?: return null
val verifier = prefs.getString(KEY_VERIFIER, null) ?: return null
return PendingSso(state = state, verifier = verifier)
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_sso_pending"
const val KEY_STATE = "state"
const val KEY_VERIFIER = "verifier"
}
}

View File

@@ -1,24 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
/**
* Persists the in-flight SSO `{state, verifier}` (PKCE Layer B + CSRF state) across
* the Custom-Tab round trip so the exchange survives process death — a low-memory
* device can evict the app while the Custom Tab is foreground, and the callback then
* returns to a fresh process (PLAN.md §4.2). Kept behind an interface so
* [SsoAuthManager] stays framework-free and unit-tests on the JVM with a fake.
*
* Exactly one flow is pending at a time; [save] overwrites any prior. The verifier
* is a bearer-equivalent secret for the one-time code, so the production impl
* ([EncryptedPendingSsoStore]) encrypts it at rest, mirroring the token store.
*/
interface PendingSsoStore {
fun save(state: String, verifier: String)
fun load(): PendingSso?
fun clear()
}
/** The stashed CSRF state + PKCE verifier for the current SSO attempt. */
data class PendingSso(val state: String, val verifier: String)

View File

@@ -5,7 +5,6 @@ package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
@@ -14,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
@@ -34,23 +34,20 @@ import javax.inject.Singleton
* parses the callback `Uri` (the Android edge) and hands the raw params here,
* so this class stays free of framework types and unit-tests on the JVM.
*
* The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at
* rest), so the exchange survives the process being evicted while the Custom Tab is
* foreground — the callback can land in a fresh process and still complete. It is
* cleared the moment [complete] consumes it, so a lost/duplicate callback still
* **fails closed** as [Failure.STATE_MISMATCH] rather than double-exchanging.
* The pending `{state, verifier}` lives only in memory: if the process is killed
* while the Custom Tab is foreground it is lost and the exchange **fails closed**
* (the user simply retries) — never a security downgrade.
*
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
* activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a
* ViewModel/activity recreation while the Custom Tab is open cannot drop a result.
* activity's coroutine scope after a deep link. The pending holder is an
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation
* while the Custom Tab is open cannot drop a result.
*/
@Singleton
class SsoAuthManager @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
private val trustTokenStore: TrustTokenStore,
) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
@@ -78,6 +75,10 @@ class SsoAuthManager @Inject constructor(
data class Failed(val reason: Failure) : Outcome
}
private data class Pending(val state: String, val verifier: String)
private val pending = AtomicReference<Pending?>(null)
/**
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
@@ -96,16 +97,16 @@ class SsoAuthManager @Inject constructor(
/**
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
* PKCE verifier + CSRF state (persisted so it survives process death). Returns
* null when no shard site is configured yet. Also resets [outcome] to
* [Outcome.Idle] so a stale prior result can't fire against the new attempt.
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet
* (the caller then keeps the website hand-off fallback). Also resets [outcome]
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt.
*/
fun buildStartUrl(providerId: String): String? {
val base = baseUrlHolder.current ?: return null
val verifier = Pkce.newVerifier()
val challenge = Pkce.challengeOf(verifier)
val state = Pkce.newState()
pendingStore.save(state = state, verifier = verifier)
pending.set(Pending(state = state, verifier = verifier))
_outcome.value = Outcome.Idle
return base.newBuilder()
.addPathSegments("api/v1/auth/mobile/sso/start")
@@ -157,8 +158,7 @@ class SsoAuthManager @Inject constructor(
* single-uses the code).
*/
suspend fun complete(state: String?, code: String?, error: String?) {
val stashed = pendingStore.load()
pendingStore.clear()
val stashed = pending.getAndSet(null)
// CSRF: the callback must echo the exact state we generated at /start.
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
@@ -195,14 +195,6 @@ class SsoAuthManager @Inject constructor(
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
// The user ticked "trust this device" on the TOTP form inside the Custom
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
// the token the exchange handed back is what lets a native PASSWORD login
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
// username exactly like the password path, so it is never replayed for a
// different account on a shared device. Saved BEFORE onSignedIn so a
// process death mid-callback can't lose it.
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success
return

View File

@@ -1,74 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.inbox
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.runicgateway.app.data.api.dto.NotificationItemDto
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
private val Context.inboxDataStore: DataStore<Preferences> by preferencesDataStore(name = "inbox")
/**
* [InboxCache] over the same plain DataStore the push state uses. Not secret —
* tokens stay in the encrypted store — but an inbox body is a person's own
* notifications, which is why the snapshot is owner-scoped and cleared on
* sign-out rather than left lying about.
*/
@Singleton
class DataStoreInboxCache @Inject constructor(
@param:ApplicationContext private val context: Context,
private val json: Json,
) : InboxCache {
private val store = context.inboxDataStore
override suspend fun read(owner: String): InboxCache.Snapshot? {
val raw = store.data.first()[KEY_SNAPSHOT] ?: return null
val stored = try {
json.decodeFromString(Stored.serializer(), raw)
} catch (_: Exception) {
// A snapshot this build can't parse is a snapshot from an older one;
// dropping it silently is right — it will be rewritten on the next pull.
return null
}
if (stored.owner != owner) return null
return InboxCache.Snapshot(items = stored.items, unread = stored.unread, savedAt = stored.savedAt)
}
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
val payload = Stored(
owner = owner,
items = items.take(InboxCache.MAX_ITEMS),
unread = unread,
savedAt = System.currentTimeMillis(),
)
store.edit { it[KEY_SNAPSHOT] = json.encodeToString(Stored.serializer(), payload) }
}
override suspend fun clear() {
store.edit { it.remove(KEY_SNAPSHOT) }
}
@Serializable
private data class Stored(
val owner: String,
val items: List<NotificationItemDto>,
val unread: Int,
val savedAt: Long,
)
private companion object {
val KEY_SNAPSHOT = stringPreferencesKey("snapshot")
}
}

View File

@@ -1,65 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.inbox
import com.runicgateway.app.data.api.dto.NotificationItemDto
/**
* The inbox's offline snapshot (ENGAGEMENT.md phase 8).
*
* **PLAN.md §7 decided the app ships no Room cache, and that decision stands** —
* this is its one named exception, settled with the org lead 2026-08-31. The
* inbox is a short, read-only, newest-first list with a server-side cursor and no
* joins, so what "works offline" needs is the newest page and the badge, not a
* database: one JSON blob in the DataStore the push code already uses. Nothing
* here is a source of truth — a successful pull always replaces it, and the
* screen says out loud when it is showing this instead.
*
* **The [owner] key is the security property, not a convenience.** A snapshot is
* written under the base URL *and* the account id that produced it and is only
* ever handed back to that exact pair, so a cache cannot survive into another
* account or another shard — including the sign-out paths that never reach
* [clear] at all (a dead refresh token, a server switch). Clearing on logout is
* the tidy-up; this is what makes it safe.
*
* An interface for the same reason [com.runicgateway.app.core.auth.TokenStore] is
* one: the storage needs a `Context` and the view models that use it should be
* testable without one.
*/
interface InboxCache {
/**
* What was cached for [owner], or null when nothing was — including when the
* stored snapshot belongs to a different account or shard, which is the same
* answer on purpose.
*/
suspend fun read(owner: String): Snapshot?
/**
* Replace the snapshot with the newest page.
*
* Only the FIRST page is ever cached, capped at [MAX_ITEMS]: an offline inbox
* is there so the last things you were told are still readable on a train, not
* so the whole history is. Later pages come from the server or not at all.
*/
suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int)
/** Forget everything. Called on sign-out, alongside the push deregistration. */
suspend fun clear()
/** What the screen renders from while offline, with the time it was captured. */
data class Snapshot(
val items: List<NotificationItemDto>,
val unread: Int,
val savedAt: Long,
)
companion object {
/** The server's own default page size — caching more than it sends is pointless. */
const val MAX_ITEMS = 30
/** The (shard, account) a snapshot belongs to. */
fun ownerKey(baseUrl: String?, userId: Long): String = "${baseUrl.orEmpty()}|$userId"
}
}

View File

@@ -1,16 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import kotlinx.coroutines.flow.Flow
/**
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
* can then be unit-tested against a fake stream instead of a real network connection.
*/
interface ShardStream {
fun events(): Flow<ShardStreamEvent>
}

View File

@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
baseClient: OkHttpClient,
private val baseUrlHolder: BaseUrlHolder,
private val json: Json,
) : ShardStream {
) {
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
// so the read timeout must be disabled or the idle stream would be killed.
private val sseClient: OkHttpClient = baseClient.newBuilder()
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
* `{ kind, … }` payload the boards merge in place.
*/
override fun events(): Flow<ShardStreamEvent> = channelFlow {
fun events(): Flow<ShardStreamEvent> = channelFlow {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
val url = baseUrlHolder.current?.resolve(STREAM_PATH)

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import retrofit2.HttpException
import java.io.IOException
@@ -38,16 +37,6 @@ inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (th
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
* Coroutine cancellation is rethrown so structured concurrency still works — it
* is control flow, not a network failure.
*
* A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g.
* a live-shaped `guild.update` snapshot carrying an unexpected value) throws a
* [SerializationException] out of the Retrofit converter. That is a broken contract
* with the backend, not a bug to crash on: the request completed but the response is
* unusable — an invalid upstream response — so it is surfaced as a server-side error
* (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry",
* exactly the graceful-degradation the layer promises (never throw for an expected
* failure). Without this catch the exception escapes the collecting coroutine and
* takes down the whole app.
*/
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Ok(block())
@@ -57,9 +46,4 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.HttpError(e.code(), e.message())
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: SerializationException) {
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
}
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
private const val MALFORMED_RESPONSE_STATUS = 502

View File

@@ -1,41 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.time
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
/**
* Parse a timestamp off the wire, in either shape the backend sends.
*
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
* columns, and one 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**,
* because that is what the server stores — reading it as local time would
* silently shift every timestamp by the device's offset, which is a bug that
* looks right on the machine it was written on.
*
* Anything unparseable answers null, and every caller is expected to render
* *something* without it: a notification with an odd date is still worth reading,
* and an event with one is still worth listing.
*
* Lives here rather than beside either caller because the trap is the wire's, not
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
* inherit it (EVENTS.md §I).
*/
fun parseWireInstant(raw: String?): Instant? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
return try {
Instant.parse(text)
} catch (_: Exception) {
try {
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
} catch (_: Exception) {
null
}
}
}

View File

@@ -26,8 +26,12 @@ class WebsiteUrls @Inject constructor(
/** Forgot / reset password (the flow built on the backend before app work, §8). */
fun forgotPassword(): String? = resolve(FORGOT)
/** The website login page — carries the SSO provider buttons (§4.2). */
fun login(): String? = resolve(LOGIN)
private companion object {
const val REGISTER = "account/register"
const val FORGOT = "account/forgot"
const val LOGIN = "account/login"
}
}

View File

@@ -1,97 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
/**
* The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On
* the authed client — every call carries the bearer, and the backend re-checks the
* caller's role on every request (`staffOnly` / `modAccess` / `adminOnly`), so a
* demoted user is refused server-side even if a stale menu still showed the entry.
*
* Grows one group at a time (dashboard first); moderation, support, and content
* endpoints are added with their screens.
*/
interface AdminApi {
/** `GET /admin/dashboard` — summary counts + site mode (any staff role). */
@GET("api/v1/admin/dashboard")
suspend fun dashboard(): AdminDashboardDto
/** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
@PUT("api/v1/admin/site-mode")
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
// ── Content: news posts (any staff role) ──────────────────────────────
@GET("api/v1/admin/posts")
suspend fun posts(): List<AdminPostDto>
@POST("api/v1/admin/posts")
suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto
@PATCH("api/v1/admin/posts/{id}/publish")
suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto
@DELETE("api/v1/admin/posts/{id}")
suspend fun deletePost(@Path("id") id: Long): Response<Unit>
// ── Content: wiki taxonomy (any staff role) ───────────────────────────
@GET("api/v1/admin/wiki/categories")
suspend fun wikiCategories(): List<AdminWikiCategoryDto>
@POST("api/v1/admin/wiki/categories")
suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto
@DELETE("api/v1/admin/wiki/categories/{id}")
suspend fun deleteWikiCategory(@Path("id") id: Long): Response<Unit>
@GET("api/v1/admin/wiki/tags")
suspend fun wikiTags(): List<AdminWikiTagDto>
// ── Moderation: shard write plane (admin/moderator) ───────────────────
@POST("api/v1/admin/shard/kick")
suspend fun kick(@Body body: KickRequest): Response<Unit>
@POST("api/v1/admin/shard/ban")
suspend fun ban(@Body body: BanRequest): Response<Unit>
@POST("api/v1/admin/shard/unban")
suspend fun unban(@Body body: UnbanRequest): Response<Unit>
@POST("api/v1/admin/shard/broadcast")
suspend fun broadcast(@Body body: BroadcastRequest): Response<Unit>
// ── Support queue: help pages (admin/moderator) ───────────────────────
@GET("api/v1/admin/shard/pages")
suspend fun supportPages(): List<SupportPageDto>
@POST("api/v1/admin/shard/pages/{id}/respond")
suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response<Unit>
@POST("api/v1/admin/shard/pages/{id}/close")
suspend fun closePage(@Path("id") id: String): Response<Unit>
}

View File

@@ -10,7 +10,6 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST
@@ -28,15 +27,9 @@ import retrofit2.http.POST
interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
// header entirely when it is null, so an untrusted device sends nothing.
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login")
suspend fun login(
@Body body: MobileLoginRequest,
@Header("X-Trust-Token") trustToken: String? = null,
): Response<MobileTokenResponse>
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>

View File

@@ -1,84 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryDto
import com.runicgateway.app.data.api.dto.EventSeriesResponse
import com.runicgateway.app.data.api.dto.PublicEventResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
*
* **These are CORE routes, not a module's**, which is why they live here rather
* than beside the shard reads in [PublicApi]: they exist on a backend
* with no game module installed at all, and they are gated by core's own `events`
* capability rather than by a module's. Nothing here is under `/shard`.
*
* The three public reads and the one player read share an interface for the same
* reason the website mounts them in one feature: the history row's whole purpose
* is to link back to the public page. The player call carries a bearer through
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
* call; there is one Retrofit.
*/
interface EventsApi {
/**
* The public calendar. Defaults to now through 31 days out when neither end
* is named; the window may span at most 92 days and the server 400s past it.
*
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
* in the answer, so there is nothing here to re-check.
*/
@GET("api/v1/public/events")
suspend fun getCalendar(
@Query("from") from: String? = null,
@Query("to") to: String? = null,
@Query("seriesId") seriesId: Long? = null,
): EventCalendarDto
/**
* One event.
*
* **[run] selects which occurrence the results table is about**, and is what
* an announcement's link carries: the page lives at the definition's slug, so
* a weekly event has one address that survives a retitle, while every
* `event.` trigger is about one occurrence. A run belonging to some other
* event is ignored rather than refused, so a stale link in a months-old mail
* still opens the page it was about.
*
* A draft, an archived definition and an unlisted one all answer 404,
* indistinguishable from a slug that never existed.
*/
@GET("api/v1/public/events/{slug}")
suspend fun getEvent(
@Path("slug") slug: String,
@Query("run") run: String? = null,
): PublicEventResponse
/**
* One arc. A series with no listed events answers 404 rather than an empty
* page — an arc is a label on its definitions, so a page for an empty one
* would publish that an operator has named something they have not announced.
*/
@GET("api/v1/public/events/series/{slug}")
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
/**
* The caller's own participation history. Self-scoped on the session's user
* id server-side; there is deliberately no id parameter here, because there
* is none on the route.
*
* [before] is a participation row id, not an offset — the list gains rows at
* the top as the reader attends things.
*/
@GET("api/v1/player/events/history")
suspend fun getHistory(
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
): EventHistoryDto
}

View File

@@ -7,21 +7,11 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.PATCH
@@ -62,28 +52,4 @@ interface MeApi {
// path template explicit alongside the provider argument.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
@GET("api/v1/auth/me/trusted-devices")
suspend fun trustedDevices(): List<TrustedDeviceDto>
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
// which a thrown HttpException would discard.
@POST("api/v1/auth/me/trusted-devices")
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
@DELETE("api/v1/auth/me/trusted-devices/{id}")
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
@DELETE("api/v1/auth/me/trusted-devices")
suspend fun revokeAllTrustedDevices(): RevokedCountDto
// ── Recovery (backup) codes ──────────────────────────────────────────────
@GET("api/v1/auth/me/account/recovery-codes/status")
suspend fun recoveryCodesStatus(): RecoveryStatusDto
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
}

View File

@@ -3,13 +3,8 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
import retrofit2.http.Body
@@ -18,13 +13,10 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
/**
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
* registration, per-user stream subscriptions, the per-channel preferences that
* supersede them (ENGAGEMENT.md phase 3), and the in-app **inbox** — the first
* of these that carries content rather than a preference (phase 7/8). Every call rides the
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
* (endpoint) registration and per-user stream subscriptions. Every call rides the
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
* registration only ever succeeds while signed in.
@@ -48,41 +40,4 @@ interface NotificationsApi {
@PUT("api/v1/auth/me/notifications/subscriptions")
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────
//
// The superset of the two calls above: `notification_subscriptions` is now
// the push projection of this table and the server fans every write to
// either one into the other, so the two cannot disagree.
@GET("api/v1/auth/me/notifications/channels")
suspend fun channelPrefs(): NotificationChannelPrefsDto
/** SPARSE — send only the pairs that changed; everything unnamed is untouched. */
@PUT("api/v1/auth/me/notifications/channels")
suspend fun putChannelPrefs(
@Body body: NotificationChannelPrefsUpdateDto,
): NotificationChannelPrefsDto
// ── The inbox (ENGAGEMENT.md phase 7/8) ────────────────────────────────
//
// Keyset-paged on `before`, never an offset. There is no way to name another
// user on any of these: the caller is the only account they can read or write.
@GET("api/v1/auth/me/notifications")
suspend fun inbox(
@Query("limit") limit: Int? = null,
@Query("before") before: Long? = null,
@Query("unread") unread: Boolean? = null,
): NotificationInboxDto
@GET("api/v1/auth/me/notifications/unread-count")
suspend fun unreadCount(): NotificationUnreadDto
/** Idempotent; 404 both for a missing item and for another account's. */
@POST("api/v1/auth/me/notifications/{id}/read")
suspend fun markRead(@Path("id") id: Long): NotificationReadResultDto
@POST("api/v1/auth/me/notifications/read-all")
suspend fun markAllRead(): NotificationReadResultDto
}

View File

@@ -3,9 +3,6 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.AtlasMetaDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
@@ -15,18 +12,11 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.ModulesDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -68,18 +58,6 @@ interface PublicApi {
@GET("api/v1/public/settings")
suspend fun getSettings(): SettingsDto
/**
* Which modules this backend is serving, and the capabilities each declares
* (§5, M13). Read together with the `version` block's own `capabilities` —
* core's list and a module's are separate lists on purpose.
*
* This is what lets the app tell a module that is **not installed** from a
* lookup that failed: `/public/shard/features` 404s in both cases, and only
* this call distinguishes them.
*/
@GET("api/v1/public/modules")
suspend fun getModules(): ModulesDto
// ── News & content ───────────────────────────────────────────────────
@GET("api/v1/public/posts/{category}")
suspend fun getPosts(@Path("category") category: String): List<PostDto>
@@ -115,14 +93,6 @@ interface PublicApi {
suspend fun postContact(@Body body: ContactRequest): ContactResponse
// ── Public shard widgets (§6.2) ──────────────────────────────────────
/**
* Which shard features this caller may reach, so the menu hides entries instead
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
* call and a signed-in one can differ.
*/
@GET("api/v1/public/shard/features")
suspend fun getShardFeatures(): ShardFeaturesDto
@GET("api/v1/public/shard/status")
suspend fun getShardStatus(): ShardStatusDto
@@ -158,65 +128,4 @@ interface PublicApi {
@GET("api/v1/public/shard/houses")
suspend fun getShardHouses(): List<HouseDto>
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
//
// Each of these sits behind the website's `requireFeature` gate: a 404 means the
// shard doesn't publish it and a 403 means this viewer is below its audience rung,
// which `toShardUiState()` folds into one "not available here" state.
/** The shard's configured ruleset. A `null` body means "not published yet". */
@GET("api/v1/public/shard/ruleset")
suspend fun getShardRuleset(): RulesetDto?
/** Every points/loyalty leaderboard the shard publishes. */
@GET("api/v1/public/shard/points")
suspend fun getShardPoints(): List<PointsBoardDto>
@GET("api/v1/public/shard/points/{system}")
suspend fun getShardPointsBoard(@Path("system") system: String): PointsBoardDto
/**
* Search the player-vendor index. **Rate-limited** — the first genuinely expensive
* public endpoint on the site, so handle `429` (`ErrorKind.RATE_LIMITED`).
*/
@GET("api/v1/public/shard/market")
suspend fun getShardMarket(
@Query("q") query: String? = null,
@Query("minPrice") minPrice: Long? = null,
@Query("maxPrice") maxPrice: Long? = null,
@Query("map") map: String? = null,
@Query("region") region: String? = null,
@Query("sort") sort: String? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): MarketPageDto
/** Index size, staleness, and which facets/regions actually hold vendors. */
@GET("api/v1/public/shard/market/meta")
suspend fun getShardMarketMeta(): MarketMetaDto
@GET("api/v1/public/shard/market/vendors/{serial}")
suspend fun getShardMarketVendor(
@Path("serial") serial: String,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): MarketVendorDto
// The atlas lives under /public/atlas, NOT /public/shard: it is static shard
// content parsed from the server's data files, so it stays readable while the
// shard is down — but it IS site-mode gated, unlike the shard routes.
@GET("api/v1/public/atlas/creatures")
suspend fun getAtlasCreatures(
@Query("q") query: String? = null,
@Query("facet") facet: String? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): AtlasCreaturePageDto
@GET("api/v1/public/atlas/creatures/{slug}")
suspend fun getAtlasCreature(@Path("slug") slug: String): AtlasCreatureDto
@GET("api/v1/public/atlas/meta")
suspend fun getAtlasMeta(): AtlasMetaDto
}

View File

@@ -55,78 +55,9 @@ data class TotpSetupDto(
@Serializable
data class TotpCodeRequest(val code: String)
/**
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
* app shows them for the user to save and never persists them.
*/
/** Result of enabling/disabling 2FA. */
@Serializable
data class TotpStateDto(
val totp_enabled: Boolean = false,
val recoveryCodes: List<String>? = null,
)
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
/**
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
* strings shown as-is (advisory display).
*/
@Serializable
data class TrustedDeviceDto(
val id: Long = 0,
val platform: String? = null,
val deviceName: String? = null,
val userAgent: String? = null,
val createdAt: String? = null,
val lastUsedAt: String? = null,
val expiresAt: String? = null,
)
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
@Serializable
data class TrustDeviceRequest(val deviceName: String? = null)
/**
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
*/
@Serializable
data class TrustDeviceResultDto(
val trusted: Boolean = false,
val trustToken: String? = null,
)
/**
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
* the app lists [devices] and asks the user to revoke one, then retry.
*/
@Serializable
data class TrustedDeviceLimitDto(
val error: String? = null,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
@Serializable
data class RevokedFlagDto(val revoked: Boolean = false)
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
@Serializable
data class RecoveryGenerateRequest(val currentPassword: String? = null)
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
data class TotpStateDto(val totp_enabled: Boolean = false)
/** A linked external identity (`GET /auth/me/account/identities`). */
@Serializable

View File

@@ -1,179 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Wire shapes for the M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md
* §1, §6.4). These are consumed only by the staff screens (dashboard, moderation,
* support, content); every DTO ignores unknown keys (NetworkModule's lenient Json)
* so additive backend fields stay safe. Nothing here is auto-provisioned or secret.
*/
/** `GET /admin/dashboard` — the staff landing summary. */
@Serializable
data class AdminDashboardDto(
@SerialName("site_mode") val siteMode: String = "live",
@SerialName("last_change") val lastChange: SiteModeChangeDto = SiteModeChangeDto(),
val counts: AdminCountsDto = AdminCountsDto(),
@SerialName("recent_activity") val recentActivity: List<AdminActivityDto> = emptyList(),
)
@Serializable
data class SiteModeChangeDto(
val at: String? = null,
val by: String? = null,
)
@Serializable
data class AdminCountsDto(
/** Post counts keyed by DB category (`news`, `five_on_friday`, …). */
val posts: Map<String, Int> = emptyMap(),
val users: Int = 0,
)
/** One row of the recent admin-activity log. `detail` is provider-shaped JSON. */
@Serializable
data class AdminActivityDto(
val id: Long = 0,
val username: String? = null,
val action: String = "",
val detail: JsonElement? = null,
@SerialName("created_at") val createdAt: String? = null,
)
/** `PUT /admin/site-mode` request + response. */
@Serializable
data class SiteModeRequest(val mode: String)
@Serializable
data class SiteModeStateDto(
@SerialName("site_mode") val siteMode: String = "live",
@SerialName("changed_at") val changedAt: String? = null,
@SerialName("changed_by") val changedBy: String? = null,
)
// ── Content: news posts ───────────────────────────────────────────────────
/**
* A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the
* public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished].
*/
@Serializable
data class AdminPostDto(
val id: Long,
val category: String = "",
val title: String = "",
val slug: String? = null,
val excerpt: String? = null,
val body: String? = null,
@SerialName("image_url") val imageUrl: String? = null,
val published: Int = 0,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
) {
val isPublished: Boolean get() = published != 0
}
/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps
* (news | five-on-friday | newsletter | screenshots). */
@Serializable
data class PostCreateRequest(
val category: String,
val title: String,
val excerpt: String? = null,
val body: String? = null,
@SerialName("image_url") val imageUrl: String? = null,
val published: Boolean = false,
)
/** `PATCH /admin/posts/:id/publish` body. */
@Serializable
data class PublishRequest(val published: Boolean)
// ── Content: wiki taxonomy ────────────────────────────────────────────────
/** A wiki category from `GET /admin/wiki/categories` (with page counts). */
@Serializable
data class AdminWikiCategoryDto(
val id: Long,
val slug: String = "",
val title: String = "",
val description: String? = null,
@SerialName("sort_order") val sortOrder: Int? = null,
@SerialName("page_count") val pageCount: Int? = null,
@SerialName("published_count") val publishedCount: Int? = null,
)
/** `POST /admin/wiki/categories` body. */
@Serializable
data class WikiCategoryRequest(
val slug: String,
val title: String,
val description: String? = null,
@SerialName("sort_order") val sortOrder: Int? = null,
)
/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */
@Serializable
data class AdminWikiTagDto(
val id: Long,
val slug: String = "",
val label: String = "",
@SerialName("published_count") val publishedCount: Int? = null,
)
// ── Moderation (admin/moderator; shard write plane) ───────────────────────
/** `POST /admin/shard/kick` — at least one of account/serial. */
@Serializable
data class KickRequest(val account: String? = null, val serial: String? = null)
/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */
@Serializable
data class BanRequest(
val account: String? = null,
val serial: String? = null,
@SerialName("durationSec") val durationSec: Long? = null,
val reason: String? = null,
)
/** `POST /admin/shard/unban`. */
@Serializable
data class UnbanRequest(val account: String)
/** `POST /admin/shard/broadcast` — a system message to everyone online. */
@Serializable
data class BroadcastRequest(val text: String, val hue: Int? = null)
// ── Support queue (admin/moderator; help pages) ───────────────────────────
/**
* One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId`
* is the sender's in-game serial (the `:id` for respond/close). Permissive — the
* shard-state fields beyond these (coords, timing) are ignored.
*/
@Serializable
data class SupportPageDto(
@SerialName("pageId") val pageId: String = "",
val type: String? = null,
val message: String? = null,
val handled: Boolean? = null,
val handler: String? = null,
val sender: SupportActorDto? = null,
)
/** The page's sender (actor object); [account] present when the character is linked. */
@Serializable
data class SupportActorDto(
val name: String? = null,
val account: String? = null,
)
/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */
@Serializable
data class PageRespondRequest(val message: String, val close: Boolean = false)

View File

@@ -12,23 +12,12 @@ import kotlinx.serialization.Serializable
* safe (§8, recorded for M1).
*/
/**
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
* future logins skip the second factor — on success the response carries a
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
* [device_name] labels the resulting trusted-device / session row (snake_case to
* match the backend field exactly).
*/
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
@Serializable
data class MobileLoginRequest(
val username: String,
val password: String,
val code: String? = null,
val recoveryCode: String? = null,
val trustDevice: Boolean? = null,
val device_name: String? = null,
)
/** `POST /auth/mobile/refresh` body. */
@@ -45,11 +34,6 @@ data class MobileLogoutRequest(
/**
* Success payload from login and refresh: the token pair, the access lifetime
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
*
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
* [trustToken] is the opaque token to persist + replay (present only when the trust
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
* (the login itself still succeeded). Refresh never sets these.
*/
@Serializable
data class MobileTokenResponse(
@@ -57,9 +41,6 @@ data class MobileTokenResponse(
val refreshToken: String,
val expiresIn: String? = null,
val user: SafeUserDto,
val trustToken: String? = null,
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */

View File

@@ -1,212 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
* Phase 14a; the app's half is M13). Field names match
* `server/src/model/events/eventPublic.model.js` exactly.
*
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
* the server side is spread into a public entry — a field reaches one because a
* line put it there — so three things are absent from every shape below and each
* absence is a decision core made: the **spec** (phases, steps, actions and their
* params are the operator's plan for changing a live world; a visitor gets the
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
* and errors** (facts about the deployment's plumbing, not about the event), and
* **`member_key`** (module-opaque, so core cannot say what publishing one would
* disclose). Adding a field here that the server does not send would decode to a
* default and render as a fact.
*
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
* backend field is safe.
*/
/**
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
* differently on purpose.
*
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
* what it says is committed to. A **projected** entry is arithmetic past the
* materialisation horizon — a forecast with nothing behind it — so the screen
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
* only ever arrive on a projection, and say a DST shift moved it.
*
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
*/
@Serializable
data class EventCalendarEntryDto(
val kind: String = "run",
val title: String = "",
val slug: String = "",
val seriesName: String? = null,
val seriesSlug: String? = null,
val scheduledFor: String = "",
val timezone: String? = null,
val status: String = "scheduled",
val live: Boolean = false,
val adjusted: Boolean = false,
val shiftMinutes: Int = 0,
) {
/** True for a forecast the server has committed nothing to. */
val isProjected: Boolean get() = kind == "projected"
}
/** `GET /public/events` — the calendar for a window, ascending by instant. */
@Serializable
data class EventCalendarDto(
val entries: List<EventCalendarEntryDto> = emptyList(),
/** True when the server capped the answer; the screen says so rather than lying by omission. */
val truncated: Boolean = false,
)
/**
* One occurrence on an event's page.
*
* [phase] is the label of the phase a live run is in, resolved from the version
* that run PINNED — so an edit since does not relabel a run in flight. It is null
* on anything that is not live, which is why the screen only ever shows it there.
*/
@Serializable
data class EventOccurrenceDto(
val runId: Long = 0,
val scheduledFor: String = "",
val timezone: String? = null,
val startedAt: String? = null,
val endedAt: String? = null,
val status: String = "scheduled",
val live: Boolean = false,
val scope: String? = null,
val phase: String? = null,
val resultsPublishedAt: String? = null,
)
/**
* One row of a published results table.
*
* [name] is whatever the module put in its participation `meta`, and there is
* genuinely nothing else to render when it is absent: core has no name for a
* character and the member key is not published, so the screen says "Unnamed"
* rather than inventing one.
*
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
* writes a fraction — the live walk found `318.5` in the first row it read.
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
* response fails to decode, and the screen reports a server error for a `200`.
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
*/
@Serializable
data class EventParticipantDto(
val name: String? = null,
val score: Double = 0.0,
val rank: Int? = null,
)
/** The results table for ONE occurrence, present only once it has been published. */
@Serializable
data class EventResultsDto(
val runId: Long = 0,
val scheduledFor: String = "",
val publishedAt: String? = null,
val participants: List<EventParticipantDto> = emptyList(),
)
/** The arc an event belongs to, as its own page names it. */
@Serializable
data class EventSeriesRefDto(
val name: String = "",
val slug: String = "",
)
/** `GET /public/events/:slug` — the event. */
@Serializable
data class PublicEventDto(
val title: String = "",
val slug: String = "",
val summary: String? = null,
/** Sanitized HTML, written the way a wiki page and a forum post are. */
val body: String? = null,
val imageUrl: String? = null,
val timezone: String? = null,
val series: EventSeriesRefDto? = null,
val live: Boolean = false,
val current: EventOccurrenceDto? = null,
/**
* The next occurrence — **narrower than the first of [upcoming]**, and the
* server decides which. A cancelled occurrence still appears under what is
* coming, because "next Friday is off" is what somebody checking a calendar
* came to find out; it is not what "next" means.
*/
val next: EventOccurrenceDto? = null,
val upcoming: List<EventOccurrenceDto> = emptyList(),
val past: List<EventOccurrenceDto> = emptyList(),
val results: EventResultsDto? = null,
)
/** The envelope `GET /public/events/:slug` answers with. */
@Serializable
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
/** One event as an arc lists it — the editor's order, so no dates. */
@Serializable
data class EventSeriesEntryDto(
val title: String = "",
val slug: String = "",
val summary: String? = null,
val imageUrl: String? = null,
)
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
@Serializable
data class EventSeriesDto(
val name: String = "",
val slug: String = "",
val description: String? = null,
val events: List<EventSeriesEntryDto> = emptyList(),
)
/** The envelope `GET /public/events/series/:slug` answers with. */
@Serializable
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
/**
* One row of the caller's own participation history.
*
* [rank] is null until `core.results.publish` ran for that occurrence, and that
* is a real state rather than an error — the screen says "not published" rather
* than rendering a dash that reads as a bug.
*
* [id] is the participation row's own id and is what the keyset page walks back
* on: the list gains a row every time the reader attends something, so an offset
* would skip and repeat.
*/
@Serializable
data class EventHistoryEntryDto(
val id: Long = 0,
val runId: Long = 0,
val title: String = "",
val slug: String = "",
val seriesName: String? = null,
val seriesSlug: String? = null,
val scheduledFor: String = "",
val startedAt: String? = null,
val endedAt: String? = null,
val timezone: String? = null,
val status: String = "scheduled",
val joinedAt: String? = null,
// Fractional, for the reason [EventParticipantDto.score] gives.
val score: Double = 0.0,
val rank: Int? = null,
val resultsPublishedAt: String? = null,
)
/** `GET /player/events/history` — self-scoped, one page. */
@Serializable
data class EventHistoryDto(
val entries: List<EventHistoryEntryDto> = emptyList(),
)

View File

@@ -60,151 +60,8 @@ data class NotificationStreamsDto(
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
* echoed back.
*
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
* backend validator requires the `streams` field (`body('streams').isArray()`).
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
* of `emptyList()` would drop the field when the user clears their LAST subscription,
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
*/
@Serializable
data class NotificationSubscriptionsDto(
val streams: List<String>,
)
// ── The in-app channel (ENGAGEMENT.md phase 7/8) ───────────────────────────
//
// The inbox is the first notification surface that carries CONTENT. Everything
// above is a preference or a content-free tickle; these four shapes are the
// items themselves, pulled over the authenticated API after a tickle wakes the
// app. The wire names come from `userNotifications.db.js`'s `toItem`.
/**
* One inbox item. [read] is the flag and [readAt] the stamp, sent side by side so
* a client renders one without parsing the other.
*
* [url] is where the item points on the site (rendered from the template's
* `email.button` block) and is **null on most items** — an inbox row is complete
* on its own. [triggerId] is the event that produced it, in §7.2's ONE namespace,
* so it is the same vocabulary a push tickle's `stream` speaks.
*/
@Serializable
data class NotificationItemDto(
val id: Long = 0,
val triggerId: String = "",
val title: String = "",
val body: String? = null,
val url: String? = null,
val read: Boolean = false,
val readAt: String? = null,
val createdAt: String? = null,
)
/**
* `GET /auth/me/notifications` — one page, newest first.
*
* Keyset-paged: the next page is `?before=<the last item's id>`, not an offset,
* because the list gains rows at the top while it is being read. [hasMore] comes
* from the server's take+1, so "is there another page" costs no second query.
* [unread] counts the WHOLE inbox, not the page — it rides along so a screen
* rendering both a badge and a list from one response cannot show the two
* disagreeing.
*/
@Serializable
data class NotificationInboxDto(
val items: List<NotificationItemDto> = emptyList(),
val hasMore: Boolean = false,
val unread: Int = 0,
)
/** `GET /auth/me/notifications/unread-count` — the badge, on its own. */
@Serializable
data class NotificationUnreadDto(
val unread: Int = 0,
)
/**
* What both mark-read routes answer with. [unread] is the count AFTER the write,
* so the badge follows from the response rather than from a second call.
*/
@Serializable
data class NotificationReadResultDto(
val ok: Boolean = false,
val changed: Int = 0,
val unread: Int = 0,
)
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────────
/**
* One delivery channel from the registry. [modes] is what this channel accepts —
* `["off","instant"]` for push and in-app, `["off","instant","digest"]` for email
* — and the UI renders its control from THIS, never from a hardcoded set, so a
* channel added server-side arrives without an app release.
*
* [carriesContent] is the tickle invariant stated on the wire: push is `false`,
* which is why a push item's title never leaves the server.
*/
@Serializable
data class NotificationChannelDto(
val id: String = "",
val label: String = "",
val carriesContent: Boolean = false,
val defaultMode: String = "off",
val supportsDigest: Boolean = false,
val modes: List<String> = emptyList(),
)
/**
* One subscribable id, from `GET /auth/me/notifications/channels`. The list is the
* UNION of push streams and event triggers in one namespace (§7.2), so an id may
* be a stream, a trigger, or both.
*
* [channels] is which channels apply to THIS id — a trigger-only id carries no
* `push` because nothing is registered to push it — and [modes] is the EFFECTIVE
* mode per channel: where the user has expressed nothing the server has already
* substituted that channel's default, and the client must not re-implement the
* defaulting.
*/
@Serializable
data class NotificationChannelItemDto(
val id: String = "",
val label: String = "",
val description: String = "",
val personal: Boolean = false,
val requiresLinkedAccount: Boolean = false,
val ceiling: String? = null,
val channels: List<String> = emptyList(),
val modes: Map<String, String> = emptyMap(),
)
/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */
@Serializable
data class NotificationChannelPrefsDto(
val channels: List<NotificationChannelDto> = emptyList(),
val items: List<NotificationChannelItemDto> = emptyList(),
)
/** One (id, channel) → mode pair of a sparse update. */
@Serializable
data class NotificationChannelPrefDto(
val id: String,
val channel: String,
val mode: String,
)
/**
* `PUT /auth/me/notifications/channels` body — a SPARSE update: only the pairs
* named are written and every other pair is left alone, so one toggle saves
* without the screen holding the whole table.
*
* [prefs] has no default for the same reason [NotificationSubscriptionsDto.streams]
* has none — kotlinx omits a property equal to its default, and the validator
* requires the field. Unlike that DTO there is no empty-set case to get wrong
* here: `off` is a mode, never an omission.
*/
@Serializable
data class NotificationChannelPrefsUpdateDto(
val prefs: List<NotificationChannelPrefDto>,
val streams: List<String> = emptyList(),
)

View File

@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
* Presentation is text-only for v1 (no item icons / paperdoll).
*
* In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
* the public boards — these are separate endpoints with separate shapes.
*/
// ── Game-account linking ─────────────────────────────────────────────────────
@@ -83,47 +83,8 @@ data class CharProfileDto(
val titles: TitlesDto? = null,
val guild: GuildRefDto? = null,
val governorOf: List<String> = emptyList(),
/**
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
* earned nothing anywhere — the shard omits systems the character has no entry in
* — and empty on a shard whose plugin predates 3.0.
*
* Served **ungated**: a character's own standings are self-service data on
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
* feature being visible. Don't re-gate them app-side.
*/
val points: List<CharPointsDto> = emptyList(),
)
/**
* One point system a character holds a score in (Protocol 3.0 §7.3).
*
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
*
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
* Nothing may divide by it, and a full-width progress bar for an uncapped score
* would imply a completion that doesn't exist.
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
* Loyalty") is the PRIMARY display path, not a defensive fallback.
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
* Absent and "unranked" are different answers, so it renders only when sent.
*/
@Serializable
data class CharPointsDto(
val system: String? = null,
val nameString: String? = null,
val points: Long? = null,
val maxPoints: Long? = null,
val rank: Int? = null,
) {
/** The cap, or null when the system is uncapped (see [maxPoints]). */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
@Serializable
data class CharStatsDto(
val str: Int? = null,
@@ -173,45 +134,17 @@ data class EquipmentDto(
val itemId: Int? = null,
val hue: Int? = null,
val mods: JsonObject? = null,
/**
* A player-given name — set for the minority of items someone has renamed, null
* for almost everything else. The shard sends the plain `Item.Name` field; it
* never builds a display name (that call is a packet builder, not a field read).
*/
val name: String? = null,
/**
* The item's type name, resolved from its cliloc id **by the website** against
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
* table configured, which is fully supported — the sheet then falls back to the
* layer, exactly as it did before the table existed.
*/
val clilocName: String? = null,
) {
/**
* What to call this item.
*
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
* not be relabelled "hatchet" — and the server applies the same precedence, so
* this only re-states it for an item that arrived with both.
*/
val label: String? get() = name ?: clilocName ?: layer
}
)
/**
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
*
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
* into words against its cliloc table — same length and order as [reward], with a null
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
* the shard has no cliloc table, so read it positionally and tolerate it being short.
* See `displayTitles` in the character sheet.
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
* literal — numeric ones are skipped without a cliloc table (as the website does).
*/
@Serializable
data class TitlesDto(
val selected: Int? = null,
val reward: List<String> = emptyList(),
val rewardResolved: List<String?> = emptyList(),
val fameKarma: String? = null,
val skill: String? = null,
)

View File

@@ -5,7 +5,6 @@ package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* DTOs for the public site/identity endpoints. Shapes mirror the backend
@@ -20,47 +19,6 @@ data class VersionDto(
val service: String = "",
val api: String = "",
val server: String = "",
/**
* What CORE serves beyond the baseline every backend has (events Phase 14a;
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
* `GET /public/modules` so a client feature-detects one way, and a **separate
* list** because core is not a module.
*
* **The value is in what is absent**, which is why the default is empty
* rather than something meaningful: a backend released before a capability
* existed omits the key entirely, and that is how the app tells an older site
* from one that simply has nothing to show. An unknown string is absent, and
* no route may be inferred from one.
*/
val capabilities: List<String> = emptyList(),
)
/**
* One installed, **started** module on `GET /public/modules`.
*
* A module that is disabled or failed to load is absent rather than listed with a
* state — its routes and its nav are absent too, so a client renders a site
* without that capability rather than one advertising a capability that 503s.
*/
@Serializable
data class InstalledModuleDto(
val id: String = "",
val name: String = "",
val version: String = "",
val capabilities: List<String> = emptyList(),
)
/**
* `GET /public/modules` — what this backend is serving beyond core.
*
* Database-free and never gated by site mode, so the app can feature-detect
* during maintenance. A **500** is the one answer that is not an answer: core
* refuses to return `[]` for a list read before its loader ran, because a caller
* cannot tell an empty list from a mis-ordered boot.
*/
@Serializable
data class ModulesDto(
val modules: List<InstalledModuleDto> = emptyList(),
)
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
@@ -122,28 +80,4 @@ data class SettingsDto(
val brand: BrandDto = BrandDto(),
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
val push: PushConfigDto = PushConfigDto(),
/**
* The admin's **resolved** theme tokens — the CSS custom properties the site
* paints, already layered `:root ← preset ← custom` by the server
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
* means "the shipped defaults" and is the untouched-instance path.
*
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
* purpose: a single unexpected value must not fail the decode of the whole
* settings payload and take `brand` and `push` down with it. It is coerced
* field-by-field by `SiteAppearance.from`.
*
* The raw `theme_visual` / `brand_assets` rows ride along in this same
* response and are deliberately **not** modeled — they are inputs, and
* re-deriving a palette from them would be a second `resolveThemeTokens` in
* Kotlin, guaranteed to drift (§3).
*/
val theme: JsonElement? = null,
/**
* The public nav overrides, as the raw JSON **string** stored in
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
* client's `parseJsonSetting` does. Absent when the admin never edited the
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
)

View File

@@ -1,367 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11):
* the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn
* atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas.
* controller.js` responses; see `docs/link/v3.md` §5§8.
*
* Every field is nullable-with-a-default, which is load-bearing rather than merely
* defensive here: an admin can gate individual fields away per audience rung
* (`ownerName`, `location`, a board's `name`), so a response legitimately arrives
* with them missing and must still decode.
*/
// ── Ruleset (§5) ────────────────────────────────────────────────────────────
/**
* `GET /public/shard/ruleset` — what this shard's world is configured to do.
*
* Every block is optional and omitted when its system is off, so a null block means
* "not applicable here", not "unknown". A `null` BODY (rather than an empty object)
* means the shard has never published a ruleset — distinct from the feature being
* switched off, which is a 404.
*/
@Serializable
data class RulesetDto(
val shard: String? = null,
val expansion: String? = null,
/**
* The public connect address, published only when the operator set one. It is
* also the ruleset's one admin-configurable field, so it can be present for a
* signed-in viewer and absent for an anonymous one.
*/
val connect: String? = null,
/** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */
val systems: Map<String, Boolean> = emptyMap(),
val caps: RulesetCapsDto? = null,
val accounts: RulesetAccountsDto? = null,
val housing: RulesetHousingDto? = null,
val vetRewards: RulesetVetRewardsDto? = null,
val vendors: RulesetVendorsDto? = null,
val vvv: RulesetVvvDto? = null,
val store: RulesetStoreDto? = null,
val schedule: RulesetScheduleDto? = null,
val updatedAt: String? = null,
)
/**
* Skill and stat caps.
*
* **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores
* them, and the raw number is actively misleading rather than merely unhelpful (a
* "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap]
* and [totalSkillCap]. The stat caps below them are plain values.
*/
@Serializable
data class RulesetCapsDto(
val skill: Int? = null,
val totalSkill: Int? = null,
val stat: Int? = null,
val str: Int? = null,
val dex: Int? = null,
val int: Int? = null,
val strMax: Int? = null,
val dexMax: Int? = null,
val intMax: Int? = null,
) {
val skillCap: Double? get() = skill?.let { it / 10.0 }
val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 }
}
@Serializable
data class RulesetAccountsDto(
val perIp: Int? = null,
val charSlots: Int? = null,
val autoCreate: Boolean? = null,
)
@Serializable
data class RulesetHousingDto(val accountHouseLimit: Int? = null)
@Serializable
data class RulesetVetRewardsDto(
val enabled: Boolean? = null,
val rewardIntervalDays: Int? = null,
)
@Serializable
data class RulesetVendorsDto(
val restockDelayMinutes: Int? = null,
val maxSell: Int? = null,
val economyStockAmount: Int? = null,
)
@Serializable
data class RulesetVvvDto(
val enabled: Boolean? = null,
val startSilver: Int? = null,
val enhancedRules: Boolean? = null,
)
@Serializable
data class RulesetStoreDto(
val enabled: Boolean? = null,
val currencyName: String? = null,
)
@Serializable
data class RulesetScheduleDto(
val autoSaveFrequencyMinutes: Int? = null,
val autoRestartEnabled: Boolean? = null,
val autoRestartHour: Int? = null,
val autoRestartMinute: Int? = null,
)
// ── Leaderboards (§7) ───────────────────────────────────────────────────────
/**
* One point system's board (`GET /public/shard/points`, `/points/:system`).
*
* [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is
* usually null because most systems name themselves with a cliloc — the same two
* traps as [CharPointsDto], documented in full there.
*
* [players] counts players actually *holding* points, not the entry count: ten of the
* shard's systems auto-add a zero-point row for every character ever created, so the
* raw count would report the whole census as one system's participants.
*/
@Serializable
data class PointsBoardDto(
val system: String? = null,
val nameString: String? = null,
val nameNumber: Int? = null,
val maxPoints: Long? = null,
val players: Int? = null,
val showOnGump: Boolean = true,
val top: List<PointsEntryDto> = emptyList(),
val t: Long? = null,
val updatedAt: String? = null,
) {
/** The cap, or null when the system is uncapped. */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
/**
* A ranked character on a board. [name] is admin-configurable (the `leaderboards`
* feature's one field rule), so a shard can publish standings without naming who
* holds them — a rank with no name is a valid row, not a broken one.
*/
@Serializable
data class PointsEntryDto(
val rank: Int? = null,
val serial: String? = null,
val name: String? = null,
val points: Long? = null,
)
// ── Marketplace (§8) ────────────────────────────────────────────────────────
/**
* Where a shop stands. **Nested, not flattened**, on the wire and in the read model
* alike, so that ONE admin rule hides the facet, the coordinates, the region and the
* house together — five flat keys would be five rules that drift apart (`v3.md` §8.8).
* A null location means an admin gated it away; render that as an answer, not a blank.
*/
@Serializable
data class MarketLocationDto(
val map: String? = null,
val x: Int? = null,
val y: Int? = null,
val z: Int? = null,
val region: String? = null,
val house: String? = null,
)
/** The shop a listing belongs to, as embedded in a search result. */
@Serializable
data class MarketVendorRefDto(
val serial: String? = null,
val shopName: String? = null,
val ownerName: String? = null,
val location: MarketLocationDto? = null,
)
/**
* One item for sale. [displayName] is resolved server-side against the site's cliloc
* table, preferring a player-set [name]; a shard with no cliloc table configured sends
* neither and the item renders by id.
*
* [child] marks an item priced by an enclosing container rather than itself, exactly
* as the in-game Vendor Search reports it.
*/
@Serializable
data class MarketListingDto(
val serial: String? = null,
val itemId: Int? = null,
val hue: Int? = null,
val amount: Int? = null,
val price: Long? = null,
val name: String? = null,
val cliloc: Int? = null,
val displayName: String? = null,
val child: Boolean = false,
val vendor: MarketVendorRefDto? = null,
) {
/** What to call this item; null when the shard publishes no name for it. */
val label: String? get() = name ?: displayName
}
/**
* A page of search results (`GET /public/shard/market`).
*
* Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much"
* is the question, and a vendor-shaped result would make every caller flatten the
* shops back out.
*
* [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The
* shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old;
* a page implying live prices sends someone to an item that sold twenty minutes ago.
*/
@Serializable
data class MarketPageDto(
val listings: List<MarketListingDto> = emptyList(),
val total: Int = 0,
val limit: Int? = null,
val offset: Int? = null,
val vendors: Int? = null,
val staleAt: String? = null,
)
/**
* One shop and its stock (`GET /public/shard/market/vendors/:serial`).
*
* [truncated] means the shard publishes only the first `MarketMaxListings` of a larger
* inventory — [count] is what is published, [total] what the shop holds. Saying so is
* the point of this screen: a search result list cannot express it.
*/
@Serializable
data class MarketVendorDto(
val serial: String? = null,
val shopName: String? = null,
val ownerSerial: String? = null,
val ownerName: String? = null,
val location: MarketLocationDto? = null,
val count: Int? = null,
val total: Int? = null,
val truncated: Boolean = false,
val updatedAt: String? = null,
val items: List<MarketListingDto> = emptyList(),
)
/** Index size, staleness and the filter options that actually hold vendors. */
@Serializable
data class MarketMetaDto(
val vendors: Int = 0,
val items: Int = 0,
val staleAt: String? = null,
val freshAt: String? = null,
val maps: List<String> = emptyList(),
val regions: List<String> = emptyList(),
)
// ── Spawn atlas (§6) ────────────────────────────────────────────────────────
/**
* A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`:
* the atlas is static shard *content* parsed from the server's own data files, not
* live shard *state*, so it does not go offline with the sidecar — but unlike the
* shard routes it IS site-mode gated, like posts and the wiki.
*
* [points] is a **count** of spawners; [spawners] is the list, and only the
* single-creature route sends it. The two names are one letter apart in meaning and
* were deliberately separated (`v3.md` §6.3) — do not reuse one for the other.
*/
@Serializable
data class AtlasCreatureDto(
val slug: String? = null,
val name: String? = null,
/** How many can be alive at once, summed across every spawner. */
val total: Int? = null,
/** How many spawners mention this creature. */
val points: Int? = null,
/** Spawner count per facet. */
val facets: Map<String, Int> = emptyMap(),
/**
* Where it appears, aggregated per named place — the detail route only, and the
* answer the whole screen exists to give. **Objects, not strings:** the server
* sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
* made the detail route fail to decode entirely.
*/
val places: List<AtlasPlaceDto> = emptyList(),
/**
* Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
* the normal state, since no artwork ships. Neither client renders it yet; the
* field is carried so a decode never depends on that staying true.
*/
val art: String? = null,
val spawners: List<AtlasSpawnerDto> = emptyList(),
val spawnersTruncated: Boolean = false,
/** Creatures sharing its spawners — the detail route only. */
val alsoHere: List<AtlasCreatureDto> = emptyList(),
)
/**
* One named place a creature spawns in, already aggregated across its spawners.
*
* [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
* "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
* "Wilderness" — turning a list of coordinates into an answer.
*/
@Serializable
data class AtlasPlaceDto(
val facet: String? = null,
val label: String? = null,
/** Spawners in this place. */
val spawners: Int? = null,
/** How many can be alive at once here, summed across those spawners. */
val maxAlive: Int? = null,
)
/**
* One spawn point.
*
* **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser.
* XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole
* minutes, flagging that per record — so the raw file has `5` meaning five minutes on
* one spawner and five seconds on the next, both plausible. The API and this client
* carry seconds throughout.
*/
@Serializable
data class AtlasSpawnerDto(
val id: Long? = null,
val facet: String? = null,
val name: String? = null,
val x: Int? = null,
val y: Int? = null,
val maxCount: Int? = null,
val minDelay: Int? = null,
val maxDelay: Int? = null,
val region: String? = null,
val landmark: String? = null,
/** The server's own "Despise, Felucca" style placement label. */
val label: String? = null,
)
/** A page of creature search results (`GET /public/atlas/creatures`). */
@Serializable
data class AtlasCreaturePageDto(
val creatures: List<AtlasCreatureDto> = emptyList(),
val total: Int = 0,
val limit: Int? = null,
val offset: Int? = null,
)
/** When the atlas was last derived from the shard's data files, and what it holds. */
@Serializable
data class AtlasMetaDto(
val importedAt: String? = null,
val generatedAt: String? = null,
val counts: Map<String, Int> = emptyMap(),
val facets: List<String> = emptyList(),
)

View File

@@ -15,43 +15,13 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
*/
/**
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
* the audience rung they resolved to.
*
* Every shard-derived feature is admin-configurable — it can be switched off or
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
* since `player` means *a linked game account* and staff always satisfy it.
*
* The response reports only what the caller can see, so the list itself never
* discloses a feature they're gated out of.
*/
@Serializable
data class ShardFeaturesDto(
val level: String? = null,
val features: List<String> = emptyList(),
)
/**
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
* (e.g. `"0x1A2B"`), never numbers.
*
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
* not in-game-visible the way a character name is, so they are stripped from every
* response below `admin` and no admin setting can loosen that. The fields stay
* declared because an admin session does receive them; nothing below one should
* expect a value.
*/
/** A game actor (player/leader/governor) as embedded in board payloads. */
@Serializable
data class ActorDto(
val serial: String? = null,
val serial: Long? = null,
val name: String? = null,
val acct: String? = null,
val webId: String? = null,
val webId: Long? = null,
) {
/** Best display label for this actor. */
val label: String get() = name ?: acct ?: "Someone"
@@ -103,7 +73,7 @@ data class FeedEventDto(
*/
@Serializable
data class OnlineStaffDto(
val serial: String? = null,
val serial: Long? = null,
val name: String? = null,
val map: String? = null,
val x: Int? = null,
@@ -117,7 +87,7 @@ data class OnlineStaffDto(
*/
@Serializable
data class HouseDto(
val serial: String = "",
val serial: Long = 0,
val name: String? = null,
val region: String? = null,
val map: String? = null,
@@ -134,7 +104,7 @@ data class HouseDto(
*/
@Serializable
data class ChampDto(
val serial: String = "",
val serial: Long = 0,
val category: String? = null,
val type: String? = null,
val name: String? = null,

View File

@@ -1,37 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
/**
* Parse a JSON-valued settings row, client side — the second stage of decoding
* `nav_public` (THEMING_AND_NAV.md §3).
*
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
* the row arrives as a **string inside** the already-decoded settings object,
* and a malformed or wrong-shaped one must read as **absent** — the surface
* falls back to the coded default — never as an error and never as a
* half-applied object.
*/
private val settingsJson = Json { ignoreUnknownKeys = true }
/**
* @param raw the raw stored value, as it arrived in the settings payload
* @return the parsed object, or null when absent/malformed
*/
fun parseJsonSetting(raw: String?): JsonObject? {
if (raw.isNullOrEmpty()) return null
val parsed = try {
settingsJson.parseToJsonElement(raw)
} catch (_: SerializationException) {
return null
}
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
// every consumer of these keys as a syntax error is.
return parsed as? JsonObject
}

View File

@@ -1,73 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.SettingsDto
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Everything the app renders itself with that the shard's admin controls
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
* so the theme and the drawer can never disagree about which shard they are showing.
*
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
* that predates the feature, and a settings call that failed outright are all the
* same state here, and all three must render exactly as the app did before this
* milestone existed (§2). That is why nothing on this class is nullable except
* [brand], which was already nullable and whose absence already meant "use the
* bundled strings".
*/
data class SiteAppearance(
/** The per-shard branding block; null when settings couldn't be loaded. */
val brand: BrandDto? = null,
/**
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
* Empty means "the shipped defaults" — the server never emits an empty map,
* but absent and empty are the same thing to the app and it must not depend
* on that.
*/
val theme: Map<String, String> = emptyMap(),
/**
* The parsed `nav_public` row, or null when the admin never edited the nav.
* Kept as the raw object here; reading `items` / `sections` / `links` out of
* it is the job of the phases that render them.
*/
val navPublic: JsonObject? = null,
) {
companion object {
/** The shipped app: no brand, no overrides. Also what a failed load means. */
val NONE = SiteAppearance()
/**
* Build the appearance from a `GET /public/settings` body. Forgiving
* field by field (§2): a bad `--accent` must not discard a good `--bg`
* beside it, and a malformed `nav_public` must not cost the theme.
*/
fun from(settings: SettingsDto?): SiteAppearance {
if (settings == null) return NONE
return SiteAppearance(
brand = settings.brand,
theme = themeTokens(settings.theme as? JsonObject),
navPublic = parseJsonSetting(settings.navPublic),
)
}
// Every themable token is a string server-side (validated on write, and
// resolveThemeTokens only ever copies a validated value). Anything else
// is dropped rather than coerced, so an unexpected value costs exactly
// its own token and the rest of the palette still applies.
private fun themeTokens(raw: JsonObject?): Map<String, String> {
if (raw.isNullOrEmpty()) return emptyMap()
return buildMap {
for ((token, value) in raw) {
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
if (!text.isNullOrBlank()) put(token, text)
}
}
}
}
}

View File

@@ -10,19 +10,10 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -35,7 +26,6 @@ import javax.inject.Singleton
@Singleton
class AccountRepository @Inject constructor(
private val api: MeApi,
private val json: Json,
) {
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
@@ -58,63 +48,4 @@ class AccountRepository @Inject constructor(
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
safeApiCall { api.trustedDevices() }
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
sealed interface TrustOutcome {
/** Trusted; [trustToken] is the opaque token to persist (native). */
data class Trusted(val trustToken: String?) : TrustOutcome
/** At the per-user cap — [devices] must be pruned before retrying. */
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
data object NetworkError : TrustOutcome
data object ServerError : TrustOutcome
}
/**
* Trust the current device. Reads the raw response so the `409 { error, devices }`
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
*/
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
val response = try {
api.trustThisDevice(TrustDeviceRequest(deviceName))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return TrustOutcome.NetworkError
} catch (_: Exception) {
return TrustOutcome.ServerError
}
if (response.isSuccessful) {
return TrustOutcome.Trusted(response.body()?.trustToken)
}
if (response.code() == 409) {
val devices = runCatching {
val raw = response.errorBody()?.string()
if (raw.isNullOrBlank()) emptyList()
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
}.getOrDefault(emptyList())
return TrustOutcome.LimitReached(devices)
}
return TrustOutcome.ServerError
}
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
safeApiCall { api.revokeTrustedDevice(id).revoked }
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
safeApiCall { api.revokeAllTrustedDevices().revoked }
// ── Recovery (backup) codes ────────────────────────────────────────────
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
safeApiCall { api.recoveryCodesStatus() }
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
}

View File

@@ -1,94 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* The M10 staff-operations data source over `/api/v1/admin/…` (PLAN.md §1, §6.4).
* Every call returns a typed [ApiResult] so a screen renders a clean error/retry
* rather than crashing — a `403` (role lost since the menu rendered) and a `503`
* (shard/sidecar offline for the shard-write actions) are both expected outcomes
* the UI handles, never thrown. Role is authoritative on the server.
*/
@Singleton
class AdminRepository @Inject constructor(
private val api: AdminApi,
) {
suspend fun dashboard(): ApiResult<AdminDashboardDto> = safeApiCall { api.dashboard() }
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
// ── Content: news posts ───────────────────────────────────────────────
suspend fun posts(): ApiResult<List<AdminPostDto>> = safeApiCall { api.posts() }
suspend fun createPost(body: PostCreateRequest): ApiResult<AdminPostDto> =
safeApiCall { api.createPost(body) }
suspend fun setPostPublished(id: Long, published: Boolean): ApiResult<AdminPostDto> =
safeApiCall { api.publishPost(id, PublishRequest(published)) }
suspend fun deletePost(id: Long): ApiResult<Unit> = safeApiCall { api.deletePost(id).requireOk() }
// ── Content: wiki taxonomy ────────────────────────────────────────────
suspend fun wikiCategories(): ApiResult<List<AdminWikiCategoryDto>> = safeApiCall { api.wikiCategories() }
suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult<AdminWikiCategoryDto> =
safeApiCall { api.createWikiCategory(body) }
suspend fun deleteWikiCategory(id: Long): ApiResult<Unit> =
safeApiCall { api.deleteWikiCategory(id).requireOk() }
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
// ── Moderation: shard write plane ─────────────────────────────────────
suspend fun kick(account: String?, serial: String?): ApiResult<Unit> =
safeApiCall { api.kick(KickRequest(account, serial)).requireOk() }
suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult<Unit> =
safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() }
suspend fun unban(account: String): ApiResult<Unit> =
safeApiCall { api.unban(UnbanRequest(account)).requireOk() }
suspend fun broadcast(text: String, hue: Int?): ApiResult<Unit> =
safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() }
// ── Support queue: help pages ─────────────────────────────────────────
suspend fun supportPages(): ApiResult<List<SupportPageDto>> = safeApiCall { api.supportPages() }
suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult<Unit> =
safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() }
suspend fun closePage(id: String): ApiResult<Unit> =
safeApiCall { api.closePage(id).requireOk() }
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
private fun Response<Unit>.requireOk() {
if (!isSuccessful) throw HttpException(this)
}
}

View File

@@ -3,10 +3,7 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
@@ -15,9 +12,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import retrofit2.Response
import java.io.IOException
@@ -36,59 +31,25 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val inboxCache: InboxCache,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json,
) {
/** The three outcomes of SSO provider discovery, so the login screen can tell a
* shard that offers no SSO ([None]) apart from a discovery that failed
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
* a broken call behind a dead website hand-off (§4.2). */
sealed interface SsoDiscovery {
/** At least one enabled provider — render a native button per entry. */
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
/** Discovery succeeded but the shard has no SSO providers configured. */
data object None : SsoDiscovery
/** The discovery call failed (offline / server error) — surface a retry. */
data object Unavailable : SsoDiscovery
}
/**
* Discover the shard's enabled SSO providers for the native login buttons (§4.2).
* Public discovery, never secrets. Retries once before reporting [Unavailable],
* so a single transient blip doesn't strand the user.
* The shard's enabled SSO providers for the native login buttons (§4.2). Public
* discovery, never secrets. Returns an empty list on any failure — the login
* screen then keeps the website hand-off fallback rather than showing nothing.
*/
suspend fun ssoProviders(): SsoDiscovery {
var lastFailed = false
repeat(2) { attempt ->
try {
val providers = ssoApi.providers()
return if (providers.isEmpty()) SsoDiscovery.None else SsoDiscovery.Available(providers)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
lastFailed = true
if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
}
}
return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
suspend fun ssoProviders(): List<SsoProviderDto> = try {
ssoApi.providers()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
/** Outcome of a login attempt (§4.1). */
sealed interface LoginResult {
/**
* Signed in. [trustLimitReached] is true when "trust this device" was asked
* for but the per-user cap blocked it (the login still succeeded, but no trust
* token was issued); [devices] then lists the trusted devices to manage.
*/
data class Success(
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) : LoginResult
data object Success : LoginResult
/** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult
@@ -104,32 +65,9 @@ class AuthRepository @Inject constructor(
data object NetworkError : LoginResult
}
/**
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
* [trustDevice], the server may return a fresh trust token to persist for next time.
*/
suspend fun login(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
trustDevice: Boolean = false,
): LoginResult {
val storedTrustToken = trustTokenStore.tokenFor(username)
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
val response: Response<MobileTokenResponse> = try {
authApi.login(
MobileLoginRequest(
username = username,
password = password,
code = code,
recoveryCode = recoveryCode,
trustDevice = trustDevice.takeIf { it },
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
),
trustToken = storedTrustToken,
)
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
@@ -138,14 +76,8 @@ class AuthRepository @Inject constructor(
if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError
// Persist a freshly minted trust token (scoped to this account) so the next
// login skips the second factor — it deliberately outlives logout.
body.trustToken?.let { trustTokenStore.save(username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success(
trustLimitReached = body.trustLimitReached,
devices = body.devices,
)
return LoginResult.Success
}
return when (response.code()) {
@@ -155,20 +87,6 @@ class AuthRepository @Inject constructor(
}
}
/**
* Persist a trust token minted by the self-service "trust this device" action
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
*/
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
/**
* Drop the locally stored trust token so this device stops skipping the TOTP step
* (used after "untrust all" and on a Settings → Server switch). Server-side
* revocation makes any surviving token inert anyway — the next login just prompts
* for the code — so this is a client-side cleanliness step, never load-bearing.
*/
fun clearTrustToken() = trustTokenStore.clear()
/**
* Revoke this session (or, with [allDevices], every session) and clear local
* tokens (§4.3). Best-effort: the local session is torn down even if the
@@ -185,18 +103,6 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) {
// Ignore — local session teardown proceeds regardless.
}
// Drop the cached inbox with it: those are one person's notifications, and
// they have finished with this device. This is the tidy-up, not the
// safeguard — InboxCache scopes every snapshot to (base URL, user id), so
// the paths that never reach here (a dead refresh, a server switch) cannot
// surface one account's items under another's session either.
try {
inboxCache.clear()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore — same reason.
}
val refreshToken = sessionManager.currentRefreshToken()
try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
@@ -234,8 +140,4 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) {
false
}
private companion object {
const val DISCOVERY_RETRY_DELAY_MS = 400L
}
}

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences
@@ -27,9 +26,6 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -110,17 +106,6 @@ class ConnectionRepository @Inject constructor(
}
pushManager.setNtfyUrl(null)
sessionManager.onSignedOut()
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
// a switch between two signed-out hosts changes no session, so nothing else
// invalidates the cache and the new shard would inherit the old one's menu.
shardFeaturesRepository.invalidate()
// Same argument, one layer up: what the OLD host served says nothing about
// the new one, and a stale "this backend has no game module" would hide the
// new host's shard rows until its first successful read.
siteCapabilitiesRepository.invalidate()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -1,61 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.api.dto.PublicEventDto
import javax.inject.Inject
import javax.inject.Singleton
/**
* The event calendar, event pages, arcs and the caller's own participation
* history (PLAN.md §6.1, §9 M13).
*
* The two single-object reads unwrap their envelope here rather than in a view
* model, so a screen never holds a `…Response` whose only job was to carry one
* field. The calendar and the history keep theirs: `truncated` is a fact about
* the answer that the screen renders, and the history's page is a list the pager
* appends to.
*/
@Singleton
class EventsRepository @Inject constructor(
private val api: EventsApi,
) {
/** The public calendar. Both ends optional; the server's default window is 31 days. */
suspend fun calendar(
from: String? = null,
to: String? = null,
seriesId: Long? = null,
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
/**
* One event, optionally about one occurrence.
*
* [run] is passed through untouched — including a run that belongs to some
* other event, which the server ignores rather than refusing. Filtering it
* here would turn a stale link into a dead end instead of a page about the
* thing the link was about.
*/
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
safeApiCall { api.getSeries(slug) }.map { it.series }
/**
* One page of the caller's own participation history, newest first.
*
* [before] is the id of the last row already shown — a keyset page, not an
* offset, because the list gains rows at the top as the reader attends things.
*/
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
}

View File

@@ -6,23 +6,16 @@ package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Device registration, stream subscriptions, per-channel preferences and the
* in-app inbox — the whole `/auth/me` notification surface (PLAN.md §11,
* ENGAGEMENT.md phases 3 and 7/8). Every call returns a typed [ApiResult] so the screen
* Device registration + per-user stream subscriptions over the opt-in push surface
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
* and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
* (endpoint off the shard's allow-set) or a down backend never throws (§7).
*/
@@ -44,33 +37,4 @@ class NotificationsRepository @Inject constructor(
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
// ── Per-channel preferences (phase 3) ──────────────────────────────────
suspend fun channelPrefs(): ApiResult<NotificationChannelPrefsDto> =
safeApiCall { api.channelPrefs() }
/**
* Write ONE (id, channel) → mode pair. The endpoint is sparse, so a screen
* saving a single toggle sends a single row and cannot disturb the others —
* including the ones it does not render.
*/
suspend fun setChannelMode(id: String, channel: String, mode: String): ApiResult<NotificationChannelPrefsDto> =
safeApiCall {
api.putChannelPrefs(
NotificationChannelPrefsUpdateDto(listOf(NotificationChannelPrefDto(id, channel, mode))),
)
}
// ── The inbox (phase 7/8) ──────────────────────────────────────────────
/** One page, newest first. [before] is the previous page's last id, never an offset. */
suspend fun inbox(before: Long? = null, unreadOnly: Boolean = false): ApiResult<NotificationInboxDto> =
safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) }
suspend fun unreadCount(): ApiResult<NotificationUnreadDto> = safeApiCall { api.unreadCount() }
suspend fun markRead(id: Long): ApiResult<NotificationReadResultDto> = safeApiCall { api.markRead(id) }
suspend fun markAllRead(): ApiResult<NotificationReadResultDto> = safeApiCall { api.markAllRead() }
}

View File

@@ -1,111 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which shard surfaces the current viewer may reach, from
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
*
* Every shard-derived feature is admin-configurable — it can be switched off, or its
* audience raised above the caller's rung — so shard navigation can no longer be a
* static list gated on the session role alone. [level] is the server's own answer on
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
* re-derive it.
*
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
*/
@Singleton
class ShardFeaturesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _features = MutableStateFlow<ShardFeatures?>(null)
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on every session change,
// and two overlapping loads would race to publish.
private val mutex = Mutex()
/**
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
* a role revalidation that actually changed the user), because the answer is
* per-viewer.
*
* A failed lookup clears the cache rather than keeping a stale one: falling back
* to "show everything" is the safe direction here, since the server still gates
* every call.
*/
suspend fun refresh() = mutex.withLock {
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
is ApiResult.Ok -> ShardFeatures(
level = result.data.level,
visible = result.data.features.toSet(),
)
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
// route — that site has no visibility framework, so "unknown" is exactly
// the right answer and the menu behaves as it did before M11.
else -> null
}
}
/**
* Drop the cached answer. Called on a Settings → Server switch: the features
* belong to the host that reported them, and a switch between two signed-out
* hosts changes no session, so nothing else would invalidate them.
*/
fun invalidate() {
_features.value = null
}
}
/**
* The resolved visibility answer for one viewer: the rung the server placed them on
* and the shard features they may reach.
*/
data class ShardFeatures(
val level: String?,
val visible: Set<String>,
)
/**
* True when [feature] may be shown — **or when the answer isn't known yet**.
*
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
* the server gates every call regardless, so the cost of guessing wrong is a link that
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
* flickers its entries in on every cold start.
*/
fun canSee(features: ShardFeatures?, feature: String): Boolean =
features == null || feature in features.visible
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
object ShardFeature {
const val STATUS = "status"
const val ACTIVITY = "activity"
const val CHAMPS = "champs"
const val GUILDS = "guilds"
const val GOVERNORS = "governors"
const val HOUSES = "houses"
const val PRESENCE = "presence"
// Added by Protocol 3.0.
const val RULESET = "ruleset"
const val ATLAS = "atlas"
const val LEADERBOARDS = "leaderboards"
const val MARKET = "market"
}

View File

@@ -3,13 +3,11 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
@@ -17,13 +15,8 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.KSerializer
@@ -42,7 +35,7 @@ import javax.inject.Singleton
@Singleton
class ShardRepository @Inject constructor(
private val api: PublicApi,
private val stream: ShardStream,
private val stream: ShardStreamClient,
private val json: Json,
) {
// ── Snapshots ────────────────────────────────────────────────────────
@@ -69,59 +62,6 @@ class ShardRepository @Inject constructor(
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
//
// All four sit behind `requireFeature`, so a 404/403 here is "this shard doesn't
// publish it" rather than a fault — see `toShardUiState()`.
/** The shard ruleset, or `Ok(null)` when the shard has never published one. */
suspend fun ruleset(): ApiResult<RulesetDto?> = safeApiCall { api.getShardRuleset() }
suspend fun pointsBoards(): ApiResult<List<PointsBoardDto>> = safeApiCall { api.getShardPoints() }
suspend fun pointsBoard(system: String): ApiResult<PointsBoardDto> =
safeApiCall { api.getShardPointsBoard(system) }
suspend fun market(
query: String? = null,
map: String? = null,
region: String? = null,
sort: String = SORT_PRICE_ASC,
limit: Int = MARKET_PAGE,
offset: Int = 0,
): ApiResult<MarketPageDto> = safeApiCall {
api.getShardMarket(
query = query?.takeIf { it.isNotBlank() },
map = map?.takeIf { it.isNotBlank() },
region = region?.takeIf { it.isNotBlank() },
sort = sort,
limit = limit,
offset = offset,
)
}
suspend fun marketMeta(): ApiResult<MarketMetaDto> = safeApiCall { api.getShardMarketMeta() }
suspend fun marketVendor(serial: String): ApiResult<MarketVendorDto> =
safeApiCall { api.getShardMarketVendor(serial) }
suspend fun atlasCreatures(
query: String? = null,
facet: String? = null,
limit: Int = ATLAS_PAGE,
offset: Int = 0,
): ApiResult<AtlasCreaturePageDto> = safeApiCall {
api.getAtlasCreatures(
query = query?.takeIf { it.isNotBlank() },
facet = facet?.takeIf { it.isNotBlank() },
limit = limit,
offset = offset,
)
}
suspend fun atlasCreature(slug: String): ApiResult<AtlasCreatureDto> =
safeApiCall { api.getAtlasCreature(slug) }
// ── Live stream ──────────────────────────────────────────────────────
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
@@ -133,27 +73,9 @@ class ShardRepository @Inject constructor(
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
// Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by
// default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out
// disabled (a live firehose of vendor inventories would be the site's biggest
// bandwidth consumer), so the market screen is a plain paginated read and must never
// wait on a frame.
fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer())
fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer())
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
json.decodeFromJsonElement(serializer, obj)
} catch (_: Exception) {
null
}
companion object {
const val SORT_PRICE_ASC = "price_asc"
const val SORT_PRICE_DESC = "price_desc"
const val SORT_RECENT = "recent"
/** The server caps `limit` at 100; stay well under it on a phone. */
const val MARKET_PAGE = 50
const val ATLAS_PAGE = 50
}
}

View File

@@ -1,164 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* What this BACKEND serves — core's own capabilities and every started module's
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
*
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
*
* The two answer different questions and neither can answer the other's:
*
* - **Capability — is this module installed at all?** Per HOST. It changes when
* an operator installs or removes a module, so it is resolved beside the
* appearance and invalidated on a server switch.
* - **Feature — does this shard publish this surface to this viewer?** Per
* VIEWER. It changes on sign-in, which is why it is resolved on every session
* change.
*
* Without the first, the app cannot tell a module that is **not installed** from
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
* treats as visible. On a site running a different game that renders every shard
* row in the drawer and every one of them 404s when tapped.
*
* ## Absence of an answer is not an answer of absence
*
* The distinction this class exists to make, and the reason [SiteCapabilities]
* carries no "unknown" member of its own — the *absence of the whole value* is
* the unknown state:
*
* - a **successful** read that does not name a capability is an answer, and
* [canUse] hides what needs it;
* - a **failed** read keeps the last answer this host gave, because a moment
* with no connectivity is not an uninstall;
* - a host that has **never** answered leaves the value null, and [canUse]
* passes — the drawer renders as it did before this existed rather than
* flickering its rows in on every cold start.
*
* The last one is deliberately the same fail-open direction [canSee] takes, for
* the same reason: the server gates every call regardless, so the cost of
* guessing wrong is a link that briefly 404s.
*/
@Singleton
class SiteCapabilitiesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
/** The current answer, or `null` while this host has never given one. */
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on resume and the
// connect flow refreshes on first load, and two overlapping reads would race
// to publish.
private val mutex = Mutex()
/**
* Re-resolve what this backend serves.
*
* **Two calls, and one failing is not the same as both failing.** Core's list
* and a module's are separate lists (§2.9), so they are merged from separate
* reads and each is kept only if it answered. A backend released before
* events omits `capabilities` from its `version` block entirely, which is an
* answer — the empty list — and not a failure.
*/
suspend fun refresh() = mutex.withLock {
val status = safeApiCall { api.getStatus() }
val modules = safeApiCall { api.getModules() }
// Neither call answered: keep whatever this host said last, which for a
// host that has never answered is still null.
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
val previous = _capabilities.value
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
?: previous?.core
?: emptySet()
val installed = (modules as? ApiResult.Ok)?.data?.modules
?.flatMap { it.capabilities }
?.toSet()
?: previous?.modules
?: emptySet()
_capabilities.value = SiteCapabilities(core = core, modules = installed)
}
/**
* Drop the answer. Called on a Settings → Server switch: capabilities belong
* to the host that reported them, and the new host must not inherit them —
* a switch between two signed-out hosts changes no session, so nothing else
* would invalidate this.
*/
fun invalidate() {
_capabilities.value = null
}
}
/**
* What one backend serves, as two lists rather than one.
*
* They are kept apart because core is not a module: merging them would leave the
* app unable to tell *"this backend has events"* from *"a module called core
* happens to be installed"*, which is exactly the distinction
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
* entry does not care which half serves it — but the halves stay separable, so a
* future caller that does care still can.
*/
data class SiteCapabilities(
/** Core's own, from the `version` block. Empty on a backend that predates them. */
val core: Set<String>,
/** Every started module's, flattened. Two modules may declare the same string. */
val modules: Set<String>,
) {
/** True when either half names [capability]. */
operator fun contains(capability: String): Boolean =
capability in core || capability in modules
}
/**
* True when [capability] may be relied on — **or when this host has not answered
* yet**.
*
* The null case is the fail-open one and it is not the same as the empty one: a
* [SiteCapabilities] that names nothing is a backend that told us it serves
* nothing extra, and that hides. See the class doc above.
*
* `null` [capability] means the caller declared none, which always passes.
*/
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
capability == null || capabilities == null || capability in capabilities
/**
* The capability strings the app gates on.
*
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
* its own would be a second, worse copy of what the per-viewer feature flags
* already decide — and one that drifts, because a capability is opaque to core
* and nothing checks the two agree. One string answers the only question a
* capability can: is the module there.
*/
object Capability {
/**
* A game module serving a live shard. Declared by `module-uo`; a different
* game's module that serves the same surfaces would declare it too, which is
* the point of an opaque string.
*/
const val SHARD = "shard"
/** Core's event system (events Phase 14a). Never a module's. */
const val EVENTS = "events"
}

View File

@@ -9,15 +9,11 @@ import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.EventsApi
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi
@@ -97,12 +93,6 @@ object NetworkModule {
@Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
@Provides
@Singleton
fun provideShardStream(client: ShardStreamClient): ShardStream = client
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
@@ -123,26 +113,12 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java)
/**
* The event surface (§9 M13). Three public reads and one bearer-authed player
* read on one interface — they are all CORE routes, so none of them is a
* module path and none is under `/shard`.
*/
@Provides
@Singleton
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
@Provides
@Singleton
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
retrofit.create(NotificationsApi::class.java)
/** Staff operations (§1, §6.4, M10) — bearer-authed; the server re-checks role every call. */
@Provides
@Singleton
fun provideAdminApi(retrofit: Retrofit): AdminApi = retrofit.create(AdminApi::class.java)
/**
* Token refresh runs on its own **bare** client — UA + host retargeting only,
* no auth interceptor and no authenticator — so a refresh can never recurse

View File

@@ -3,24 +3,15 @@
*/
package com.runicgateway.app.di
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore
import com.runicgateway.app.core.inbox.DataStoreInboxCache
import com.runicgateway.app.core.inbox.InboxCache
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/** Binds the at-rest stores to their implementations — EncryptedSharedPreferences
* for anything secret (§4.3), plain DataStore for the inbox snapshot. */
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
@Module
@InstallIn(SingletonComponent::class)
abstract class StorageModule {
@@ -28,22 +19,4 @@ abstract class StorageModule {
@Binds
@Singleton
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
@Binds
@Singleton
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
@Binds
@Singleton
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
@Binds
@Singleton
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
/** The inbox's offline snapshot — plain DataStore, not encrypted (ENGAGEMENT.md phase 8). */
@Binds
@Singleton
abstract fun bindInboxCache(impl: DataStoreInboxCache): InboxCache
}

View File

@@ -8,10 +8,9 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -21,8 +20,8 @@ import javax.inject.Inject
/**
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
* the drawer are built from. Activity-scoped so the whole app observes one state.
* or the main UI shows, and holds the per-shard branding the theme is seeded
* from. Activity-scoped so the whole app observes one state.
*/
@HiltViewModel
class AppViewModel @Inject constructor(
@@ -30,7 +29,6 @@ class AppViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
private val pushManager: PushManager,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
sealed interface AppState {
@@ -40,11 +38,8 @@ class AppViewModel @Inject constructor(
/** No shard site configured yet — show the connect screen. */
data object NeedsConnection : AppState
/**
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
* couldn't be loaded — the shipped app, still fully usable (§2).
*/
data class Ready(val appearance: SiteAppearance) : AppState
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
data class Ready(val brand: BrandDto?) : AppState
}
private val _state = MutableStateFlow<AppState>(AppState.Loading)
@@ -53,7 +48,7 @@ class AppViewModel @Inject constructor(
init {
viewModelScope.launch {
_state.value = if (connectionRepository.restore()) {
AppState.Ready(loadAppearance())
AppState.Ready(loadBrand())
} else {
AppState.NeedsConnection
}
@@ -62,36 +57,7 @@ class AppViewModel @Inject constructor(
/** Called by the connect screen once a site has been validated + saved. */
fun onConnected() {
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
}
/**
* Re-read the appearance while the app is already running — on resume, beside
* the session's own re-validation (§5.5). An admin who re-skins the site from
* a laptop and picks the phone up should see it.
*
* Best-effort, and silent either way: a failed refresh **keeps the last good
* appearance** rather than dropping back to the shipped one, so a moment of
* no connectivity does not repaint a themed shard. There is no loading state
* and no error surface. Ignored unless a site is configured.
*/
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
viewModelScope.launch {
// What the backend SERVES is a per-host fact and refreshes on the same
// clock as the appearance: an operator who installs a module while the
// app is backgrounded should see its rows on the next resume. Done
// before the early return below, because a failed settings read is no
// reason to skip it — they are separate calls to separate routes.
siteCapabilitiesRepository.refresh()
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// changeServer() may have raced us back to the connect screen while the
// call was in flight; don't resurrect Ready on top of it.
if (_state.value is AppState.Ready) {
_state.value = AppState.Ready(SiteAppearance.from(settings))
}
}
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
}
/** Settings → Server switch: hard reset back to the connect screen (§3). */
@@ -103,15 +69,14 @@ class AppViewModel @Inject constructor(
}
/**
* Load public settings for the appearance and feed the shard's push relay URL into
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
* Load public settings for branding and feed the shard's push relay URL into the
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
*/
private suspend fun loadAppearance(): SiteAppearance {
siteCapabilitiesRepository.refresh()
private suspend fun loadBrand(): BrandDto? {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return SiteAppearance.from(settings)
return settings?.brand
}
/**

View File

@@ -3,16 +3,11 @@
*/
package com.runicgateway.app.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -23,7 +18,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -35,10 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -54,55 +45,30 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.events.EventScreen
import com.runicgateway.app.ui.events.EventSeriesScreen
import com.runicgateway.app.ui.events.EventsScreen
import com.runicgateway.app.ui.events.MyEventsScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.buildNavTree
import com.runicgateway.app.ui.navigation.isEntryVisible
import com.runicgateway.app.ui.navigation.pruneNav
import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.admin.AdminContentScreen
import com.runicgateway.app.ui.admin.AdminDashboardScreen
import com.runicgateway.app.ui.admin.AdminModerationScreen
import com.runicgateway.app.ui.admin.AdminSupportScreen
import com.runicgateway.app.ui.notifications.InboxBadgeViewModel
import com.runicgateway.app.ui.notifications.InboxScreen
import com.runicgateway.app.ui.notifications.NotificationSettingsScreen
import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen
import com.runicgateway.app.ui.player.MyHousesScreen
import com.runicgateway.app.ui.player.VendorsScreen
import com.runicgateway.app.ui.session.SessionViewModel
import com.runicgateway.app.ui.shard.AtlasCreatureScreen
import com.runicgateway.app.ui.shard.AtlasScreen
import com.runicgateway.app.ui.shard.ChampsScreen
import com.runicgateway.app.ui.shard.GovernorsScreen
import com.runicgateway.app.ui.shard.GuildsScreen
import com.runicgateway.app.ui.shard.HousesScreen
import com.runicgateway.app.ui.shard.LeaderboardsScreen
import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch
@@ -110,16 +76,8 @@ import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
// Protocol 3.0 content screens are drawer destinations, so the drawer gesture works
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
// Events (M13): the calendar and the history are drawer rows, so the drawer
// gesture works on them. The event page and an arc are detail screens and are
// deliberately absent — a back gesture there means "back", not "open the menu".
Routes.EVENTS, Routes.MY_EVENTS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
/**
@@ -132,47 +90,29 @@ private val TOP_LEVEL_ROUTES = setOf(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RunicApp(
appearance: SiteAppearance,
brand: BrandDto?,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
deepLinkStream: String? = null,
deepLinkRef: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// What this BACKEND serves at all, independently of both (§5, M13). A different
// question from the line above and gated separately — see `isEntryVisible`.
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// 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.
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
sessionViewModel.revalidate()
inboxBadgeViewModel.refresh()
onPauseOrDispose { }
}
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
// The badge follows the session, so signing out clears it rather than leaving
// the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
// A tapped push notification deep-links to its stream's screen (§11, item 7).
LaunchedEffect(deepLinkStream, deepLinkRef) {
LaunchedEffect(deepLinkStream) {
val stream = deepLinkStream ?: return@LaunchedEffect
// 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)) {
navController.navigate(Routes.forStream(stream)) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
}
@@ -180,37 +120,9 @@ fun RunicApp(
}
val backStackEntry by navController.currentBackStackEntryAsState()
// A destination's route is its NavHost *pattern*, so News reports
// "news?category={category}" (§6.2). Compare on the part before the query.
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
// The admin's nav overrides, then the gates — never the other way round. An
// override is presentation only: it may relabel, reorder, group and hide, so
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures, capabilities)
}
val context = LocalContext.current
// An added link's path is site-relative; a hand-off needs it absolute against
// the configured base URL, which is exactly what the asset resolver does (§6.3).
val resolveUrl = LocalAssetResolver.current
val openNode: (NavNode) -> Unit = { node ->
scope.launch { drawerState.close() }
when (node) {
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
// A link the app resolved opens like any other drawer row, detail screen
// or not: one rule, and back-press lands on Home as it does from every
// row. One it could not resolve goes to the browser, absolute against
// the site's base URL (§6.3).
is NavNode.Link -> node.route
?.let { navController.navigateTopLevel(it) }
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
// Section headers aren't clickable — the group is always open (§6.3).
is NavNode.Section -> Unit
}
}
val entries = visibleEntries(APP_MENU, session)
ModalNavigationDrawer(
drawerState = drawerState,
@@ -222,20 +134,7 @@ fun RunicApp(
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
)
// Scroll the drawer: a signed-in session adds Account, Notifications, and
// the player groups, and the full list overflows a phone's drawer height —
// without this the lower entries (Notifications included) are clipped and
// unreachable. See RunicGateway M10.
Column(Modifier.verticalScroll(rememberScrollState())) {
Spacer(Modifier.height(12.dp))
// The instance's logo above its name (§5.6). Decorative — the name
// is the very next line — and absent on an instance that uploaded
// none, in which case the header is exactly what it was before M12.
BrandLogo(
logo = brand?.logo,
height = 32.dp,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp),
)
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
@@ -244,34 +143,17 @@ fun RunicApp(
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
nav.forEach { node ->
if (node is NavNode.Section) {
// A group the admin created: its label as a header, its rows
// beneath it. Always open — a drawer is already a vertical
// list, so the website's dropdown does not translate (§6.3).
Text(
text = node.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 28.dp,
end = 28.dp,
top = 12.dp,
bottom = 4.dp,
),
)
node.items.forEach { child ->
NavRow(
node = child,
currentRoute = currentRoute,
colors = drawerItemColors,
indented = true,
unread = unread,
) { openNode(child) }
}
} else {
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
}
entries.forEach { entry ->
NavigationDrawerItem(
label = { Text(stringResource(entry.labelRes)) },
selected = currentRoute == entry.route,
onClick = {
scope.launch { drawerState.close() }
navController.navigateTopLevel(entry.route)
},
colors = drawerItemColors,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -295,7 +177,6 @@ fun RunicApp(
}
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
@@ -306,10 +187,8 @@ fun RunicApp(
onChangeServer()
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
}
},
) {
@@ -324,24 +203,13 @@ fun RunicApp(
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
title = {
val name = brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name)
// The logo stands in for the title here, so unlike the drawer's
// it is named for a screen reader — and it falls back to the
// text when the instance has no logo or the load fails (§5.6).
BrandLogo(
logo = brand?.logo,
height = 24.dp,
contentDescription = name,
) {
Text(
text = name.uppercase(),
style = MaterialTheme.typography.titleSmall
.copy(letterSpacing = 1.2.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = (brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name)).uppercase(),
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
if (isTopLevel) {
@@ -372,80 +240,6 @@ fun RunicApp(
}
}
/**
* One drawer row: a coded entry, or an admin's added link (§6.3).
*
* A link that the app can open natively is deliberately indistinguishable from a
* coded row — that is the point of resolving it. One that hands off to the browser
* carries a trailing icon, so leaving the app is never a surprise.
*/
@Composable
private fun NavRow(
node: NavNode,
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
unread: Int = 0,
onClick: () -> Unit,
) {
val route = when (node) {
is NavNode.Item -> node.entry.route
is NavNode.Link -> node.route
is NavNode.Section -> null
}
val label = when (node) {
// An admin's label wins over the bundled one, and is the same string in
// every locale — see MenuEntry.label.
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
is NavNode.Link -> node.label
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
// The unread count rides on whichever row leads to the inbox — including an
// admin's own nav override pointing at it, since the badge belongs to the
// destination, not to the bundled entry.
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = when {
handsOff -> {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
}
showsUnread -> {
{
// Named for a screen reader: "7" beside "Notifications" reads as
// a count to a sighted user and as a bare number to everyone else.
val spoken = stringResource(R.string.inbox_unread_count, unread)
Text(
text = unread.toString(),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.semantics { contentDescription = spoken },
)
}
}
else -> null
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
// argument (CircleShape) rather than from the theme, so --radius-pill has to
// be handed to it at every call site or the selected row stays fully round
// while every other radius follows the shard (phase 8's AC-5 walk).
shape = LocalShardStructure.current.pill,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
.padding(start = if (indented) 16.dp else 0.dp),
)
}
@Composable
private fun RunicNavHost(
navController: NavHostController,
@@ -463,19 +257,7 @@ private fun RunicNavHost(
composable(Routes.HOME) {
HomeScreen(brand = brand)
}
// The category is optional: navigating to plain Routes.NEWS matches this
// pattern with no argument and opens the default tab, which is every route
// into the screen except an admin's nav override or added link (§6.2).
composable(
route = Routes.NEWS_ROUTE,
arguments = listOf(
navArgument(Routes.Args.CATEGORY) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
composable(Routes.NEWS) {
NewsScreen(onOpenPost = { category, idOrSlug ->
navController.navigate(Routes.post(category, idOrSlug))
})
@@ -489,50 +271,6 @@ private fun RunicNavHost(
) {
PostScreen()
}
// Events (M13). CORE's routes, so these screens are reachable on a backend
// with no game module at all — which is why they sit above the shard block
// rather than inside it.
composable(Routes.EVENTS) {
EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
// The app's one route with a query argument. `run` is optional and nullable:
// navigating to Routes.event(slug) with no run matches this pattern with no
// argument, which is every route in except an announcement's link.
composable(
route = Routes.EVENT_ROUTE,
arguments = listOf(
navArgument(Routes.Args.SLUG) { type = NavType.StringType },
navArgument(Routes.Args.RUN) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
EventScreen(
onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) },
onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
},
)
}
composable(
route = Routes.EVENT_SERIES,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) {
EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
}
composable(Routes.MY_EVENTS) {
// Signed out, this route is not in the drawer — but a saved back-stack
// entry can still be restored onto it, so the shell says where to go
// rather than letting the screen ask the server and render a 401.
when (session) {
is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId ->
navController.navigate(Routes.event(slug, runId.toString()))
})
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.SHARD) {
ShardScreen(onOpenBoard = { board ->
navController.navigate(
@@ -549,30 +287,6 @@ private fun RunicNavHost(
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
composable(Routes.SHARD_HOUSES) { HousesScreen() }
// Protocol 3.0 shard content (M11). Each screen self-reports "not published
// here" from its own 404/403, so a deep link to a gated feature still lands on
// an honest answer even though the menu hides the entry.
composable(Routes.SHARD_RULES) { RulesScreen() }
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
composable(Routes.SHARD_MARKET) {
MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
}
composable(
route = Routes.SHARD_MARKET_VENDOR,
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
) { entry ->
MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty())
}
composable(Routes.ATLAS) {
AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) })
}
composable(
route = Routes.ATLAS_CREATURE,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) { entry ->
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}
@@ -592,15 +306,7 @@ private fun RunicNavHost(
ContactScreen()
}
composable(Routes.LOGIN) {
// Leave the login screen as soon as the session is established — whether by
// password or the SSO bridge. Keying off the shared session (not just the
// login VM's local flag) makes this robust to the deep-link/recomposition
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
if (session is Session.SignedIn) {
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
} else {
LoginScreen(onSignedIn = { navController.popBackStack() })
}
LoginScreen(onSignedIn = { navController.popBackStack() })
}
composable(Routes.ACCOUNT) {
// Only meaningful while signed in; a sign-out (here or from the drawer)
@@ -611,47 +317,17 @@ private fun RunicNavHost(
roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
)
Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME)
}
}
}
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
when (session) {
is Session.SignedIn -> TrustedDevicesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than
// 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.
// leaving stale settings up. The backend gates every call regardless (§5).
when (session) {
is Session.SignedIn -> InboxScreen(
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
// A notification whose link the app can render opens in the app.
// `navigate`, not `navigateTopLevel`: the inbox is where the
// reader came from and back should return there.
onOpenRoute = { route -> navController.navigate(route) },
)
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS_SETTINGS) {
when (session) {
is Session.SignedIn -> NotificationSettingsScreen()
is Session.SignedIn -> NotificationsScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
@@ -676,24 +352,6 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() }
}
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
// The backend re-checks role on every /admin/… call; these gates only mirror
// the menu's visibility so a signed-out/demoted user isn't left on a stale screen.
composable(Routes.ADMIN_DASHBOARD) {
StaffGate(session, navController) {
AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
}
}
composable(Routes.ADMIN_CONTENT) {
StaffGate(session, navController) { AdminContentScreen() }
}
composable(Routes.ADMIN_MODERATION) {
StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
}
composable(Routes.ADMIN_SUPPORT) {
StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
}
}
}
@@ -715,23 +373,6 @@ private fun PlayerGate(
}
}
/**
* The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for
* a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is
* sent home rather than left on a stale admin screen. The backend is the authority —
* every `/admin/…` call re-checks role — so this only mirrors the menu's visibility.
*/
@Composable
private fun StaffGate(
session: Session,
navController: NavHostController,
require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
content: @Composable () -> Unit,
) {
val ok = (session as? Session.SignedIn)?.user?.let(require) == true
if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
/** Navigate to a top-level menu destination: single instance, reset to it. */
private fun NavHostController.navigateTopLevel(route: String) {
navigate(route) {

View File

@@ -34,13 +34,6 @@ enum class ErrorKind {
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
SHARD_OFFLINE,
/**
* This shard doesn't publish the surface, or doesn't publish it to this viewer
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
* may well be up, and retrying changes nothing — an admin decides this.
*/
FEATURE_UNAVAILABLE,
/** Any other non-2xx server response. */
SERVER,
}
@@ -59,27 +52,3 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
httpStatus = status,
)
}
/**
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
*
* The website's `requireFeature` gate answers `404` when a feature is switched off —
* deliberately, so the response doesn't disclose that the surface exists — and `403`
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
* On these routes a `404` therefore almost never means "no such thing"; it means this
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
* invite the user to retry something an admin controls.
*
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
* mean something else off the shard surface: `404` is a genuinely missing item (a
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
* player or admin route, which is not an admin's visibility setting.
*/
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()
}
else -> toUiState()
}

View File

@@ -1,291 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two
* tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…`
* routes; the CMS block/hero editor stays out of scope. Any staff role; the server
* re-checks on every call.
*/
@Composable
fun AdminContentScreen(
modifier: Modifier = Modifier,
viewModel: AdminContentViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var tab by rememberSaveable { mutableIntStateOf(0) }
var showNewPost by rememberSaveable { mutableStateOf(false) }
var showNewCategory by rememberSaveable { mutableStateOf(false) }
Column(modifier.fillMaxSize()) {
TabRow(selectedTabIndex = tab) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) })
}
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (tab) {
0 -> PostsTab(
state = state.posts,
busy = state.busy,
onNew = { showNewPost = true },
onToggle = viewModel::togglePublish,
onDelete = viewModel::deletePost,
onRetry = viewModel::loadPosts,
)
else -> WikiTab(
state = state.categories,
tags = state.tags,
busy = state.busy,
onNew = { showNewCategory = true },
onDelete = viewModel::deleteCategory,
onRetry = viewModel::loadWiki,
)
}
}
if (showNewPost) {
NewPostDialog(
categories = viewModel.postCategories,
onDismiss = { showNewPost = false },
onCreate = { cat, title, excerpt, body, published ->
viewModel.createPost(cat, title, excerpt, body, published)
showNewPost = false
},
)
}
if (showNewCategory) {
NewCategoryDialog(
onDismiss = { showNewCategory = false },
onCreate = { slug, title, desc, sort ->
viewModel.createCategory(slug, title, desc, sort)
showNewCategory = false
},
)
}
}
@Composable
private fun PostsTab(
state: UiState<List<AdminPostDto>>,
busy: Boolean,
onNew: () -> Unit,
onToggle: (AdminPostDto) -> Unit,
onDelete: (Long) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(stringResource(R.string.admin_content_new_post))
}
}
items(state.data, key = { it.id }) { post ->
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StatusPill(
text = if (post.isPublished) stringResource(R.string.admin_content_published)
else stringResource(R.string.admin_content_draft),
tone = if (post.isPublished) PillTone.Success else PillTone.Neutral,
)
Spacer(Modifier.width(8.dp))
Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onToggle(post) }, enabled = !busy) {
Text(
stringResource(
if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish,
),
)
}
TextButton(onClick = { onDelete(post.id) }, enabled = !busy) {
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
}
}
}
@Composable
private fun WikiTab(
state: UiState<List<AdminWikiCategoryDto>>,
tags: List<AdminWikiTagDto>,
busy: Boolean,
onNew: () -> Unit,
onDelete: (Long) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(stringResource(R.string.admin_content_new_category))
}
}
items(state.data, key = { it.id }) { cat ->
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) {
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
if (tags.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 12.dp))
Text(
stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun NewPostDialog(
categories: List<String>,
onDismiss: () -> Unit,
onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit,
) {
var category by rememberSaveable { mutableStateOf(categories.first()) }
var title by rememberSaveable { mutableStateOf("") }
var excerpt by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
var published by rememberSaveable { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) {
Text(stringResource(R.string.admin_content_create))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_content_new_post)) },
text = {
Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
categories.forEach { c ->
FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) })
}
}
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f))
Switch(checked = published, onCheckedChange = { published = it })
}
}
},
)
}
@Composable
private fun NewCategoryDialog(
onDismiss: () -> Unit,
onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit,
) {
var slug by rememberSaveable { mutableStateOf("") }
var title by rememberSaveable { mutableStateOf("") }
var description by rememberSaveable { mutableStateOf("") }
var sort by rememberSaveable { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) {
Text(stringResource(R.string.admin_content_create))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_content_new_category)) },
text = {
Column {
OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
}
},
)
}

View File

@@ -1,140 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.repository.AdminRepository
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 staff content screen (PLAN.md §1, M10): news posts (list, create,
* publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete
* category). Any staff role reaches these (`staffOnly`); the full CMS block/hero
* editor stays out of scope. Reads go through the typed [AdminRepository] (§7).
*/
@HiltViewModel
class AdminContentViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
/** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */
val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots")
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val posts: UiState<List<AdminPostDto>> = UiState.Loading,
val categories: UiState<List<AdminWikiCategoryDto>> = UiState.Loading,
val tags: List<AdminWikiTagDto> = emptyList(),
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
loadPosts()
loadWiki()
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun loadPosts() {
_state.update { it.copy(posts = UiState.Loading) }
viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } }
}
fun loadWiki() {
_state.update { it.copy(categories = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(categories = admin.wikiCategories().toUiState()) }
when (val tags = admin.wikiTags()) {
is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) }
else -> Unit // tags are secondary; leave the last list on a failure
}
}
}
fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) {
admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated)
}
fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) {
admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted)
}
fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) {
if (title.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) }
return
}
mutate(onSuccess = ::loadPosts) {
admin.createPost(
PostCreateRequest(
category = category,
title = title.trim(),
excerpt = excerpt.ifBlank { null },
body = body.ifBlank { null },
published = published,
),
).asFeedback(R.string.admin_content_post_created)
}
}
fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) {
if (slug.isBlank() || title.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) }
return
}
mutate(onSuccess = ::loadWiki) {
admin.createWikiCategory(
WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder),
).asFeedback(R.string.admin_content_cat_created)
}
}
fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) {
admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted)
}
// ── Shared mutation plumbing ──────────────────────────────────────────
/** Run a write: set busy + clear feedback, then on completion set the feedback
* banner and, only if it succeeded, run [onSuccess] (a targeted reload). */
private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = block()
if (feedback.ok) onSuccess()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError ->
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
}

View File

@@ -1,169 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.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.AdminDashboardDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.StatusPill
/**
* The staff dashboard (PLAN.md §1, M10): site mode + a site-mode toggle (admins
* only), summary counts, and recent admin activity. Read-only for moderators/editors;
* only [isAdmin] callers see the maintenance switch, and the server enforces it too.
*/
@Composable
fun AdminDashboardScreen(
isAdmin: Boolean,
modifier: Modifier = Modifier,
viewModel: AdminDashboardViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val ds = state.dashboard) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(ds.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> DashboardContent(
data = ds.data,
isAdmin = isAdmin,
switching = state.switching,
feedbackRes = state.feedback?.messageRes,
onSetMode = viewModel::setSiteMode,
modifier = modifier,
)
}
}
@Composable
private fun DashboardContent(
data: AdminDashboardDto,
isAdmin: Boolean,
switching: Boolean,
feedbackRes: Int?,
onSetMode: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val live = data.siteMode.equals("live", ignoreCase = true)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
// ── Site status ──────────────────────────────────────────────
SectionLabel(stringResource(R.string.admin_dashboard_site))
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StatusPill(
text = if (live) stringResource(R.string.admin_site_live) else stringResource(R.string.admin_site_maintenance),
tone = if (live) PillTone.Success else PillTone.Warning,
)
data.lastChange.by?.takeIf { it.isNotBlank() }?.let { by ->
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.admin_site_changed_by, by),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (isAdmin) {
Spacer(Modifier.height(12.dp))
Button(
onClick = { onSetMode(if (live) "maintenance" else "live") },
enabled = !switching,
modifier = Modifier.fillMaxWidth(),
) {
if (switching) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp))
} else {
Text(
stringResource(
if (live) R.string.admin_site_switch_maintenance else R.string.admin_site_switch_live,
),
)
}
}
}
feedbackRes?.let {
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Counts ───────────────────────────────────────────────────
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_dashboard_counts))
Spacer(Modifier.height(8.dp))
StatRow(stringResource(R.string.admin_count_users), data.counts.users.toString())
val totalPosts = data.counts.posts.values.sum()
StatRow(stringResource(R.string.admin_count_posts), totalPosts.toString())
data.counts.posts.forEach { (category, count) ->
StatRow("· $category", count.toString())
}
// ── Recent activity ──────────────────────────────────────────
if (data.recentActivity.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_dashboard_recent_activity))
Spacer(Modifier.height(8.dp))
data.recentActivity.forEach { row ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(row.action, style = MaterialTheme.typography.bodyMedium)
val meta = listOfNotNull(row.username, row.createdAt).joinToString(" · ")
if (meta.isNotBlank()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun StatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

View File

@@ -1,91 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.repository.AdminRepository
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 staff dashboard (PLAN.md §1, M10): summary counts + the site-mode
* toggle. The mode switch is admin-only server-side (`adminOnly`); the screen only
* offers it to admins, but a `403` is still handled cleanly if a moderator reaches
* it. Everything is read through the typed [AdminRepository] (§7).
*/
@HiltViewModel
class AdminDashboardViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val dashboard: UiState<AdminDashboardDto> = UiState.Loading,
/** True while a site-mode switch is in flight (disables the control). */
val switching: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(dashboard = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(dashboard = admin.dashboard().toUiState()) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/** Switch the site between "live" and "maintenance" (admin only). */
fun setSiteMode(mode: String) {
if (_state.value.switching) return
_state.update { it.copy(switching = true, feedback = null) }
viewModelScope.launch {
when (val result = admin.setSiteMode(mode)) {
is ApiResult.Ok -> {
// Reflect the new mode locally, then refresh the full summary.
val current = _state.value.dashboard
if (current is UiState.Success) {
_state.update {
it.copy(dashboard = UiState.Success(current.data.copy(siteMode = result.data.siteMode)))
}
}
_state.update { it.copy(switching = false, feedback = Feedback(true, R.string.admin_site_mode_updated)) }
load()
}
is ApiResult.HttpError ->
_state.update {
it.copy(
switching = false,
feedback = Feedback(
false,
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
),
)
}
is ApiResult.NetworkError ->
_state.update { it.copy(switching = false, feedback = Feedback(false, R.string.error_network)) }
}
}
}
}

View File

@@ -1,92 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.ui.components.SectionLabel
/**
* The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and
* broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required;
* offline, actions return a clean "shard offline" message. Fields are entered here;
* the [AdminModerationViewModel] performs the guarded action.
*/
@Composable
fun AdminModerationScreen(
modifier: Modifier = Modifier,
viewModel: AdminModerationViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var account by rememberSaveable { mutableStateOf("") }
var serial by rememberSaveable { mutableStateOf("") }
var reason by rememberSaveable { mutableStateOf("") }
var duration by rememberSaveable { mutableStateOf("") }
var broadcast by rememberSaveable { mutableStateOf("") }
val busy = state.busy
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
) {
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
// ── Account actions ──────────────────────────────────────────────
SectionLabel(stringResource(R.string.admin_mod_account_action))
OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_kick))
}
Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_ban))
}
OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_unban))
}
}
// ── Broadcast ────────────────────────────────────────────────────
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_mod_broadcast_section))
OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.admin_mod_broadcast))
}
}
}

View File

@@ -1,89 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.AdminRepository
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 moderation actions (PLAN.md §1, M10): kick / ban / unban an account
* and broadcast a system message, over the shard write plane (`/admin/shard/…`,
* admin/moderator). These need a live sidecar — when the shard is offline the call
* fails and the screen shows a clean error, never a crash (§7). The form fields live
* in the screen; this VM owns only the busy + feedback state and the actions.
*/
@HiltViewModel
class AdminModerationViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(val busy: Boolean = false, val feedback: Feedback? = null)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun kick(account: String, serial: String) {
if (account.isBlank() && serial.isBlank()) return badTarget()
run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) }
}
fun ban(account: String, serial: String, durationSec: Long?, reason: String) {
if (account.isBlank() && serial.isBlank()) return badTarget()
run(R.string.admin_mod_banned) {
admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null })
}
}
fun unban(account: String) {
if (account.isBlank()) return badTarget()
run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) }
}
fun broadcast(text: String, hue: Int?) {
if (text.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) }
return
}
run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) }
}
private fun badTarget() {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) }
}
private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (val r = block()) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
}

View File

@@ -1,145 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.SupportPageDto
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 support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
* over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages
* (or the shard is offline); every read/write degrades cleanly (§7).
*/
@Composable
fun AdminSupportScreen(
modifier: Modifier = Modifier,
viewModel: AdminSupportViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var replyTo by remember { mutableStateOf<SupportPageDto?>(null) }
Column(modifier.fillMaxSize()) {
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (val s = state.pages) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success ->
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.admin_support_empty))
} else {
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
items(s.data, key = { it.pageId }) { page ->
SupportPageCard(
page = page,
busy = state.busy,
onReply = { replyTo = page },
onClose = { viewModel.close(page.pageId) },
)
}
}
}
}
}
replyTo?.let { page ->
RespondDialog(
onDismiss = { replyTo = null },
onSend = { message, close ->
viewModel.respond(page.pageId, message, close)
replyTo = null
},
)
}
}
@Composable
private fun SupportPageCard(
page: SupportPageDto,
busy: Boolean,
onReply: () -> Unit,
onClose: () -> Unit,
) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
Text(
text = listOfNotNull(page.type, who).joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
)
page.message?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(4.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) }
TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) }
}
}
}
}
@Composable
private fun RespondDialog(
onDismiss: () -> Unit,
onSend: (message: String, close: Boolean) -> Unit,
) {
var message by rememberSaveable { mutableStateOf("") }
var alsoClose by rememberSaveable { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_support_reply)) },
text = {
Column {
OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it })
Text(stringResource(R.string.admin_support_close_after))
}
}
},
)
}

View File

@@ -1,87 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.repository.AdminRepository
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 support (help-page) queue (PLAN.md §1, M10): list open pages, reply
* (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator).
* The list is served from shard state — empty when no tickets (or the shard is
* offline); writes need a live sidecar and fail cleanly otherwise (§7).
*/
@HiltViewModel
class AdminSupportViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val pages: UiState<List<SupportPageDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun load() {
_state.update { it.copy(pages = UiState.Loading) }
viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } }
}
fun respond(id: String, message: String, close: Boolean) {
if (message.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) }
return
}
mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) }
}
fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) }
private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (val r = block()) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
404 -> R.string.admin_support_unknown_page
503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
if (feedback.ok) load()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
}

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -50,7 +51,6 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -65,8 +65,6 @@ fun AccountScreen(
roleLabel: String,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(),
) {
@@ -80,15 +78,10 @@ fun AccountScreen(
) {
IdentityCard(username = username, roleLabel = roleLabel)
// One-time recovery codes surfaced right after enabling 2FA — save them now.
state.recoveryCodesOnce?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
}
when (val account = state.account) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
is UiState.Success -> AccountSections(account.data, state, viewModel)
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
@@ -107,7 +100,7 @@ fun AccountScreen(
@Composable
private fun IdentityCard(username: String, roleLabel: String) {
ShardCard(Modifier.fillMaxWidth()) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp)) {
Text(text = username, style = MaterialTheme.typography.titleLarge)
StatusPill(
@@ -124,37 +117,16 @@ private fun AccountSections(
account: PlayerAccountDto,
state: AccountViewModel.State,
viewModel: AccountViewModel,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
) {
UsernameSection(account, state, viewModel)
PasswordSection(account, state, viewModel)
TwoFactorSection(account, state, viewModel)
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
IdentitiesSection(state, viewModel)
}
/**
* Links to the dedicated trusted-device and recovery-code screens
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
*/
@Composable
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
SectionCard(R.string.account_security_title) {
OutlinedButton(
onClick = onOpenTrustedDevices,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_security_trusted_devices)) }
OutlinedButton(
onClick = onOpenRecoveryCodes,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.account_security_recovery_codes)) }
}
}
@Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()

View File

@@ -49,8 +49,6 @@ class AccountViewModel @Inject constructor(
val busy: Boolean = false,
/** The pending TOTP enrollment (QR shown) between setup and enable. */
val totpSetup: TotpSetupDto? = null,
/** The single-use recovery codes returned once when 2FA was just enabled. */
val recoveryCodesOnce: List<String>? = null,
val feedback: Feedback? = null,
)
@@ -124,12 +122,9 @@ class AccountViewModel @Inject constructor(
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = accountRepository.totpEnable(code.trim())) {
when (accountRepository.totpEnable(code.trim())) {
is ApiResult.Ok -> {
// 2FA enable returns the fresh recovery-code batch once — surface it.
_state.update {
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
}
_state.update { it.copy(totpSetup = null) }
finish(Section.TOTP, true, R.string.account_totp_enabled)
reloadAccount()
}
@@ -139,9 +134,6 @@ class AccountViewModel @Inject constructor(
}
}
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
fun disableTotp(code: String) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }

View File

@@ -5,11 +5,8 @@ package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
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.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -17,22 +14,15 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -125,73 +115,22 @@ fun LoginScreen(
)
if (state.totpRequired) {
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = viewModel::onRecoveryCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_recovery_code)) },
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
} else {
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_totp_code)) },
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
// Toggle between authenticator code and a single-use recovery code.
TextButton(
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
singleLine = true,
enabled = !state.submitting,
modifier = Modifier.align(Alignment.Start),
) {
Text(
stringResource(
if (state.useRecoveryCode) R.string.login_use_totp_instead
else R.string.login_use_recovery_instead,
),
)
}
// "Trust this device" → skip the 2FA step on future logins here.
Row(
verticalAlignment = Alignment.CenterVertically,
label = { Text(stringResource(R.string.login_totp_code)) },
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Checkbox(
checked = state.trustDevice,
onCheckedChange = viewModel::onTrustDeviceChange,
enabled = !state.submitting,
)
Text(
text = stringResource(R.string.login_trust_device),
style = MaterialTheme.typography.bodyMedium,
)
}
.padding(top = 12.dp),
)
}
state.error?.let { err ->
@@ -224,46 +163,27 @@ fun LoginScreen(
}
}
// ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the
// Custom-Tab bridge. With one provider it launches straight through; with
// several it presents a native picker (below). No website-login fallback —
// that page can't deep-link the session back; a failed discovery offers a retry.
var showSsoPicker by remember { mutableStateOf(false) }
when {
state.ssoProviders.isNotEmpty() -> {
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the
// Custom-Tab bridge and returns the user signed in. Falls back to the
// website login hand-off when the shard exposes no providers.
if (state.ssoProviders.isNotEmpty()) {
state.ssoProviders.forEach { provider ->
OutlinedButton(
onClick = {
val providers = state.ssoProviders
if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
else showSsoPicker = true
},
onClick = { viewModel.onSsoProviderClick(provider) },
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_button))
Text(stringResource(R.string.login_sso_provider, provider.name))
}
}
state.ssoDiscovering -> {
Text(
text = stringResource(R.string.login_sso_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
state.ssoUnavailable -> {
TextButton(
onClick = { viewModel.discoverSsoProviders() },
modifier = Modifier.padding(top = 4.dp),
) {
Text(stringResource(R.string.login_sso_retry))
} else {
viewModel.ssoLoginUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_sso))
}
}
// else: discovery succeeded with no providers — this shard offers no SSO.
}
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
@@ -278,53 +198,6 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot))
}
}
if (showSsoPicker) {
SsoProviderPicker(
providers = state.ssoProviders,
onDismiss = { showSsoPicker = false },
onPick = { provider ->
showSsoPicker = false
viewModel.onSsoProviderClick(provider)
},
)
}
}
}
/**
* The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO
* providers so a single "Sign in with SSO" button can serve several IdPs without a
* website chooser page. Each row opens the Custom-Tab bridge for that provider.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SsoProviderPicker(
providers: List<com.runicgateway.app.data.api.dto.SsoProviderDto>,
onDismiss: () -> Unit,
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
Text(
text = stringResource(R.string.login_sso_pick_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
providers.forEach { provider ->
TextButton(
onClick = { onPick(provider) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.login_sso_provider, provider.name),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
)
}
}
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
}
}

View File

@@ -10,7 +10,6 @@ import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,23 +38,13 @@ class LoginViewModel @Inject constructor(
val username: String = "",
val password: String = "",
val code: String = "",
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
val recoveryCode: String = "",
/** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false,
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
val useRecoveryCode: Boolean = false,
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
val trustDevice: Boolean = false,
val submitting: Boolean = false,
val error: LoginError? = null,
val signedIn: Boolean = false,
/** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */
val ssoProviders: List<SsoProviderDto> = emptyList(),
/** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
val ssoDiscovering: Boolean = true,
/** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
val ssoUnavailable: Boolean = false,
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
val ssoLaunchUrl: String? = null,
)
@@ -64,7 +53,11 @@ class LoginViewModel @Inject constructor(
val state: StateFlow<UiState> = _state.asStateFlow()
init {
discoverSsoProviders()
// Discover the native SSO providers to render buttons for (§4.2).
viewModelScope.launch {
val providers = authRepository.ssoProviders()
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
}
// Consume the SSO bridge outcome: a returned callback completes here even if
// this ViewModel was recreated while the Custom Tab was foreground (§4.2).
viewModelScope.launch {
@@ -89,43 +82,11 @@ class LoginViewModel @Inject constructor(
fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
fun onRecoveryCodeChange(value: String) =
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
fun onUseRecoveryCodeChange(value: Boolean) =
_state.update { it.copy(useRecoveryCode = value, error = null) }
val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
/**
* Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
* affordance instead of the old dead website-login hand-off, which was never
* mobile-formatted and could not deep-link the session back.
*/
fun discoverSsoProviders() {
_state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
viewModelScope.launch {
when (val result = authRepository.ssoProviders()) {
is SsoDiscovery.Available ->
_state.update {
it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.None ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.Unavailable ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
}
}
}
}
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */
val ssoLoginUrl: String? get() = websiteUrls.login()
/**
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
@@ -153,68 +114,45 @@ class LoginViewModel @Inject constructor(
fun submit() {
val s = _state.value
if (s.submitting) return
val validationError = validateForSubmit(s)
if (validationError != null) {
_state.update { it.copy(error = validationError) }
if (s.username.isBlank() || s.password.isBlank()) {
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
return
}
// If 2FA is being requested, a code must accompany the resubmit.
if (s.totpRequired && s.code.isBlank()) {
_state.update { it.copy(error = LoginError.BAD_CODE) }
return
}
_state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch {
// Only one second factor is sent; the recovery toggle picks which.
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
val result = authRepository.login(
username = s.username.trim(),
password = s.password,
code = code,
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
applyLoginResult(result)
}
}
val code = s.code.trim().takeIf { it.isNotBlank() }
when (authRepository.login(s.username.trim(), s.password, code)) {
LoginResult.Success ->
_state.update { it.copy(submitting = false, signedIn = true) }
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
private fun validateForSubmit(s: UiState): LoginError? {
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) return LoginError.BAD_CODE
}
return null
}
LoginResult.TotpRequired ->
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
_state.update {
it.copy(
submitting = false,
totpRequired = true,
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
)
}
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
private fun applyLoginResult(result: LoginResult) = when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.TotpRequired ->
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null,
)
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
}
}

View File

@@ -1,153 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.ui.UiState
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
* codes are shown only in memory — copy or share them before leaving; they are never
* stored on the device.
*/
@Composable
fun RecoveryCodesScreen(
modifier: Modifier = Modifier,
viewModel: RecoveryCodesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var currentPassword by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.recovery_codes_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
val remainingText = when (val r = state.remaining) {
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
}
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
state.freshCodes?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
}
OutlinedTextField(
value = currentPassword,
onValueChange = { currentPassword = it },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.account_password_current)) },
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
)
state.error?.let { err ->
Text(
text = stringResource(err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = { viewModel.regenerate(currentPassword) },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
}
}
/**
* A show-once display of a freshly generated recovery-code batch, with copy/share and
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
*/
@Composable
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.recovery_codes_new_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
codes.forEach { code ->
Text(
code,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 8.dp),
)
}
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(joined)) },
) { Text(stringResource(R.string.recovery_codes_copy)) }
OutlinedButton(
onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, joined)
}
context.startActivity(Intent.createChooser(send, null))
},
) { Text(stringResource(R.string.recovery_codes_share)) }
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
}
}
}
}

View File

@@ -1,84 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.AccountRepository
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 Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
* status and a password-stepped regenerate that surfaces a fresh single-use batch
* **once** (never persisted). The freshly generated codes live only in memory until
* the user leaves the screen or dismisses them.
*/
@HiltViewModel
class RecoveryCodesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
data class State(
/** Remaining unused codes (the status endpoint). */
val remaining: UiState<Int> = UiState.Loading,
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
val freshCodes: List<String>? = null,
val busy: Boolean = false,
@param:StringRes val error: Int? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(remaining = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
}
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
fun regenerate(currentPassword: String?) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
viewModelScope.launch {
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
is ApiResult.Ok -> {
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
// Refresh the remaining count to reflect the new batch.
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
is ApiResult.HttpError ->
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
is ApiResult.NetworkError ->
_state.update { it.copy(busy = false, error = R.string.error_network) }
}
}
}
/** Drop the shown-once batch from memory (user saved them / navigated away). */
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
}
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
is UiState.Success -> UiState.Success(transform(data))
is UiState.Loading -> UiState.Loading
is UiState.Error -> this
}

View File

@@ -1,136 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.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.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
* server re-checks ownership on every call; this screen just renders the outcomes.
*/
@Composable
fun TrustedDevicesScreen(
modifier: Modifier = Modifier,
viewModel: TrustedDevicesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.trusted_devices_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.trusted_devices_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
when (val devices = state.devices) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> {
if (devices.data.isEmpty()) {
Text(
stringResource(R.string.trusted_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
)
} else {
devices.data.forEach { device ->
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
}
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
Button(
onClick = viewModel::trustThisDevice,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
if (devices.data.isNotEmpty()) {
OutlinedButton(
onClick = viewModel::revokeAll,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
}
}
}
}
}
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = device.deviceName?.takeIf { it.isNotBlank() }
?: device.platform?.replaceFirstChar { it.uppercase() }
?: stringResource(R.string.trusted_devices_unknown),
style = MaterialTheme.typography.bodyLarge,
)
device.lastUsedAt?.let {
Text(
stringResource(R.string.trusted_devices_last_used, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onRevoke, enabled = !busy) {
Text(stringResource(R.string.trusted_devices_revoke))
}
}
}
}

View File

@@ -1,125 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import com.runicgateway.app.data.repository.AuthRepository
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 Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
* allowed to skip the TOTP step, trust the current one (persisting the returned
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
*/
@HiltViewModel
class TrustedDevicesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
private val deviceNameProvider: DeviceNameProvider,
) : ViewModel() {
/** A one-shot result banner shown above the list. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(devices = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
}
/** Trust the current device; persist the returned token so future logins skip 2FA. */
fun trustThisDevice() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
is TrustOutcome.Trusted -> {
// Bind the fresh token to the signed-in username (mirrors the login path).
val username = sessionManager.state.value.let {
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
}
if (outcome.trustToken != null && username != null) {
authRepository.saveTrustToken(username, outcome.trustToken)
}
finish(true, R.string.trusted_devices_trusted)
reload()
}
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revoke(id: Long) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeTrustedDevice(id)) {
is ApiResult.Ok -> {
finish(true, R.string.trusted_devices_revoked)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revokeAll() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeAllTrustedDevices()) {
is ApiResult.Ok -> {
// Every device is untrusted now, including this one — drop the local token.
authRepository.clearTrustToken()
finish(true, R.string.trusted_devices_revoked_all)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
private suspend fun reload() {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}

View File

@@ -1,157 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.runicgateway.app.ui.LocalAssetResolver
/**
* The two brand assets an instance can upload — the logo and the hero
* (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since
* M1 and neither has ever been drawn; the app has always spelled the instance
* out in text wherever the website shows a mark.
*
* **The rule that governs this whole file: an empty slot renders nothing.** Not
* a placeholder, not a reserved gap, not the app's own emblem — an instance
* that has uploaded no logo must lay out exactly as it did before this phase
* existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens
* with the same `if (!brand.logo) return null`.
*
* **A failed load is an empty slot.** No broken-image icon and no retry: an
* asset that 404s, or that can't be reached because the shard is down, must
* degrade to the same layout as an instance that never uploaded one. That is
* why nothing here reserves its space up front — every size modifier hangs off
* the image itself, so when the image isn't composed neither is its padding.
* A caller that wants space *below* a hero passes it as `Modifier.padding`
* rather than a sibling `Spacer`, and gets both cases right for free.
*/
/**
* Widest a logo may draw, as a multiple of its height. Mirrors the website's
* `maxWidth: height * 6` — an operator who uploads a long wordmark gets it
* scaled down rather than pushing the drawer header or the top bar's title out
* of shape.
*/
private const val LOGO_MAX_ASPECT = 6f
/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */
private val HERO_HEIGHT = 180.dp
/**
* The instance's uploaded logo at [height], or [fallback] when there is none.
*
* [fallback] defaults to drawing nothing, which is what the drawer header wants:
* the instance name sits directly below it, so an instance with no logo simply
* has the name where it has always been. The top bar passes the name itself,
* because there the logo *replaces* the title — leaving that blank on a failed
* load would strand the app in an unnamed shell until the next resume refresh,
* and "a failed load is an empty slot" means the slot falls back to whatever
* empty would have shown, which for the top bar is the text.
*
* There is deliberately no fallback while the load is still in flight. Drawing
* the text first would flash text → logo on every navigation for the sake of
* one frame, since Coil serves the second and later reads from its memory cache.
*
* Pass [contentDescription] only where the logo stands alone. Beside or above
* the name in text it is decorative, and describing it would have a screen
* reader say the instance's name twice — the same call the website's `alt=''`
* makes.
*/
@Composable
fun BrandLogo(
logo: String?,
height: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
fallback: @Composable () -> Unit = {},
) {
val url = brandAssetUrl(logo, LocalAssetResolver.current)
// Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets
// a fresh attempt rather than inheriting the old one's failure.
var failed by remember(url) { mutableStateOf(false) }
if (url == null || failed) {
fallback()
return
}
AsyncImage(
model = url,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
onError = { failed = true },
modifier = modifier
.height(height)
.widthIn(max = height * LOGO_MAX_ASPECT),
)
}
/**
* The instance's hero image as a full-width band above Home's title block, or
* nothing when there is none.
*
* **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's
* other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a
* CSS background driven by `hero_layout`, which the app does not port, so the
* app needs its own rule — and the website's *default* hero is a square emblem,
* so an uploaded square is a case to expect rather than an edge one. At the
* intrinsic aspect that square would be a ~360dp block that pushes the status
* card off the first screenful; cropped to a band, a wide banner and a square
* both give the same frame above the title.
*
* Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card`
* like every other surface the admin can round off (§5.2).
*
* Decorative: Home spells the instance's name and tagline out in text directly
* below, so the hero carries no content description.
*/
@Composable
fun BrandHero(hero: String?, modifier: Modifier = Modifier) {
val url = brandAssetUrl(hero, LocalAssetResolver.current)
var failed by remember(url) { mutableStateOf(false) }
if (url == null || failed) return
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
onError = { failed = true },
modifier = modifier
.fillMaxWidth()
.height(HERO_HEIGHT)
.clip(MaterialTheme.shapes.medium),
)
}
/**
* Resolve a brand asset slot to a loadable URL, or null when the slot is empty.
*
* The blank check has to happen on **both** sides of [resolve]: `BrandDto`
* defaults every asset field to `""` rather than null (the server publishes the
* empty string for "not set"), and a resolver given a path it cannot make
* absolute may hand one straight back. Null out of here is the signal for "draw
* nothing", so a blank slipping through would put a zero-size image request in
* the layout instead of no image at all.
*
* Pulled out of the composables purely so it can be tested: the app has no
* Robolectric, so a composable body cannot run in a JVM unit test, but this rule
* is the whole of §5.6's "renders nothing when unset" and it is worth pinning.
*/
internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? =
path?.takeIf { it.isNotBlank() }
?.let(resolve)
?.takeIf { it.isNotBlank() }

View File

@@ -36,10 +36,6 @@ fun LoadingView(modifier: Modifier = Modifier) {
/**
* Whole-screen error state with a friendly, kind-specific message and a Retry
* button (§7). Copy is resolved from string resources so it stays localizable.
*
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
* whether the shard publishes that surface, so retrying cannot change the answer and
* offering it would read as a transient failure the user could wait out (M11).
*/
@Composable
fun ErrorView(
@@ -57,20 +53,15 @@ fun ErrorView(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
if (isRetryable(kind)) {
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
}
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
}
}
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
/** Centered informational message for an empty list (§7). */
@Composable
fun EmptyView(message: String, modifier: Modifier = Modifier) {
@@ -92,6 +83,5 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
ErrorKind.NOT_FOUND -> R.string.error_not_found
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -14,22 +14,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.runicgateway.app.ui.theme.LocalShardPalette
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.theme.ShardCardBottom
import com.runicgateway.app.ui.theme.ShardCardTop
import com.runicgateway.app.ui.theme.ShardDanger
import com.runicgateway.app.ui.theme.ShardDangerBg
import com.runicgateway.app.ui.theme.ShardElevated
import com.runicgateway.app.ui.theme.ShardFaint
import com.runicgateway.app.ui.theme.ShardOutline
import com.runicgateway.app.ui.theme.ShardPillBg
import com.runicgateway.app.ui.theme.ShardPillFg
import com.runicgateway.app.ui.theme.ShardSuccess
import com.runicgateway.app.ui.theme.ShardSuccessBg
import com.runicgateway.app.ui.theme.ShardSuccessDot
@@ -41,14 +43,6 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
* no state, no data dependencies — so any screen can adopt them.
*
* This is the app's **only** file that reaches past `MaterialTheme` for a
* themable value, so it is the one place M12 had to migrate: the surface, line
* and accent tokens come from [LocalShardPalette] and the pill shape and card
* depth from [LocalShardStructure], both following the shard's theme
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
* stay imported directly — those are semantic and never themed, mirroring the
* server's `FIXED_TOKENS`.
*/
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
@@ -56,26 +50,21 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
private data class PillColors(val fg: Color, val bg: Color)
@Composable
private fun toneColors(tone: PillTone): PillColors = when (tone) {
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
PillTone.Neutral, PillTone.Info ->
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
}
/**
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
*
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
*/
@Composable
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
val c = toneColors(tone)
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
@@ -92,7 +81,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
PillTone.Success -> ShardSuccessDot
PillTone.Warning -> ShardWarning
PillTone.Danger -> ShardDanger
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
PillTone.Neutral, PillTone.Info -> ShardFaint
}
Box(modifier.size(8.dp).clip(CircleShape).background(color))
}
@@ -106,7 +95,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = LocalShardPalette.current.faint,
color = ShardFaint,
modifier = modifier,
)
}
@@ -115,11 +104,6 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
* soft shadow, used for the home status card, the shard-online banner, and the
* vendor card. [content] is laid out in a padded [Column].
*
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
* resolves to (§5.4).
*/
@Composable
fun FeatureCard(
@@ -127,40 +111,17 @@ fun FeatureCard(
contentPadding: Int = 18,
content: @Composable ColumnScope.() -> Unit,
) {
val palette = LocalShardPalette.current
val shape = MaterialTheme.shapes.medium
Box(
modifier = modifier
.fillMaxWidth()
.shadow(LocalShardStructure.current.cardElevation, shape)
.clip(shape)
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
.border(1.dp, palette.outline, shape),
.clip(RoundedCornerShape(12.dp))
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
) {
Column(Modifier.padding(contentPadding.dp), content = content)
}
}
/**
* A Material [Card] at the shard's resolved depth — the app's standard card, and
* the reason every screen's `Card(` became a `ShardCard(`.
*
* `Card` takes its elevation as a **default argument**, not from the theme, so
* unlike the color scheme and the shape scale there is no way to make
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
* [CardDefaults.cardElevation] at each site instead would have put the same line
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
* card the shard cannot theme, which makes the invariant greppable.
*/
@Composable
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
content = content,
)
}
/**
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
* the slate accent over a bordered dark track.
@@ -168,14 +129,13 @@ fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.()
@Composable
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
val pct = fraction.coerceIn(0f, 1f)
val palette = LocalShardPalette.current
Box(
modifier = modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(palette.elevated)
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
.background(ShardElevated)
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
) {
Box(
Modifier

View File

@@ -1,310 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
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.style.TextAlign
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.EventOccurrenceDto
import com.runicgateway.app.data.api.dto.EventParticipantDto
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.HtmlText
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* One event's public page (EVENTS.md § API surface, M13).
*
* The storyline, its arc, what is live, what is next, what happened recently, and
* a results table once an occurrence has published one.
*
* **The plan behind the event is never shown**, because the server never sends
* it: a live run carries the LABEL of the phase it is in — resolved from the
* version that run pinned, so an edit since does not relabel it — and nothing
* else. Phases, steps and actions are the operator's.
*/
@Composable
fun EventScreen(
onOpenSeries: (String) -> Unit,
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Error before content. Phase 13 found the inverse of this one tier along: a
// `if (loading || !form)` spinner above the error branch left a failed load
// spinning for ever with nothing on screen naming the problem.
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier)
}
}
@Composable
private fun EventBody(
event: PublicEventDto,
onOpenSeries: (String) -> Unit,
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = event.title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
event.summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
event.series?.let { series ->
Text(
text = stringResource(R.string.events_part_of, series.name),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onOpenSeries(series.slug) },
)
}
}
}
// The one fact a visitor came for, above the storyline rather than below
// it: whether it is happening now, and if not, when it next is.
item(key = "headline") { Headline(event) }
event.body?.takeIf { it.isNotBlank() }?.let { body ->
item(key = "body") {
ShardCard(Modifier.fillMaxWidth()) {
// Sanitized on write, the treatment a wiki page and a forum
// post already get.
HtmlText(body, Modifier.padding(16.dp))
}
}
}
event.results?.let { results ->
item(key = "results-head") {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = stringResource(R.string.events_results),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = eventDateTime(results.scheduledFor, event.timezone),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (results.participants.isEmpty()) {
item(key = "results-empty") {
Text(
text = stringResource(R.string.events_results_nobody),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
items(results.participants.size, key = { "p$it" }) { index ->
ParticipantRow(results.participants[index])
}
}
}
occurrenceSection(
key = "upcoming",
titleRes = R.string.events_coming_up,
list = event.upcoming,
timezone = event.timezone,
slug = event.slug,
onOpenRun = onOpenRun,
linkResults = false,
)
occurrenceSection(
key = "past",
titleRes = R.string.events_previously,
list = event.past,
timezone = event.timezone,
slug = event.slug,
onOpenRun = onOpenRun,
linkResults = true,
)
if (event.current == null && event.next == null && event.past.isEmpty()) {
item(key = "unscheduled") {
Text(
text = stringResource(R.string.events_never_scheduled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun Headline(event: PublicEventDto) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val current = event.current
when {
event.live && current != null -> {
StatusPill(
text = stringResource(R.string.events_status_live),
tone = PillTone.Success,
)
Text(
// The phase LABEL, and only while it is live.
text = current.phase?.takeIf { it.isNotBlank() }
?: stringResource(R.string.events_under_way),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
event.next != null -> {
Text(
text = stringResource(R.string.events_next),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
else -> Text(
text = stringResource(R.string.events_nothing_scheduled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* A titled list of occurrences, or nothing at all when there are none.
*
* `linkResults` is what separates the two calls: only a PAST occurrence that
* actually published results gets its own tap target, because on any other one
* `?run=` would change nothing a reader could see.
*/
private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection(
key: String,
titleRes: Int,
list: List<EventOccurrenceDto>,
timezone: String?,
slug: String,
onOpenRun: (String, Long) -> Unit,
linkResults: Boolean,
) {
if (list.isEmpty()) return
item(key = "$key-title") {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
items(list.size, key = { "$key-${list[it].runId}" }) { index ->
val occurrence = list[index]
val tappable = linkResults && occurrence.resultsPublishedAt != null
ShardCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) }
else Modifier,
),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(
statusWordRes(occurrence.status, occurrence.scheduledFor),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ParticipantRow(participant: EventParticipantDto) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = participant.rank?.toString() ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
modifier = Modifier.width(32.dp),
)
Text(
// A module supplies a display name in its participation meta or it does
// not; the member key is never published, so there is genuinely nothing
// else to render.
text = participant.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.events_participant_unnamed),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Text(
text = scoreText(participant.score),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}

View File

@@ -1,113 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* One arc (EVENTS.md §I, M13).
*
* **The arc is the thing the tooling this replaces could not express at all.** A
* calendar plugin has no series field, so "Royal Spy Mission → Risky Partner →
* Message From the Void" existed only in a GM's head and in whatever the forum
* post said. This screen is that continuity, in the order an editor arranged it —
* which is why the events are numbered rather than dated: an arc has an order, and
* its parts may be months apart or run out of sequence.
*/
@Composable
fun EventSeriesScreen(
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventSeriesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
// Error first, then loading — the order Phase 13 had to fix one tier along.
when (val s = state) {
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Loading -> LoadingView(modifier)
is UiState.Success -> {
val series = s.data
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = series.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
series.description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(series.events.size, key = { series.events[it].slug }) { index ->
val entry = series.events[index]
ShardCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpenEvent(entry.slug) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = (index + 1).toString(),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.End,
modifier = Modifier.width(32.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
entry.summary?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}

View File

@@ -1,50 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.EventSeriesDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
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.launch
import javax.inject.Inject
/**
* One arc (PLAN.md §9 M13).
*
* A series with nothing listed in it answers 404 rather than an empty page, so
* there is no "empty arc" state to render: the error branch is the whole of it,
* and that is the server's decision rather than this screen's — an empty page
* would publish that an operator has named something they have not announced.
*/
@HiltViewModel
class EventSeriesViewModel @Inject constructor(
private val repository: EventsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
private val _state = MutableStateFlow<UiState<EventSeriesDto>>(UiState.Loading)
val state: StateFlow<UiState<EventSeriesDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.series(slug).toUiState()
}
}
}

View File

@@ -1,166 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.time.parseWireInstant
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* Rendering an event's instant and its status word (EVENTS.md §I).
*
* Everything here is pure and takes its clock, zone and locale as parameters, so
* the rules below are unit-tested off-device rather than eyeballed on one.
*
* ## The split, which is the one thing about event times that is easy to get wrong
*
* The server returns UTC instants and never guesses the reader's zone. The client
* places them, and it places the two halves differently:
*
* - the **day** an entry is filed under is the READER's own — "what is on this
* month" is a question about the month the person holding the phone is living
* in;
* - the **time** beside it is always the EVENT's zone, carried on the entry —
* because every listing this feature replaces is written in the shard's local
* zone, and "8pm" means the shard's evening to everyone reading it.
*
* Rendering the time in the reader's zone instead is defensible and wrong here: a
* player in Berlin told an American shard's event is at 02:00 has been told
* something true and useless, and told it in a way that makes the shard's own
* announcement look like a mistake.
*/
/**
* A participation score, as a reader should see it.
*
* Scores are `DECIMAL(18,4)` on the wire because a module may score by distance,
* time or a weighted tally — but most score by counting, and rendering a plain
* tally of kills as `12.0` reads as a rounding artefact. So a whole number prints
* whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`,
* `318.5`, `0.25`.
*/
fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String {
if (!score.isFinite()) return "0"
if (score == Math.floor(score) && Math.abs(score) < 1e15) {
return String.format(locale, "%d", score.toLong())
}
return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',')
}
/** The event's own wall clock, with the zone named so it misreads as nothing. */
fun eventTime(
instant: String?,
timezone: String?,
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
val zone = eventZone(timezone)
val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at)
return "$time ${shortZone(timezone)}"
}
/**
* The event's own day and time together, for a screen showing one occurrence.
*
* Localized rather than patterned, because a full date's field order is the
* locale's business; only the zone stays the event's.
*/
fun eventDateTime(
instant: String?,
timezone: String?,
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
val zone = eventZone(timezone)
val text = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.withLocale(locale)
.withZone(zone)
.format(at)
return "$text ${shortZone(timezone)}"
}
/** The reader's own day, for the heading an entry is filed under. */
fun readerDayLabel(
instant: String?,
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String {
val at = parseWireInstant(instant) ?: return ""
return DateTimeFormatter
.ofLocalizedDate(FormatStyle.FULL)
.withLocale(locale)
.withZone(zone)
.format(at)
}
/**
* The zone as a reader recognises it: `America/New_York` → `New York`.
*
* Not the abbreviation (`EDT`), which is unstable across the year and unknown to
* most readers of a shard in another country.
*/
fun shortZone(timezone: String?): String {
if (timezone.isNullOrBlank()) return "UTC"
return timezone.substringAfterLast('/').replace('_', ' ')
}
/**
* The event's zone, or UTC when its column holds something `java.time` will not
* read.
*
* A typo in a definition's timezone must still render: UTC off the instant is the
* honest answer when the zone cannot be honoured, and it is what the web client
* falls back to for the same reason.
*/
private fun eventZone(timezone: String?): ZoneId = try {
if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone)
} catch (_: Exception) {
ZoneId.of("UTC")
}
/**
* The word beside an occurrence, for the four statuses the server publishes.
*
* **`cancelled` needs the instant, and that is the whole reason this takes one.**
* The server publishes `failed` and `missed` as `cancelled` too — to a visitor the
* three are one event, and the difference between them is about the deployment —
* but the three do not share one English sentence. *Did not happen* is right for a
* past occurrence and a plain falsehood for a future one, and a run four days out
* that an operator has called off is exactly the common case: this is the defect
* Phase 14a's own calendar shipped and the live walk caught, which is why it is
* restated here rather than ported.
*
* So **the tense follows the clock, not the status**. A future call-off reads
* *Cancelled*; a past one reads *Did not happen*, which is also the honest word
* for the failed and missed runs folded in with it.
*
* An unrecognised status reads *Scheduled*, mirroring the server's own fallback:
* `publicStatus()` folds anything it does not know to `scheduled`, so a word the
* app has never seen is a contract break rather than a state, and rendering a raw
* enum at a reader is not an improvement on it.
*/
@StringRes
fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int =
when (status) {
"live" -> R.string.events_status_live
"completed" -> R.string.events_status_completed
"cancelled" -> {
val at = parseWireInstant(scheduledFor)
// An unreadable instant is treated as past, which is the safer of the
// two: "did not happen" about something unplaceable in time is vague,
// while "cancelled" about a past run implies it is still coming.
if (at != null && at.isAfter(now)) {
R.string.events_status_cancelled
} else {
R.string.events_status_did_not_happen
}
}
else -> R.string.events_status_scheduled
}

View File

@@ -1,59 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PublicEventDto
import com.runicgateway.app.data.repository.EventsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
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.launch
import javax.inject.Inject
/**
* One event's page (PLAN.md §9 M13, EVENTS.md § API surface).
*
* **`run` is read from the route and passed through untouched**, because that is
* what an announcement's link carries. The page lives at the definition's slug —
* one stable address, so a link posted in Discord survives a retitle — and the
* occurrence has to be in the query or a mail about last Friday's invasion would
* open next Friday's.
*
* A run that belongs to some other event is **not** filtered here. The server
* ignores it and answers with this event anyway, which turns a stale link in a
* months-old mail into the page it was about rather than a dead end; second-
* guessing that would undo it.
*/
@HiltViewModel
class EventViewModel @Inject constructor(
private val repository: EventsRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
/** Null unless the route carried one; never an empty string forwarded to the server. */
private val run: String? = savedStateHandle.get<String>(Routes.Args.RUN)?.takeIf { it.isNotBlank() }
private val _state = MutableStateFlow<UiState<PublicEventDto>>(UiState.Loading)
val state: StateFlow<UiState<PublicEventDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.event(slug, run).toUiState()
}
}
}

View File

@@ -1,191 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
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.EventCalendarEntryDto
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.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The public event calendar (EVENTS.md §I, M13).
*
* **A list, not a month grid**, which is the same call the web client makes and
* for the same reason: an operator's question is "what does this month look
* like" — coverage, clashes, the gap on the third weekend — and a grid answers
* it. A visitor's question is "what is on, and when is the next one", which a
* chronological list answers in one glance and a grid answers by making them
* count squares. On a phone the grid is not even a close second.
*
* **A projection is drawn differently from a run**, one tier along from the
* operator's own reason for the distinction: past the materialisation horizon
* there is no row, nothing is committed to, and nothing can be cancelled. Drawing
* a forecast identically to a booking would be the screen promising something the
* server has not.
*/
@Composable
fun EventsScreen(
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: EventsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
val entries = s.data.entries
if (entries.isEmpty()) {
EmptyView(stringResource(R.string.events_empty), modifier)
} else {
Calendar(entries, s.data.truncated, onOpenEvent, modifier)
}
}
}
}
/**
* Group by the READER's day, preserving the server's order rather than re-sorting.
*
* Internal + pure so the grouping — and the fact that it never reorders — is
* unit-tested without Compose.
*/
internal fun groupByReaderDay(entries: List<EventCalendarEntryDto>): List<CalendarDayGroup> {
val days = mutableListOf<CalendarDayGroup>()
for (entry in entries) {
val label = readerDayLabel(entry.scheduledFor)
val last = days.lastOrNull()
if (last != null && last.label == label) {
last.entries.add(entry)
} else {
days.add(CalendarDayGroup(label, mutableListOf(entry)))
}
}
return days
}
/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */
internal data class CalendarDayGroup(
val label: String,
val entries: MutableList<EventCalendarEntryDto>,
)
@Composable
private fun Calendar(
entries: List<EventCalendarEntryDto>,
truncated: Boolean,
onOpenEvent: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val days = groupByReaderDay(entries)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
days.forEach { day ->
item(key = "day-${day.label}") {
Text(
text = day.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(
items = day.entries,
key = { "${it.slug}-${it.scheduledFor}-${it.kind}" },
) { entry ->
EntryCard(entry, onOpenEvent)
}
}
if (truncated) {
item(key = "truncated") {
Text(
text = stringResource(R.string.events_truncated),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) {
ShardCard(
modifier = Modifier
.fillMaxWidth()
// A projection has a page too — the definition's — so it opens like any
// other entry. What it does not have is an occurrence to link to.
.clickable { onOpenEvent(entry.slug) },
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
StatusPill(
text = stringResource(statusWordRes(entry.status, entry.scheduledFor)),
tone = if (entry.live) PillTone.Success else PillTone.Neutral,
)
}
Text(
text = eventTime(entry.scheduledFor, entry.timezone),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
Text(
text = series,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (entry.isProjected) {
// Said in words rather than drawn as a dashed border, because a
// phone reader skimming a list will not decode a border and the
// distinction is worth more than the pixel it would cost.
Text(
text = stringResource(R.string.events_projected),
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -1,51 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.EventCalendarDto
import com.runicgateway.app.data.repository.EventsRepository
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.launch
import javax.inject.Inject
/**
* The public event calendar (PLAN.md §9 M13, EVENTS.md §I).
*
* **No window is asked for**, and that is the whole of this view model's design.
* The server's default is now through 31 days out, so a client that computed a
* window before it could ask anything would make every deep link carry two ISO
* instants and would have to agree with the server about what "now" is. The
* window bound and the entry cap are the server's defence on the one surface with
* no login in front of it; there is nothing for the app to add.
*
* `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the
* backend has no events at all, not that an admin switched a shard surface off,
* and offering "not published here" for it would name the wrong cause.
*/
@HiltViewModel
class EventsViewModel @Inject constructor(
private val repository: EventsRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<EventCalendarDto>>(UiState.Loading)
val state: StateFlow<UiState<EventCalendarDto>> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.calendar().toUiState()
}
}
}

View File

@@ -1,142 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.style.TextAlign
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.EventHistoryEntryDto
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
/**
* This account's event participation (EVENTS.md §J, M13).
*
* **The screen's one real design decision is what an unranked row says.** A run
* whose participants were collected but whose results have not been published has
* a score and no rank, and that is a real state rather than an error — it is the
* same state the admin run console has shown since events Phase 10. Rendering a
* dash with nothing explaining it would read as a bug; the row says the results
* are not published, which is a fact about the event rather than about the reader.
*
* Reached by **one drawer row for every signed-in account**, players and staff
* alike. The website mounts this twice only because its `RequirePlayer` guard sits
* over `/account` and the route behind it is role-agnostic; the app has no such
* wall, so it needs no second mount.
*/
@Composable
fun MyEventsScreen(
onOpenRun: (String, Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: MyEventsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val items = state.items) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> if (items.data.isEmpty()) {
EmptyView(stringResource(R.string.events_history_empty), modifier)
} else {
androidx.compose.foundation.lazy.LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items.data.size, key = { items.data[it].id }) { index ->
HistoryRow(items.data[index], onOpenRun)
}
if (state.hasMore) {
item(key = "more") {
TextButton(
onClick = viewModel::loadMore,
enabled = !state.loadingMore,
modifier = Modifier.fillMaxWidth(),
) {
Text(
stringResource(
if (state.loadingMore) R.string.events_loading
else R.string.events_show_more,
),
)
}
}
}
}
}
}
}
@Composable
private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) {
ShardCard(
modifier = Modifier
.fillMaxWidth()
// Straight to the occurrence the reader took part in, not to whatever
// is next: `?run=` is what makes the event page answer about this one.
.clickable { onOpenRun(entry.slug, entry.runId) },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = eventDateTime(entry.scheduledFor, entry.timezone),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
entry.seriesName?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = entry.rank
?.let { stringResource(R.string.events_rank, it) }
?: stringResource(R.string.events_results_unpublished),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.End,
)
Text(
text = stringResource(R.string.events_score, scoreText(entry.score)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

View File

@@ -1,117 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.events
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.result.ApiResult
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
import com.runicgateway.app.data.repository.EventsRepository
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.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* This account's event participation (PLAN.md §9 M13, EVENTS.md §J).
*
* **Self-scoped by the session and nothing else.** There is no id parameter on
* the route and deliberately none here: one account never reads another's, and
* there is no argument that could later grow into one.
*
* **Keyset-paged on the participation row's own id, never an offset** — the list
* gains a row every time the reader attends something, so an offset page would
* skip and repeat rows around the seam.
*
* ## Why this watches the session, when no other screen here does
*
* **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves
* and restores back-stack state, so the `NavBackStackEntry` for this route keeps
* its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a
* view model that loads only in `init` never runs again. The live walk found the
* consequence: signing out of an admin account and back in as a player showed the
* PLAYER the admin's participation history, with no request made at all.
*
* The public event screens have the same lifetime and do not care, because a
* calendar is the same for everybody. This one is per-account, so the account is
* what it keys on: the flow emits the current session immediately, which is also
* the first load, and re-emits only when the signed-in id actually changes — a
* resume revalidation returning the same user does not refetch.
*/
@HiltViewModel
class MyEventsViewModel @Inject constructor(
private val repository: EventsRepository,
sessionManager: SessionManager,
) : ViewModel() {
data class State(
val items: UiState<List<EventHistoryEntryDto>> = UiState.Loading,
val hasMore: Boolean = false,
val loadingMore: Boolean = false,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId ->
// Signed out: drop the rows rather than leave the last
// account's on screen behind a shell that is about to
// navigate away.
if (userId == null) _state.value = State(items = UiState.Success(emptyList()))
else load()
}
}
}
fun load() {
_state.value = State()
viewModelScope.launch {
val result = repository.history(PAGE)
_state.value = State(
items = result.toUiState(),
// A full page means there is probably another; a short one is the
// end. One request rather than a count the server does not send.
hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE,
)
}
}
fun loadMore() {
val current = _state.value
val shown = (current.items as? UiState.Success)?.data ?: return
val last = shown.lastOrNull() ?: return
if (current.loadingMore || !current.hasMore) return
_state.value = current.copy(loadingMore = true)
viewModelScope.launch {
when (val result = repository.history(PAGE, before = last.id)) {
is ApiResult.Ok -> _state.value = State(
items = UiState.Success(shown + result.data),
hasMore = result.data.size == PAGE,
)
// A failed NEXT page keeps the pages already read rather than
// replacing a screenful of history with an error: the reader can
// still see what loaded, and tapping again retries.
else -> _state.value = current.copy(loadingMore = false)
}
}
}
private companion object {
const val PAGE = 25
}
}

View File

@@ -26,7 +26,6 @@ import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.BrandHero
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
@@ -60,12 +59,6 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
// The instance's hero above the title block (§5.6) — Home is the one screen
// with a hero-shaped space. Its bottom gap rides on the image's own modifier
// rather than a Spacer, so an instance with no hero (or one whose hero fails
// to load) opens on the title exactly where it has always been.
BrandHero(hero = brand?.hero, modifier = Modifier.padding(bottom = 16.dp))
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,

View File

@@ -6,12 +6,6 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.repository.Capability
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.SiteCapabilities
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.data.repository.canUse
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -27,57 +21,14 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN,
/**
* The linked game-data groups (§6.3). Visible to any player **or** staff:
* staff are a superset of players (all player abilities plus their staff
* tools), and the backend's player self-service surface is role-agnostic, so
* a signed-in admin/editor/moderator sees + uses their own characters too.
*/
/** Visible only to a player — the linked game-data groups (§6.3). */
PLAYER,
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
STAFF,
/** Visible to admin/moderator — moderation actions + the support queue (§1, M10). */
MODERATOR,
}
data class MenuEntry(
val route: String,
@param:StringRes val labelRes: Int,
val access: MenuAccess = MenuAccess.PUBLIC,
/**
* For a shard-derived surface, the visibility feature it belongs to (M11).
*
* Session role is not the only gate on these: an admin can switch a feature off
* or raise its audience above the caller's rung, so the entry is filtered by
* `GET /public/shard/features` as well as by [access]. `null` means the entry
* isn't shard-derived and only [access] applies.
*/
val feature: String? = null,
/**
* The backend capability this row needs, or null when it needs none (M13).
*
* **A different question from [feature], which is why it is a second field
* and not a wider one.** This asks whether the code behind the row is
* *installed at all* — a per-HOST fact, from `GET /public/modules` and core's
* own list — while [feature] asks whether this shard publishes that surface
* to *this viewer*, which is per-viewer and admin-configurable. A site with no
* game module has no `shard` capability and no shard rows, whoever is looking;
* a site with one may still hide its market from anonymous visitors.
*
* The two also fail differently, and [canUse] is where that lives.
*/
val capability: String? = null,
/**
* An admin's own label for this row, from the shard's `nav_public` override
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
*
* A label set this way is **not localized**: it is one string for every locale,
* which is what an admin typing a label means, and it matches the website. It
* only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it.
*/
val label: String? = null,
)
/**
@@ -88,138 +39,26 @@ data class MenuEntry(
val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
// Events are CORE's, so this row is gated on core's own capability rather than
// a module's: a site with no game module still has a calendar. Placed here to
// match the website's own nav, where Events is the row after News.
MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
// The shard group. Every row needs the game module INSTALLED (one capability,
// because that is the only question a capability can answer) and its own
// feature published to this viewer (M11) — both, independently.
MenuEntry(
Routes.SHARD,
R.string.menu_shard,
feature = ShardFeature.STATUS,
capability = Capability.SHARD,
),
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
// which for a brand-new install is every one of them until the plugin has swept.
MenuEntry(
Routes.SHARD_RULES,
R.string.menu_rules,
feature = ShardFeature.RULESET,
capability = Capability.SHARD,
),
MenuEntry(
Routes.ATLAS,
R.string.menu_atlas,
feature = ShardFeature.ATLAS,
capability = Capability.SHARD,
),
MenuEntry(
Routes.SHARD_LEADERBOARDS,
R.string.menu_leaderboards,
feature = ShardFeature.LEADERBOARDS,
capability = Capability.SHARD,
),
MenuEntry(
Routes.SHARD_MARKET,
R.string.menu_market,
feature = ShardFeature.MARKET,
capability = Capability.SHARD,
),
MenuEntry(Routes.SHARD, R.string.menu_shard),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
// Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth`
// alone and self-scoped on the caller's own id, and the website needed two
// mounts for it only because `RequirePlayer` guards `/account` there. Staff
// attend events too, and event history is not game-linked data.
MenuEntry(
Routes.MY_EVENTS,
R.string.menu_my_events,
MenuAccess.SIGNED_IN,
capability = Capability.EVENTS,
),
// These three read `/player/shard/*`, which is the SAME module's player mount —
// so they need the capability for the same reason the public rows do. They
// carry no `feature`, because the visibility framework covers the public
// surfaces and these are self-service, gated by role and ownership instead.
// That asymmetry is exactly why the live walk found them and the suite did
// not: "a shard row" had been defined as "a row with a feature".
MenuEntry(
Routes.PLAYER_CHARACTERS,
R.string.menu_my_characters,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
MenuEntry(
Routes.PLAYER_VENDORS,
R.string.menu_my_vendors,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
MenuEntry(
Routes.PLAYER_HOUSES,
R.string.menu_my_houses,
MenuAccess.PLAYER,
capability = Capability.SHARD,
),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR),
MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR),
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
)
/**
* The entries the given [session] may see, on a backend with these [capabilities]
* and this shard's [features]. Pure + side-effect-free so the gating is unit-tested
* without Compose.
*
* Three independent filters, and all three must pass:
*
* - [MenuEntry.access] against the session — who the caller is.
* - [MenuEntry.capability] against what this backend serves — whether the code
* behind the row is installed at all (M13). Per host.
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
* publishes to this viewer (M11). Per viewer.
*
* **The last two both fail open on an unknown answer, but "unknown" means
* different things to them.** A `null` [features] is unknown; so is a `null`
* [capabilities] — but a *non-null* [capabilities] that does not name the string
* is an ANSWER, and it hides. Without that, a site with no game module renders
* five shard rows that each 404. See [canUse].
* The entries the given [session] may see. Pure + side-effect-free so the access
* gating is unit-tested without Compose.
*/
fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
capabilities: SiteCapabilities? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features, capabilities) }
/**
* [visibleEntries] for a single entry — the same three filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
* a rule its top-level twin is not.
*/
fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
capabilities: SiteCapabilities? = null,
): Boolean {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
entries.filter { entry ->
when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
}
}
return allowedByRole &&
canUse(capabilities, entry.capability) &&
(entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -1,171 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
/**
* Apply the admin's stored public-nav overrides to the app's coded menu
* (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
* `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
*
* **This is presentation, never authorization.** An override carries `label`,
* `order` and `hidden` and nothing else: it cannot introduce a route, cannot
* touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
* `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
* **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
*
* Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
* a non-string label, a path the app doesn't surface in its menu — is ignored
* rather than rejected, so a stale or hand-edited settings row degrades to the
* coded menu instead of rendering a broken drawer.
*/
/** A usable override for one menu row. Absent fields mean "as coded". */
internal data class NavOverride(
val label: String? = null,
val order: Double? = null,
val hidden: Boolean = false,
/**
* The id of the section this row was dropped into, or null for a top-level
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
* sections here; that is the tree's job, since only it knows them.
*/
val section: String? = null,
) {
/**
* Nothing a **flat** list can express. [section] is deliberately not part of
* this: to [applyNavOverrides] a section-only override says nothing, so an
* instance that only ever grouped rows still gets its coded list back by
* identity. The tree build adds its own check.
*/
val isEmpty: Boolean get() = label == null && order == null && !hidden
}
/**
* The `items` map out of a stored `nav_public` value.
*
* Two shapes exist, because website phase 10 added sections and links without
* migrating what phases 6-8 had already stored: `{items, sections, links}` and a
* bare map of path → override. A bare map is unambiguous — every key is a path,
* so a key can never be the string `items`.
*
* `sections` and `links` come out of the same wrapper, and only ever out of the
* wrapped shape — see [sectionsOf] and [linksOf].
*/
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
if (navPublic == null) return emptyMap()
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
return items.entries
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
.toMap()
}
/**
* The stored value as the wrapped `{items, sections, links}` shape, or null when
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
* `items` **object**, which a bare map can never carry because every key in one is
* a path.
*/
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
navPublic?.takeIf { it["items"] is JsonObject }
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
(wrapperOf(navPublic)?.get(key) as? JsonArray)
?.mapNotNull { it as? JsonObject }
.orEmpty()
// Field by field, like every other read in M12: a bad `label` must not discard a
// good `order` beside it.
//
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
// never renders, and a value it cannot honor is better dropped than half-applied.
internal fun cleanOverride(raw: JsonObject): NavOverride {
val label = (raw["label"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
val order = (raw["order"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.doubleOrNull
?.takeIf { it.isFinite() }
val hidden = (raw["hidden"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.booleanOrNull == true
val section = (raw["section"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotEmpty() }
return NavOverride(label = label, order = order, hidden = hidden, section = section)
}
/**
* [base] with the admin's overrides applied: rows relabeled, reordered and
* dropped as the stored row asks.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav. Null, malformed, and "nothing usable in it" all return [base]
* itself, which is what makes an untouched instance's drawer provably today's
* (§2, AC-1).
*/
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
val items = itemsOf(navPublic)
if (items.isEmpty()) return base
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu actually declares.
// This is where an override for a path the app doesn't surface in its drawer —
// a news category tab, a Shard hub board — is dropped (§6.2). The web does the
// same with an unknown `to`.
val overrides = buildMap {
for ((path, raw) in items) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
if (!override.isEmpty) put(route, override)
}
}
if (overrides.isEmpty()) return base
// Rows the website's nav knows about are the ones an override can move; the
// app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no counterpart to be reordered against and keep their coded order,
// appended after the public block — which is exactly where they sit today, so
// this partition is the current layout rather than a new one (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val sorted = mapped
// An untouched row's sort key is its index in the WEBSITE's nav, not the
// app's: a stored `order` is a position in that list, so both keys have to
// sit on one number line to be comparable at all.
//
// Two tie-breaks, the web's: an explicit order beats a coincidental index
// (the admin said "first", so first), and two explicit orders keep code
// order, because the sort is stable.
.sortedWith(
compareBy<MenuEntry> { entry ->
overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
}.thenByDescending { overrides[it.route]?.order != null },
)
return (sorted + appOnly).mapNotNull { entry ->
val override = overrides[entry.route] ?: return@mapNotNull entry
when {
override.hidden -> null
override.label != null -> entry.copy(label = override.label)
else -> entry
}
}
}

View File

@@ -1,324 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
*
* The public nav an admin edits is keyed by **website** paths, so honoring it in
* the app needs a translation. This is the one new piece of cross-repo coupling
* the milestone introduces, which is why it lives in a single file with the
* website's own arrays quoted right beside it — the coupling is visible and
* reviewable in one place rather than spread across the drawer's call sites.
*
* ## The nav is TWO arrays now, and that is what M13 had to correct
*
* This file was written when the website's public nav was one sixteen-row array.
* Since the module-system cutover on 2026-08-12 it is **core's eight rows plus
* every installed module's**, interleaved at render time by `withModuleNav`, and
* a module's pages are mounted by core at `/<module id>/<path>` — so the nine
* shard rows moved from `/site/champs` to `/uo/champs` and this table stopped
* resolving any of them. Three things followed, all of them true of the shipped
* app until M13: a nav override on a shard row was ignored, an added link to a
* shard page handed off to a browser instead of opening natively, and the sort
* key line below was a sixteen-row line against a nav numbered differently.
*
* **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative
* — reading the installed module's id from `GET /public/modules` and building
* `/<id>/shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer
* a route from a capability"*) and would hardcode the same path shape less
* visibly. A site running a different game module matches none of these nine, its
* links hand off to a Custom Tab, and that is the correct answer rather than a
* gap: core cannot tell the app what another module calls its pages.
*
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
* exported owner of core's list (`export const NAV`, and Admin → Navigation edits
* exactly it):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Events', to: '/site/events' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { label: 'Wiki', to: '/wiki' },
* { label: 'About', to: '/site/about' },
* ]
* ```
*
* and from `module-uo/client/src/entry.jsx`, which registers the rest:
*
* ```jsx
* registry.registerNav(ID, {
* area: 'public',
* items: [
* { label: 'Shard', to: '/uo/shard', feature: 'status' },
* { label: 'Champions', to: '/uo/champs', feature: 'champs' },
* { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/uo/governors', feature: 'governors' },
* { label: 'Houses', to: '/uo/houses', feature: 'houses' },
* { label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/uo/market', feature: 'market' },
* ],
* })
* ```
*
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
* rows in registration order — which is the order they are listed in below.
*
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
* app's own source of truth for gating, and a second copy of a security-relevant
* value that drifts silently is worth more than it costs. This table carries the
* mapping and nothing else.
*
* Not every row maps to something the app shows in its drawer, and that is the
* design rather than an omission — see [WEB_PATH_TO_ROUTE].
*/
/** One row of the website's public nav: its path, and the app route it opens. */
data class WebNavPath(val path: String, val route: String)
/**
* The website's public nav in **its** order, mapped to app routes.
*
* The order is load-bearing, not decorative: a stored `order` is an index into
* *this* list (the admin's editor writes the position a row holds on the web), so
* a row the admin never moved has to take its key from the same number line or
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
*
* Core's eight first, then the module's nine, because that is what `withModuleNav`
* renders and therefore what the admin's editor numbered.
*/
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
WebNavPath("/site/events", Routes.EVENTS),
// The app's News screen carries all four categories as tabs, so these three
// have a route but no drawer row of their own — see the note below.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
WebNavPath("/wiki", Routes.WIKI),
WebNavPath("/site/about", Routes.page("about")),
// module-uo's rows. Mounted by core at `/<module id>/<path>`, which is why
// every one of these is `/uo/` and not `/site/`.
WebNavPath("/uo/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/uo/champs", Routes.SHARD_CHAMPS),
WebNavPath("/uo/guilds", Routes.SHARD_GUILDS),
WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/uo/houses", Routes.SHARD_HOUSES),
WebNavPath("/uo/rules", Routes.SHARD_RULES),
WebNavPath("/uo/atlas", Routes.ATLAS),
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/uo/market", Routes.SHARD_MARKET),
)
/**
* The same table as a lookup.
*
* **A mapped route is not the same thing as a drawer row.** Seven of these paths
* resolve to a screen the app reaches some other way: the three news categories
* are tabs on one News screen, and champs / guilds / governors / houses sit behind
* the Shard hub because that is the better shape on a phone. An override for one
* of them is **ignored** — §6.1's rule is that a nav override may never introduce
* navigation, and the hub is a design decision, not an accident to correct. The
* merge enforces that by intersecting with [APP_MENU]; nothing here needs to know
* which rows those are.
*
* The mapping still exists for all sixteen because phase 6's added links resolve
* an admin-authored path against the same table, and *there* a category tab or a
* hub board is a perfectly good destination — the admin asked for it by path.
*/
val WEB_PATH_TO_ROUTE: Map<String, String> =
WEBSITE_PUBLIC_NAV.associate { it.path to it.route }
/**
* Each app route's index in the website's own nav order — the sort key a row the
* admin never moved takes, so it lands on the same number line as a stored
* `order`. All sixteen routes are distinct, so this loses nothing.
*/
internal val WEB_ROUTE_ORDER: Map<String, Int> =
WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index }
/**
* The app route a website nav path opens, or null when the app has no screen for
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
* settings row may carry one; the root path is left alone.
*/
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
private fun normalizeWebPath(path: String?): String? {
val trimmed = path?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
return normalized.ifEmpty { "/" }
}
/**
* The website's top-level paths that are **not** CMS pages.
*
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
* last rule open an admin-authored page natively. These are the segments that rule
* must not swallow: the SPA's own sections, and the two server mounts. A link to
* one of them hands off to the browser, which is where they actually live.
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
// An installed module's pages are mounted at `/<id>/…` and are not CMS pages.
// Only ids the app knows about need listing: an unknown module's `/<id>` would
// resolve to a CMS page that 404s, which is the same answer the browser gives
// it, and core cannot enumerate them for us here anyway.
"uo",
)
/**
* The app route an **arbitrary** website path opens, or null when the app has no
* screen for it and the link must hand off to a Custom Tab (§6.3).
*
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
* answers for a path an admin typed into an added link, which may name any page on
* the site. It is the app's read of the site's own route table, and like the table
* above it is cross-repo coupling kept in one file — quoted here for the same
* reason, from `website/client/src/App.jsx`:
*
* ```jsx
* <Route path="/" element={<Portal />} />
* <Route path="/site/news" element={<News />} />
* <Route path="/site/screenshots" element={<Screenshots />} />
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/events" element={<Events />} />
* <Route path="/site/events/series/:slug" element={<EventSeries />} />
* <Route path="/site/events/:slug" element={<EventPage />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // Installed modules' pages, mounted at `/<module id>/<path>`:
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
*
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
* category page; the newsletter's is the site's one post-detail route), no
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
*
* ```
* / → HOME
* /site/news → NEWS
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /site/events → EVENTS
* /site/events/series/<slug> → EVENT_SERIES
* /site/events/<slug>[?run=<id>] → EVENT (the one route that takes a query)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /uo/<shard surface> → the mapped shard route (§6.2)
* /uo/atlas/<slug> → ATLAS_CREATURE
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off — with exactly one
* exception.** The rule exists because no app route took either, so a native
* match would quietly drop what the admin wrote while the browser honors it. The
* event page (M13) is the first route that takes a query, and it takes one key:
* `run`, which is what every `event.` announcement's `eventUrl` carries. So a
* `?run=` on an event path resolves natively and **anything else in a query
* 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".
*
* 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
* it.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
* the Market screen's honest "not published here" state, which is what typing the
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val raw = path?.trim().orEmpty()
// A fragment is never honored natively: no app route has one to put it in.
if (raw.isEmpty() || '#' in raw) return null
val queryAt = raw.indexOf('?')
val query = if (queryAt >= 0) raw.substring(queryAt + 1) else ""
val normalized = normalizeWebPath(if (queryAt >= 0) raw.substring(0, queryAt) else raw)
?: return null
if (query.isEmpty()) WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
// The one path that may carry a query, and the one key it may carry. Checked
// before the general "a query hands off" rule below, and nowhere else.
if (segments.size == 3 && segments[0] == "site" && segments[1] == "events" &&
segments[2] != "series"
) {
// No query is the ordinary case — a link to the event rather than to one
// of its occurrences. A query is honored only when it is exactly the run.
if (query.isEmpty()) return Routes.event(segments[2])
val run = runParam(query) ?: return null
return Routes.event(segments[2], run)
}
if (query.isNotEmpty()) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments[0] == "site" && segments.size == 4 && segments[1] == "events" &&
segments[2] == "series" -> Routes.eventSeries(segments[3])
segments[0] == MODULE_UO && segments.size == 3 && segments[1] == "atlas" ->
Routes.atlasCreature(segments[2])
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
else -> null
}
}
/**
* The `run` value of a query that consists of **exactly** `run=<something>`, or
* null for every other query — including one that merely contains a `run` among
* others.
*
* Deliberately not a query parser. A second parameter means the writer meant
* something the app cannot honor, and the honest answer to that is the browser.
* An empty value (`?run=`) is null too: it would reach the screen as a blank
* string and be forwarded to the server as one.
*/
private fun runParam(query: String): String? {
val value = query.removePrefix("run=")
if (value.length == query.length || value.isEmpty()) return null
return value.takeIf { '&' !in it && '=' !in it }
}
/**
* The module id whose public pages this table maps.
*
* Named once rather than spelled into four branches, so what is coupled to one
* module is countable. It is a literal on purpose — see the file header.
*/
private const val MODULE_UO = "uo"

View File

@@ -1,239 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
/**
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
*
* The public nav is the one nav an admin can restructure rather than only reorder,
* and §6.1's invariant survives that structurally rather than by vigilance: a
* coded row is still keyed by a website path the app's own table declares, so an
* override still cannot invent a destination or touch a gate, while everything
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
* is applied and the result is resolved through [resolveWebPath].
*
* An added link carries no gate and needs none — the screen behind it enforces its
* own access, so a link to somewhere this caller cannot reach lands on that
* screen's own honest state, exactly as typing the URL on the web does.
*/
sealed interface NavNode {
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
data class Item(val entry: MenuEntry) : NavNode
/**
* An admin-authored link to a page on this site.
*
* @param path the stored website path, already validated — this is what a
* Custom Tab opens, resolved against the site's base URL
* @param route the app route [path] maps to, or null when the app has no
* screen for it and the link must hand off (§6.3)
*/
data class Link(
val id: String,
val label: String,
val path: String,
val route: String?,
) : NavNode
/**
* A drawer group: its [label] as a header, its [items] beneath it.
*
* The website renders these as click-to-open dropdowns; a drawer is already a
* vertical list, so the app renders the group open (§6.3). Never empty — see
* [pruneNav].
*/
data class Section(
val id: String,
val label: String,
val items: List<NavNode>,
) : NavNode
}
/** A usable `sections` entry. */
private data class SectionSpec(val id: String, val label: String, val order: Double?)
/** A usable `links` entry, with its section already checked against the stored ones. */
private data class LinkSpec(
val id: String,
val label: String,
val to: String,
val order: Double?,
val section: String?,
)
/** One node waiting to be placed: its sort key, and whether that key was stored. */
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
/**
* Characters that must never appear in a stored link path. The same rule the
* website applies on read: a value that would leave the origin, or carry markup
* into a link, is dropped rather than rendered.
*/
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
// Forgiving, like every other read in M12: an entry that is not usable is dropped
// and its neighbours kept. A repeated id is dropped too — the first wins, since
// the id is what a link's identity in the drawer is.
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { section ->
val id = section.stringOrNull("id") ?: return@mapNotNull null
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (!seen.add(id)) return@mapNotNull null
SectionSpec(id = id, label = label, order = section.orderOrNull())
}
}
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { link ->
val id = link.stringOrNull("id") ?: return@mapNotNull null
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val to = link.stringOrNull("to") ?: return@mapNotNull null
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
return@mapNotNull null
}
if (!seen.add(id)) return@mapNotNull null
LinkSpec(
id = id,
label = label,
to = to,
order = link.orderOrNull(),
// A link naming a section that does not exist is a top-level link, not
// a dropped one: the admin's destination is still good.
section = link.stringOrNull("section")?.takeIf { it in knownSections },
)
}
}
private fun JsonObject.stringOrNull(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.orderOrNull(): Double? =
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
// index (the admin said "first", so first), and two explicit orders keep
// declaration order, because the sort is stable.
private fun List<Placed>.place(): List<NavNode> =
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
/**
* The coded menu with the admin's `nav_public` applied in full: relabeled,
* reordered and hidden as phase 5 already did, plus grouped into sections and
* joined by added links.
*
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
* is unchanged (§2). The tree build only runs when the admin actually created
* structure.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav
*/
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
val sections = readSections(sectionsOf(navPublic))
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
if (sections.isEmpty() && links.isEmpty()) {
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
}
val knownSections = sections.map { it.id }.toSet()
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu declares — the same
// narrowing as the flat merge, so an override for a path the app maps but does
// not surface (a news category tab, a Shard hub board) is dropped here too.
val overrides = buildMap {
for ((path, raw) in itemsOf(navPublic)) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
// A section the stored value never declares is no section at all.
val section = override.section?.takeIf { it in knownSections }
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
}
}
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no website counterpart to be reordered against or grouped under, so they
// keep their coded order after the public block — where they already sit (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val placed = mutableListOf<Placed>()
for (entry in mapped) {
val override = overrides[entry.route]
if (override?.hidden == true) continue
placed += Placed(
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
section = override?.section,
// An untouched row's key is its index in the WEBSITE's nav, so stored
// and implicit keys sit on one number line (phase 5).
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
explicit = override?.order != null,
)
}
// An admin-created entity with no stored order appends after the coded rows, in
// creation order, rather than jumping to the front on a 0 default.
var next = WEBSITE_PUBLIC_NAV.size
for (section in sections) {
placed += Placed(
node = NavNode.Section(section.id, section.label, emptyList()),
section = null,
key = section.order ?: (next++).toDouble(),
explicit = section.order != null,
)
}
for (link in links) {
placed += Placed(
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
section = link.section,
key = link.order ?: (next++).toDouble(),
explicit = link.order != null,
)
}
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
return top.map { node ->
if (node !is NavNode.Section) {
node
} else {
node.copy(items = placed.filter { it.section == node.id }.place())
}
} + appOnly.map { NavNode.Item(it) }
}
/**
* The tree with this caller's gates applied — and a section they empty dropped.
*
* This is the boundary, and it runs **after** [buildNavTree], never before: an
* override is presentation, so a row it relabels, moves or marks `hidden: false`
* is still shown only if [isVisible] says so (§6.1, AC-3).
*
* The empty-section case is the one with real correctness risk and the reason the
* rule is ported rather than left to the drawer: a group whose every member is
* withheld by the caller's role or by the shard's visibility config must not draw
* as a header with nothing under it.
*
* Links are not gated — see [NavNode].
*
* @param isVisible the caller's own predicate, applied to coded items only, so
* this file stays ignorant of sessions and shard features
*/
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
return tree.mapNotNull { node ->
when (node) {
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
else -> node.takeIf { keep(it) }
}
}
}

View File

@@ -3,8 +3,6 @@
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository
/**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
* plain strings for Navigation-Compose; argument-bearing routes expose a
@@ -16,59 +14,12 @@ object Routes {
const val WIKI = "wiki"
const val CONTACT = "contact"
/**
* The News hub's NavHost pattern: [NEWS] plus an optional category, so a link
* to one of the website's three category pages can land on the matching tab
* (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern
* with no argument and opens the default tab, so every existing call site —
* the drawer, [forStream] — is unaffected.
*
* Declared beside [NEWS] rather than replacing it because the two are used for
* different things: this is what `composable()` and `destination.route` speak,
* [NEWS] is what callers navigate to.
*/
const val NEWS_ROUTE = "news?category={category}"
/** Native login (§4.1) and the signed-in account surface (§5). */
const val LOGIN = "login"
const val ACCOUNT = "account"
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/**
* 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".
*/
/** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications"
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
/**
* Events (§9 M13) — CORE's, not a module's: these screens exist on a backend
* with no game module at all, which is why they are not under `shard/`.
*
* **[EVENT_ROUTE] is the app's first route that takes a query**, and it takes
* exactly one: `run`, naming which occurrence a results table is about. The
* page lives at the definition's slug so a weekly event has one address that
* survives a retitle, and the occurrence has to live somewhere else. See
* [resolveWebPath], whose "a query hands off" rule this is the one exception
* to.
*
* **[MY_EVENTS] is `account/events` and not `events/mine`**, which is not
* cosmetic: `events/mine` and `events/{slug}` are both two segments, and a
* static-versus-argument race between two NavHost patterns is exactly the bug
* events Phase 13 shipped one tier along, where a static `events/new` outranked
* `events/:id` in React Router and made creating an event impossible for seven
* phases. Under `account/` there is no dynamic sibling and no race to lose.
*/
const val EVENTS = "events"
const val EVENT_ROUTE = "events/{slug}?run={run}"
const val EVENT_SERIES = "events/series/{slug}"
const val MY_EVENTS = "account/events"
/** Public shard hub (§6.2). */
const val SHARD = "shard"
@@ -79,18 +30,6 @@ object Routes {
const val SHARD_GOVERNORS = "shard/governors"
const val SHARD_HOUSES = "shard/houses"
/**
* Protocol 3.0 shard content (M11), each gated by its own visibility feature. The
* atlas is not under `shard/` on the wire (`/public/atlas`) because it is static
* content rather than live state, but it is a peer of these in the app's nav.
*/
const val SHARD_RULES = "shard/rules"
const val SHARD_LEADERBOARDS = "shard/leaderboards"
const val SHARD_MARKET = "shard/market"
const val SHARD_MARKET_VENDOR = "shard/market/{serial}"
const val ATLAS = "atlas"
const val ATLAS_CREATURE = "atlas/{slug}"
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
const val PLAYER_CHARACTERS = "player/characters"
const val PLAYER_VENDORS = "player/vendors"
@@ -99,13 +38,6 @@ object Routes {
/** A single character sheet by in-game (hex) serial. */
const val PLAYER_CHAR = "player/char/{serial}"
/** Staff operations (§1, §6.4, M10). Gated to staff roles by the menu access level;
* the backend re-checks role on every `/admin/…` call. */
const val ADMIN_DASHBOARD = "admin/dashboard"
const val ADMIN_MODERATION = "admin/moderation"
const val ADMIN_SUPPORT = "admin/support"
const val ADMIN_CONTENT = "admin/content"
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
const val PAGE = "page/{slug}"
@@ -120,44 +52,15 @@ object Routes {
const val CATEGORY = "category"
const val ID_OR_SLUG = "idOrSlug"
const val SERIAL = "serial"
const val RUN = "run"
}
fun page(slug: String) = "page/$slug"
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
/**
* The News hub with [category] preselected. Takes the enum rather than a slug
* so an unmapped category cannot reach the NavHost — the screen's tabs are the
* enum's entries, and a slug it doesn't know would select nothing.
*/
fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}"
fun wikiPage(slug: String) = "wiki/$slug"
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
fun playerChar(serial: String) = "player/char/$serial"
/** One player vendor's shop, by in-game (hex) serial. */
fun marketVendor(serial: String) = "shard/market/$serial"
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"
/**
* One event's page, optionally about one occurrence.
*
* [runId] is what an announcement's link carries, and it is dropped when
* absent rather than sent as an empty argument — `events/x?run=` would reach
* the screen as a blank string and be forwarded to the server as one.
*/
fun event(slug: String, runId: String? = null): String {
val base = "events/$slug"
return if (runId.isNullOrBlank()) base else "$base?run=$runId"
}
/** One arc, by slug. */
fun eventSeries(slug: String) = "events/series/$slug"
/**
* The in-app destination a tapped push notification deep-links to (§11, M7
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
@@ -174,35 +77,6 @@ object Routes {
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
// An engagement rule's tickle carries the TRIGGER id as its stream
// (ENGAGEMENT.md §7.2's one namespace), and `event.run.started` is the only
// event trigger that is also a push stream. The calendar is the honest
// destination when there is no inbox row to send it to — the tickle names
// no occurrence, so there is no page to open. A row, when there is one,
// wins via [forTickle] and carries the link that does.
else -> if (streamId.startsWith(EVENT_STREAM_PREFIX)) EVENTS else HOME
else -> HOME
}
/** What every core `event.` trigger id begins with (EVENTS.md §J). */
private const val EVENT_STREAM_PREFIX = "event."
/**
* Where a tapped tickle lands, given both halves of `{ stream, ref }`.
*
* **A `notification:<id>` ref means the engine wrote this user an inbox row**
* (`pushChannel.js` builds it), so the tap goes to the inbox whatever the
* stream is — an engagement rule's stream id is a TRIGGER id in §7.2's one
* namespace, and [forStream]'s fixed map would send most of them to Home.
* Every other tickle keeps the route it has always had, so no shipped stream
* changes where it lands.
*
* The ref is not decoded beyond that prefix and is never rendered: it is a
* hint that a row exists, and the app's contract is wake-and-pull.
*/
fun forTickle(streamId: String, ref: String?): String =
if (ref != null && ref.startsWith(INBOX_REF_PREFIX)) NOTIFICATIONS else forStream(streamId)
/** What `pushChannel.js` prefixes an inbox row's id with. */
const val INBOX_REF_PREFIX = "notification:"
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
@@ -28,7 +29,6 @@ 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
/** News hub with category tabs and a post list (PLAN.md §6.1). */
@Composable
@@ -82,7 +82,7 @@ private fun PostList(
@Composable
private fun PostRow(post: PostDto, onClick: () -> Unit) {
ShardCard(
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)

View File

@@ -3,14 +3,12 @@
*/
package com.runicgateway.app.ui.news
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -23,15 +21,9 @@ import javax.inject.Inject
@HiltViewModel
class NewsViewModel @Inject constructor(
private val contentRepository: ContentRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Which tab to open on. Absent — every route into this screen except an
// admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the
// default feed, and so is a slug the app doesn't know.
private val _category = MutableStateFlow(
PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS,
)
private val _category = MutableStateFlow(PostCategory.NEWS)
val category: StateFlow<PostCategory> = _category.asStateFlow()
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)

View File

@@ -1,60 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.inbox.InboxCache
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The drawer's unread badge (ENGAGEMENT.md phase 8).
*
* Its own view model, and its own endpoint: `/notifications/unread-count` exists
* precisely because this is the question asked most often and it should not make
* the server assemble a page of bodies to answer with one integer. Refreshed when
* the app resumes rather than on a timer — the tickle is what says "something
* happened", so polling would be a second, worse copy of push.
*
* Falls back to the cached count while offline, for the same reason the inbox
* does: a badge that dropped to zero because the train went into a tunnel would
* be telling the user they have read something they have not.
*/
@HiltViewModel
class InboxBadgeViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val cache: InboxCache,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
private val _unread = MutableStateFlow(0)
val unread: StateFlow<Int> = _unread.asStateFlow()
/** Ask the server, falling back to the snapshot. A signed-out session is zero. */
fun refresh() = viewModelScope.launch {
val user = (sessionManager.state.value as? Session.SignedIn)?.user
if (user == null) {
_unread.value = 0
return@launch
}
when (val result = notifications.unreadCount()) {
is ApiResult.Ok -> _unread.value = result.data.unread
else -> {
val owner = InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
cache.read(owner)?.let { _unread.value = it.unread }
}
}
}
}

View File

@@ -1,34 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import com.runicgateway.app.core.time.parseWireInstant
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** — see [parseWireInstant], which owns that trap for every screen that
* reads a timestamp, this one and the event screens (M13).
*
* 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 = parseWireInstant(raw) ?: return null
return try {
formatter.withZone(zone).format(instant)
} catch (_: Exception) {
null
}
}

Some files were not shown because too many files have changed in this diff Show More