1 Commits

Author SHA1 Message Date
68da9da805 ci(release): auto-release engine (conventional commits) for the APK
Replace the tag-triggered release.yml with link/'s language-agnostic release
engine, adapted for Android. On every push to main it derives the next version
from conventional-commit subjects since the last v* tag (feat!/BREAKING -> major,
feat -> minor, fix|perf -> patch; nothing releasable -> no release), generates a
grouped changelog, bumps versionName in build.gradle.kts (versionCode derived
major*10000+minor*100+patch, monotonic), builds the SIGNED release APK, then
commits the bump [skip ci], tags vX.Y.Z, and creates the Gitea release with the
notes + APK + SHA256SUMS.

Uses REGISTRY_USER/REGISTRY_TOKEN (write:repository) to push the bump + create
the release, matching link/. main must allow that account to push (bump lands on
main; the [skip ci] + head-commit guard prevent a re-trigger loop). Signing
secrets (ANDROID_KEYSTORE_BASE64/_PASSWORD, ANDROID_KEY_ALIAS/_PASSWORD) unchanged.
Same self-hosted-runner handling as pr-checks.yml (apt JDK 17, sdkmanager, chmod).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 04:23:23 -05:00
113 changed files with 233 additions and 8091 deletions

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,23 +1,37 @@
# Automated release for the Runic Gateway Android app. # Automated build + release for the Runic Gateway Android app.
# #
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the # Trigger: every push to `main` (i.e. every merged PR).
# build never has to push to protected `main`; the tag *is* the release input.
# #
# To cut a release: # Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine:
# git tag v0.1.0 && git push origin v0.1.0
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
# the workflow_dispatch input below.
# #
# versionName = the tag without its leading `v`; versionCode = major*10000 + # ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected # │ reads: latest v* git tag + conventional-commit subjects │
# into app/build.gradle.kts for the build only — nothing is committed back to main. # │ produces: next version, changelog, and (at the end) the release │
# └───────────────────────────────────────────────────────────────────┘
# ┌── ANDROID ADAPTER (the only Android-specific part) ───────────────┐
# │ consumes: the version │
# │ produces: the artifact (a signed release APK + SHA256SUMS) │
# └───────────────────────────────────────────────────────────────────┘
#
# Version bump (conventional commits since the last v* tag):
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
# nothing releasable -> no release is cut
# (first ever run, no tag) -> releases the current build.gradle.kts version as-is
# versionName is the semver; versionCode is derived major*10000+minor*100+patch so
# it is deterministic + monotonic (PLAN.md §10). The bump is committed back to
# app/build.gradle.kts, then tagged.
# #
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app): # Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release) # REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository` (push the bump
# commit + tag and create the release)
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line) # ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
# ANDROID_KEYSTORE_PASSWORD — keystore password # ANDROID_KEYSTORE_PASSWORD — keystore password
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway) # ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore) # ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
# Also: `main` must accept a direct push from the REGISTRY_USER account (disable
# branch protection for it, or add it as an exception) — the bump commit lands on
# main. The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
# #
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container # Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base # lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
@@ -28,16 +42,11 @@ name: Release APK
on: on:
push: push:
tags: branches: [main]
- 'v*' workflow_dispatch: {}
workflow_dispatch:
inputs:
tag:
description: 'Existing v* tag to (re)build and release'
required: true
concurrency: concurrency:
group: release-apk-${{ github.event.inputs.tag || github.ref_name }} group: release-apk
cancel-in-progress: false cancel-in-progress: false
env: env:
@@ -48,10 +57,10 @@ env:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on # Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
# the self-hosted runner) instead of hanging forever and — because concurrency # Quoted because the expression contains a colon (`chore(release):`), which an
# is `cancel-in-progress: false` — blocking every later release behind it. # unquoted YAML scalar would misparse as a mapping value.
timeout-minutes: 30 if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
steps: steps:
- name: Install base tools + JDK 17 - name: Install base tools + JDK 17
run: | run: |
@@ -59,46 +68,70 @@ jobs:
apt-get install -y git curl unzip jq openjdk-17-jdk-headless apt-get install -y git curl unzip jq openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out the release tag (full history for the changelog) - name: Check out full history (need tags + commit log for the bump)
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
fetch-depth: 0 fetch-depth: 0
# ── Derive version + changelog straight from the tag ───────────────── # ── RELEASE ENGINE: decide the next version + changelog ──────────────
- name: Plan the release (version + changelog from the tag) - name: Plan the release (version + changelog)
id: plan id: plan
run: | run: |
set -euo pipefail set -euo pipefail
mkdir -p dist mkdir -p dist
git fetch --tags --force >/dev/null 2>&1 || true git fetch --tags --force >/dev/null 2>&1 || true
TAG="${{ github.event.inputs.tag || github.ref_name }}" # Current committed version (the `?: "x.y.z"` default in build.gradle.kts).
case "$TAG" in MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)"
v[0-9]*) : ;; LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;; if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
esac
VERSION="${TAG#v}"
# versionCode: deterministic + monotonic from the semver (PLAN.md §10). SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
BUMP=none
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
bump() { # <x.y.z> <major|minor|patch> -> bumped
IFS=. read -r MA MI PA <<< "$1"
case "$2" in
major) echo "$((MA+1)).0.0" ;;
minor) echo "${MA}.$((MI+1)).0" ;;
patch) echo "${MA}.${MI}.$((PA+1))" ;;
esac
}
RELEASE=true
if [ -z "$LAST_TAG" ]; then
VERSION="$MANIFEST_VERSION" # first release: ship what's committed
elif [ "$BUMP" = none ]; then
RELEASE=false # no feat/fix/breaking since last tag
VERSION="${LAST_TAG#v}"
else
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
fi
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
echo "Tag v${VERSION} already exists — nothing to release."
RELEASE=false
fi
# Derive a deterministic, monotonic versionCode from the semver.
IFS=. read -r MA MI PA <<< "$VERSION" IFS=. read -r MA MI PA <<< "$VERSION"
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
VERSION_CODE=$(( MA*10000 + MI*100 + PA )) VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
# Changelog: conventional-commit subjects since the previous v* tag.
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
{ {
echo "## Runic Gateway Android ${TAG}" echo "## Runic Gateway Android v${VERSION}"
echo echo
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)" FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)" FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
echo "### All changes" echo "### All changes"
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
echo "$SUBJECTS" | sed 's/^/- /' echo "$SUBJECTS" | sed 's/^/- /'
echo echo
echo "---" echo "---"
@@ -107,25 +140,35 @@ jobs:
echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT" echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}" echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
echo "==> release=${RELEASE} version=${VERSION} code=${VERSION_CODE} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
# ── SDK + signing keystore ─────────────────────────────────────────── # ── ANDROID ADAPTER: SDK + signing keystore ──────────────────────────
- name: Set up Android SDK - name: Set up Android SDK
if: ${{ steps.plan.outputs.release == 'true' }}
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with:
# Only put cmdline-tools on PATH. The action's default package set drags in
# the whole emulator + the legacy `tools` package (hundreds of MB, network-
# bound on this runner) that a headless APK build never uses. The next step
# installs exactly the packages we need.
packages: ''
- name: Install Android SDK packages - name: Install Android SDK packages
if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set +o pipefail set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
if: ${{ steps.plan.outputs.release == 'true' }}
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 }}-
- name: Decode signing keystore - name: Decode signing keystore
if: ${{ steps.plan.outputs.release == 'true' }}
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: | run: |
@@ -137,8 +180,9 @@ jobs:
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks" printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV" echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
# ── Set the version, build the signed APK ──────────────────────────── # ── ANDROID ADAPTER: set the version, gate, build the signed APK ─────
- name: Set the app version to match the tag - name: Set the app version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}" VERSION="${{ steps.plan.outputs.version }}"
@@ -149,6 +193,7 @@ jobs:
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts" grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
- name: Unit tests + signed release APK - name: Unit tests + signed release APK
if: ${{ steps.plan.outputs.release == 'true' }}
env: env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -159,6 +204,7 @@ jobs:
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
- name: Package APK + SHA256SUMS - name: Package APK + SHA256SUMS
if: ${{ steps.plan.outputs.release == 'true' }}
run: | run: |
set -euo pipefail set -euo pipefail
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
@@ -167,8 +213,37 @@ jobs:
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS ) ( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
ls -l dist && cat dist/SHA256SUMS ls -l dist && cat dist/SHA256SUMS
# ── Create the Gitea release + upload assets (no push to main) ─────── # ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
- name: Commit version bump and push tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline; a stray CR/LF corrupts the
# remote URL / auth header. Strip line breaks before use.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "android-app-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add "${GRADLE_MODULE}/build.gradle.kts"
if ! git diff --cached --quiet; then
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
git push origin "HEAD:main"
else
echo "Version unchanged (first release) — no bump commit needed."
fi
git tag "${TAG}"
git push origin "${TAG}"
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
- name: Create Gitea release and upload assets - name: Create Gitea release and upload assets
if: ${{ steps.plan.outputs.release == 'true' }}
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |

View File

@@ -1,90 +0,0 @@
# Run SonarQube static analysis against the code that just landed on `main` and
# report the results to the self-hosted SonarQube server for review. This is
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
# (which gates PRs) and release.yml (which ships the APK) — this one only feeds
# the dashboard.
#
# 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).
# • 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.)
#
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
# 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.
name: SonarQube
on:
push:
branches: [main]
# Allow re-running the analysis on demand from the Actions tab.
workflow_dispatch: {}
concurrency:
group: sonarqube-${{ github.ref }}
cancel-in-progress: true
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:
# SonarQube uses git history to attribute issues to authors and to
# 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:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}

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

@@ -10,11 +10,6 @@ plugins {
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
jacoco
}
jacoco {
toolVersion = "0.8.12"
} }
// Release signing material (PLAN.md §12) is never committed. It is read from, in // Release signing material (PLAN.md §12) is never committed. It is read from, in
@@ -53,19 +48,6 @@ android {
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0" versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Android App Links host (docs/android/APP_LINKS.md). autoVerify needs a
// *literal* host at build time, so a single multi-tenant APK cannot verify
// open-ended shard domains: App Links are a build-time opt-in. Left empty for
// the generic build (custom scheme only); a white-label/first-party build
// bakes one host with `-PappLinkHost=play.myshard.com`.
// • BuildConfig.APP_LINK_HOST — SsoAuthManager reads it to pick the redirect.
// • manifestPlaceholder appLinkHost — substituted into the intent-filter host;
// empty falls back to the reserved `.invalid` sentinel so the autoVerify
// filter is inert (matches no real link, never verifies).
val appLinkHost = (project.findProperty("appLinkHost") as String?)?.trim().orEmpty()
buildConfigField("String", "APP_LINK_HOST", "\"$appLinkHost\"")
manifestPlaceholders["appLinkHost"] = appLinkHost.ifBlank { "runic-gateway.invalid" }
} }
signingConfigs { signingConfigs {
@@ -83,11 +65,6 @@ android {
} }
buildTypes { 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 { release {
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean // R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
// release APK). Keep rules live in proguard-rules.pro. // release APK). Keep rules live in proguard-rules.pro.
@@ -180,35 +157,3 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4) 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,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

@@ -6,13 +6,6 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
and a foreground service that holds the persistent ntfy connection open — the
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".RunicGatewayApp" android:name=".RunicGatewayApp"
android:allowBackup="true" android:allowBackup="true"
@@ -20,61 +13,19 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.RunicGateway"> android:theme="@style/Theme.RunicGateway">
<!-- singleTop so the SSO Custom Tab returning via the deep link reuses the
running task (onNewIntent) instead of stacking a second activity. -->
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.RunicGateway"> android:theme="@style/Theme.RunicGateway">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
one-time authorization code back to this fixed, app-owned custom
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. This is the permanent
fallback on every build (docs/android/APP_LINKS.md). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="runicgateway"
android:host="auth"
android:path="/callback" />
</intent-filter>
<!-- App Links hardening (docs/android/APP_LINKS.md): a verified https
callback that only the domain's real owner can claim. autoVerify
needs a literal host, so ${appLinkHost} is baked at build time
(build.gradle.kts). The generic build leaves it as the reserved
runic-gateway.invalid sentinel — the filter then matches no real
link and never verifies. A white-label build sets -PappLinkHost. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="${appLinkHost}"
android:path="/mobile/callback" />
</intent-filter>
</activity> </activity>
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
dataSync foreground type; not exported — started only by PushManager. -->
<service
android:name=".core.push.PushService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>

View File

@@ -3,25 +3,18 @@
*/ */
package com.runicgateway.app package com.runicgateway.app
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel import com.runicgateway.app.ui.AppViewModel
@@ -33,8 +26,6 @@ import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.ui.theme.RunicGatewayTheme import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
/** /**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run * Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
@@ -44,23 +35,8 @@ import javax.inject.Inject
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// Native SSO bridge — handles the runicgateway://auth/callback deep link (M9,
// §4.2). Field-injected because the callback can arrive independent of any
// ViewModel; a successful exchange flips the SessionManager the whole app
// observes, and the login screen consumes SsoAuthManager.outcome.
@Inject
lateinit var ssoAuthManager: SsoAuthManager
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
// Set from the launching intent and from onNewIntent (the activity is singleTop),
// consumed once by RunicApp which navigates to the stream's screen.
private var pendingStream by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
handleSsoCallback(intent)
// Dark-only app (M5): force light system-bar icons over the transparent bars so // Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme. // they stay legible on the deep blue-black surfaces regardless of system theme.
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT) val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
@@ -82,47 +58,11 @@ class MainActivity : ComponentActivity() {
AppState.NeedsConnection -> AppState.NeedsConnection ->
ConnectScreen(onConnected = appViewModel::onConnected) ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready -> is AppState.Ready ->
RunicApp( RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
brand = s.brand,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null },
)
} }
} }
} }
} }
} }
} }
/**
* A notification tap or an SSO callback arriving while the activity is already
* running (singleTop) — the common case, since the Custom Tab overlays the live
* app during sign-in.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
handleSsoCallback(intent)
}
/**
* Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
* docs/android/APP_LINKS.md). Both feed the *same* exchange; the result surfaces
* on `SsoAuthManager.outcome` (success signs the session in; failure shows on the
* login screen). Non-callback intents are ignored.
*/
private fun handleSsoCallback(intent: Intent?) {
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
val isCallback = ssoAuthManager.matchesCallback(data.scheme, data.host, data.path) ||
ssoAuthManager.matchesAppLinkCallback(data.scheme, data.host, data.path)
if (!isCallback) return
val state = data.getQueryParameter("state")
val code = data.getQueryParameter("code")
val error = data.getQueryParameter("error")
lifecycleScope.launch { ssoAuthManager.complete(state = state, code = code, error = error) }
}
} }

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 role: Role,
) { ) {
val isPlayer: Boolean get() = role == Role.PLAYER 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

@@ -1,50 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
/**
* PKCE + CSRF-state primitives for the Mobile SSO Authorization Bridge — "Layer B"
* of the two PKCE layers (app ↔ website; PLAN.md §4.2, BACKEND_DESIGN "Two PKCE
* layers"). The app proves at `/exchange` that it holds the verifier for the
* challenge it registered at `/start`, so an intercepted callback code is useless
* to anyone but this app.
*
* Pure JVM (no Android framework types) so it unit-tests on the plain test runner.
* The encoding mirrors the backend exactly (RFC 7636 S256): the challenge is
* `base64url(SHA-256(verifier))` with no padding, matching Node's
* `crypto.createHash('sha256').update(verifier).digest('base64url')`.
*/
object Pkce {
private val random = SecureRandom()
// RFC 4648 §5 URL-safe base64 without padding — the base64url the backend uses.
private val encoder = Base64.getUrlEncoder().withoutPadding()
/**
* A fresh high-entropy `code_verifier`: 32 random bytes → 43 base64url chars,
* comfortably inside RFC 7636's 43128 range and identical in form to the
* verifier the website generates for its own IdP layer.
*/
fun newVerifier(): String = randomToken()
/** A fresh opaque CSRF `state` (same entropy/shape as a verifier). */
fun newState(): String = randomToken()
/** `code_challenge` for [verifier] using the S256 method. */
fun challengeOf(verifier: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
return encoder.encodeToString(digest)
}
private fun randomToken(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return encoder.encodeToString(bytes)
}
}

View File

@@ -1,236 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates the native "Sign in with Google/Discord" flow — the app half of the
* Mobile SSO Authorization Bridge (PLAN.md §4.2, BACKEND_DESIGN "Mobile SSO
* Authorization Bridge"). It never adds a parallel auth path: a successful exchange
* drives the *same* [SessionManager.onSignedIn] the password login uses, so the
* menu, push registration, and re-validation all react identically.
*
* The flow:
* 1. [buildStartUrl] mints PKCE (Layer B) + a CSRF `state`, stashes them, and
* returns the `/auth/mobile/sso/start` URL the caller opens in a Custom Tab.
* 2. The website bounces through the IdP and deep-links back to
* [REDIRECT_URI] with `?code&state` (success) or `?error&state` (failure).
* 3. [complete] verifies `state`, exchanges the `code` with the stashed verifier,
* and signs the user in — publishing the result on [outcome]. `MainActivity`
* 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.
*
* 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.
*/
@Singleton
class SsoAuthManager @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
enum class Failure {
/** The user cancelled or the IdP/website refused (e.g. no linked account). */
DENIED,
/** The callback `state` didn't match — CSRF guard, or the pending flow was lost. */
STATE_MISMATCH,
/** The one-time code was unknown / expired / already used, or PKCE failed. */
EXPIRED_CODE,
/** Offline / DNS / TLS / timeout during the exchange. */
NETWORK,
/** Any other server failure, or a missing base URL / malformed callback. */
SERVER,
}
/** The observable result of the most recent flow; the login screen consumes it. */
sealed interface Outcome {
data object Idle : Outcome
data object Success : Outcome
data class Failed(val reason: Failure) : Outcome
}
/**
* 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).
* `internal var` only so unit tests can exercise the App Link path without a build
* flavor; production never reassigns it.
*/
internal var appLinkHost: String = BuildConfig.APP_LINK_HOST
private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle)
val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
/** Ack a delivered [outcome] so it isn't re-handled after a recomposition. */
fun consumeOutcome() {
_outcome.value = Outcome.Idle
}
/**
* 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.
*/
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)
_outcome.value = Outcome.Idle
return base.newBuilder()
.addPathSegments("api/v1/auth/mobile/sso/start")
.addQueryParameter("provider", providerId)
.addQueryParameter("code_challenge", challenge)
.addQueryParameter("state", state)
.addQueryParameter("redirect_uri", redirectUriFor(base.host))
.build()
.toString()
}
/**
* The `redirect_uri` to request for a shard on [pairedHost]: the verified https
* App Link callback **iff** this build baked an App Link host that matches the
* paired host (a white-label/first-party build for exactly this shard — which is
* also responsible for enabling `mobile_app_links_enabled` server-side); otherwise
* the fixed custom-scheme callback, which every build/shard always supports.
*/
private fun redirectUriFor(pairedHost: String): String =
if (appLinkHost.isNotBlank() && appLinkHost.equals(pairedHost, ignoreCase = true)) {
"https://$pairedHost$APP_LINK_CALLBACK_PATH"
} else {
REDIRECT_URI
}
/** True if a deep link's scheme/host/path are our fixed custom-scheme SSO callback. */
fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH
/**
* True if a deep link is a verified https App Link callback for the shard we are
* **currently paired to**. The `host == pairedHost` check is defense-in-depth:
* `autoVerify` already means only a real, opted-in shard domain can route here,
* but the app still refuses an https callback whose host isn't the paired shard.
* Returns false before a shard is configured (no paired host to trust).
*/
fun matchesAppLinkCallback(scheme: String?, host: String?, path: String?): Boolean {
val pairedHost = baseUrlHolder.current?.host ?: return false
return scheme == "https" && path == APP_LINK_CALLBACK_PATH &&
host != null && host.equals(pairedHost, ignoreCase = true)
}
/**
* Handle the parsed callback params from a returned [REDIRECT_URI] deep link:
* verify `state`, map an `error`, else exchange the `code` and sign in.
* Publishes the result on [outcome]. Idempotent-safe: the pending is cleared on
* entry, so a duplicate delivery of the same callback finds no pending and fails
* as [Failure.STATE_MISMATCH] rather than double-exchanging (the backend also
* single-uses the code).
*/
suspend fun complete(state: String?, code: String?, error: String?) {
val stashed = pendingStore.load()
pendingStore.clear()
// CSRF: the callback must echo the exact state we generated at /start.
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
_outcome.value = Outcome.Failed(Failure.STATE_MISMATCH)
return
}
// A website/IdP-side failure comes back as ?error=… (never with a code).
if (!error.isNullOrEmpty()) {
_outcome.value = Outcome.Failed(mapError(error))
return
}
if (code.isNullOrBlank()) {
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
val response = try {
ssoApi.exchange(MobileSsoExchangeRequest(code = code, codeVerifier = stashed.verifier))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
_outcome.value = Outcome.Failed(Failure.NETWORK)
return
} catch (_: Exception) {
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
_outcome.value = Outcome.Failed(Failure.SERVER)
return
}
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
_outcome.value = Outcome.Success
return
}
_outcome.value = Outcome.Failed(if (response.code() == 401) Failure.EXPIRED_CODE else Failure.SERVER)
}
// The bridge's start + callback error codes → user-facing failure reasons.
// Start (mobileSso.controller): invalid_provider | provider_unavailable | server_error.
// Callback (sso.controller): not_linked | disabled | session_expired | error,
// plus a forwarded IdP access_denied.
private fun mapError(error: String): Failure = when (error) {
// Link-only policy refused, or the account is inactive, or the user declined.
"not_linked", "disabled", "access_denied" -> Failure.DENIED
// The bridge session aged out mid-flow — start over.
"session_expired" -> Failure.EXPIRED_CODE
// invalid_provider / provider_unavailable / server_error / error / anything else.
else -> Failure.SERVER
}
companion object {
const val CALLBACK_SCHEME = "runicgateway"
const val CALLBACK_HOST = "auth"
const val CALLBACK_PATH = "/callback"
/**
* The one fixed, application-owned callback the bridge redirects to. Must
* match the `MOBILE_AUTH_REDIRECT_URIS` allowlist entry on the backend and
* the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
*/
const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH"
/**
* Path of the verified https App Link callback (`https://<shard-host>/mobile/callback`).
* Must match the app's `autoVerify` intent-filter in `AndroidManifest.xml` and the
* backend's self-origin allowlist entry (docs/android/APP_LINKS.md §3.2/§4.2).
*/
const val APP_LINK_CALLBACK_PATH = "/mobile/callback"
}
}

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

View File

@@ -1,116 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.isActive
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
/**
* The embedded distributor's transport (PLAN.md §11, M7 Part 2 work item 1/3):
* a persistent connection to the shard's self-hosted ntfy that subscribes to the
* app's own topic and re-emits each content-free tickle. It reuses the same
* OkHttp-SSE + reconnect/backoff shape as [com.runicgateway.app.core.net.ShardStreamClient],
* but on a **bare** client — no host-retargeting or bearer interceptors — because it
* talks straight to ntfy (`<ntfy>/<topic>/sse`), not the website API. Held open by
* [PushService]'s foreground service so tickles arrive in the background without
* Google Play Services.
*/
@Singleton
class NtfyStreamClient @Inject constructor(
private val json: Json,
) {
// A dedicated client with the read timeout disabled for the mostly-idle stream
// (ntfy sends keepalive frames); no interceptors so nothing rewrites the host or
// attaches a bearer to the relay.
private val client: OkHttpClient = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(true)
.build()
private val factory = EventSources.createFactory(client)
/** Connection lifecycle + decoded tickles for a subscribed topic. */
sealed interface Event {
data object Open : Event
data object Closed : Event
data class Message(val tickle: PushTickle) : Event
}
/**
* A cold flow subscribing to `<ntfyBaseUrl>/<topic>/sse`, reconnecting with
* backoff until the collector cancels. A dropped relay simply reconnects; a bad
* config (null URL) idles rather than spinning.
*/
fun events(ntfyBaseUrl: String?, topic: String): Flow<Event> = channelFlow {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
val url = NtfyTopic.sseUrl(ntfyBaseUrl, topic)
if (url == null) {
trySend(Event.Closed)
delay(backoffMs)
backoffMs = grow(backoffMs)
continue
}
val request = Request.Builder()
.url(url)
.header("Accept", "text/event-stream")
.build()
val opened = AtomicBoolean(false)
val ended = CompletableDeferred<Unit>()
val listener = object : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) {
opened.set(true)
trySend(Event.Open)
}
override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
parseNtfyTickle(json, data)?.let { trySend(Event.Message(it)) }
}
override fun onClosed(eventSource: EventSource) {
trySend(Event.Closed)
ended.complete(Unit)
}
override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
trySend(Event.Closed)
ended.complete(Unit)
}
}
val source = factory.newEventSource(request, listener)
try {
ended.await()
} finally {
source.cancel()
}
backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
delay(backoffMs)
}
}
private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)
private companion object {
const val INITIAL_BACKOFF_MS = 2_000L
const val MAX_BACKOFF_MS = 30_000L
}
}

View File

@@ -1,48 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import java.security.SecureRandom
/**
* The app's own ntfy topic — the heart of the embedded-distributor design
* (PLAN.md §11, M7 Part 2 work item 1). The app mints a **random, unguessable**
* topic and registers its public URL (`https://<ntfy-host>/<topic>`) as the device
* endpoint the backend POSTs tickles to; the app subscribes to the same topic's SSE
* stream to receive them. Security rests on the topic being unguessable plus the
* content-free tickle — a leaked topic name reveals nothing.
*/
object NtfyTopic {
// ntfy topic names allow [A-Za-z0-9_-]; keep to that set. The "up" prefix mirrors
// the UnifiedPush convention and makes topics recognizable in logs/relay.
private const val ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
private const val TOPIC_LEN = 24
private const val PREFIX = "up"
private val secureRandom by lazy { SecureRandom() }
/** Mint a fresh unguessable topic, e.g. "up7Qk3…" (≈143 bits of entropy). */
fun generate(random: java.util.Random = secureRandom): String {
val sb = StringBuilder(PREFIX.length + TOPIC_LEN)
sb.append(PREFIX)
repeat(TOPIC_LEN) { sb.append(ALPHABET[random.nextInt(ALPHABET.length)]) }
return sb.toString()
}
/**
* The endpoint URL the backend publishes to: `<ntfyBaseUrl>/<topic>`. [ntfyBaseUrl]
* is the client-facing base from `/public/settings.push.ntfyUrl`; a trailing slash
* is tolerated. Returns null for a blank base or topic.
*/
fun endpointUrl(ntfyBaseUrl: String?, topic: String): String? {
val base = ntfyBaseUrl?.trim()?.trimEnd('/').orEmpty()
if (base.isEmpty() || topic.isBlank()) return null
return "$base/$topic"
}
/** The SSE subscribe URL the app connects to: `<ntfyBaseUrl>/<topic>/sse`. */
fun sseUrl(ntfyBaseUrl: String?, topic: String): String? =
endpointUrl(ntfyBaseUrl, topic)?.let { "$it/sse" }
}

View File

@@ -1,156 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import android.content.Context
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.repository.NotificationsRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates the app's opt-in push lifecycle (PLAN.md §11, M7 Part 2 work item 5):
* mint/keep the ntfy topic, register/unregister the device endpoint with the backend,
* and start/stop the foreground [PushService] — all keyed to the user's opt-in and
* the session. The endpoint the app registers is its own topic URL on the shard's
* ntfy (the embedded-distributor design, work item 1).
*
* Lifecycle rules:
* - register only when **signed in** and the shard advertises a relay (`ntfyUrl`);
* - a **sign-out** stops the service and forgets the ephemeral registration but keeps
* the opt-in intent, so push re-registers on the next sign-in (mirrors the M3 token
* teardown, and covers logout / dead-refresh / server switch uniformly via the
* session-state observer);
* - a **relay/base-URL change** re-registers on the new host with a fresh topic.
*/
@Singleton
class PushManager @Inject constructor(
@param:ApplicationContext private val context: Context,
private val prefs: PushPreferences,
private val notifications: NotificationsRepository,
private val sessionManager: SessionManager,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** Whether the user has push turned on (drives the Notifications screen). */
val enabled: Flow<Boolean> = prefs.enabled
/** Whether this shard advertises a push relay at all (null ntfyUrl → unsupported). */
val supported: Flow<Boolean> = prefs.ntfyUrl.map { !it.isNullOrBlank() }
init {
// Uniform teardown/resume across every auth transition: logout, dead-refresh
// sign-out, and server switch all land on SignedOut; a fresh login re-asserts.
scope.launch {
sessionManager.state.collect { s ->
when (s) {
is Session.SignedOut -> localTeardown()
is Session.SignedIn -> maybeResume()
}
}
}
}
/** Record the shard's client-facing ntfy base URL (from `/public/settings`). */
suspend fun setNtfyUrl(url: String?) {
val previous = prefs.snapshot().ntfyUrl
prefs.setNtfyUrl(url)
// The relay host arriving (or changing) is what unblocks a pending resume.
if (!url.isNullOrBlank() && url != previous) maybeResume()
}
/**
* Turn push on (idempotent): ensure a topic on the current relay, register its
* endpoint with the backend, persist, and start the foreground service. Called
* when the user opts into ≥1 stream.
*/
suspend fun enable(): PushResult = register(setIntent = true)
/** Turn push off (user opted out of every stream): clear intent + deregister. */
suspend fun disable() {
prefs.setEnabled(false)
deregisterDevice()
}
/**
* Deregister this device on an explicit sign-out / server switch, while the bearer
* is still valid, so no orphan device row is left behind. Keeps the opt-in intent
* (and ntfyUrl) so push re-registers on the next sign-in. Call this *before* the
* session is torn down.
*/
suspend fun deregisterDevice() {
val snap = prefs.snapshot()
snap.deviceId?.let { notifications.deleteDevice(it) } // best-effort
stopService()
prefs.clearRegistration()
}
/** Re-assert registration if the user is opted in and the shard supports push. */
private suspend fun maybeResume() {
val snap = prefs.snapshot()
if (snap.enabled && sessionManager.isSignedIn && !snap.ntfyUrl.isNullOrBlank()) {
register(setIntent = false)
}
}
private suspend fun register(setIntent: Boolean): PushResult {
if (!sessionManager.isSignedIn) return PushResult.NotSignedIn
val snap = prefs.snapshot()
val ntfyUrl = snap.ntfyUrl
if (ntfyUrl.isNullOrBlank()) return PushResult.Unsupported
// Reuse an existing topic only if its endpoint still sits on the current relay
// origin; otherwise (first run, or a server switch) mint a fresh unguessable one.
val base = ntfyUrl.trimEnd('/')
val topic = snap.topic?.takeIf { snap.endpoint?.startsWith("$base/") == true }
?: NtfyTopic.generate()
val endpoint = NtfyTopic.endpointUrl(ntfyUrl, topic) ?: return PushResult.Unsupported
return when (val res = notifications.registerDevice(endpoint, PLATFORM)) {
is ApiResult.Ok -> {
prefs.setRegistration(topic, endpoint, res.data.id)
if (setIntent) prefs.setEnabled(true)
startService()
PushResult.Enabled
}
// 400 = endpoint origin isn't on the shard's ntfy allow-set (misconfigured relay).
is ApiResult.HttpError -> PushResult.Failed(res.status)
is ApiResult.NetworkError -> PushResult.Failed(null)
}
}
/** Local-only teardown on sign-out — no backend DELETE (the bearer may be dead). */
private suspend fun localTeardown() {
stopService()
prefs.clearRegistration()
}
private fun startService() = runCatching { PushService.start(context) }
private fun stopService() = runCatching { PushService.stop(context) }
/** The outcome of enabling push, surfaced to the Notifications screen. */
sealed interface PushResult {
data object Enabled : PushResult
/** This shard advertises no push relay (`/public/settings.push.ntfyUrl` is null). */
data object Unsupported : PushResult
data object NotSignedIn : PushResult
/** Registration failed — [status] 400 = relay off the allow-set; null = network. */
data class Failed(val status: Int?) : PushResult
}
private companion object {
const val PLATFORM = "android"
}
}

View File

@@ -1,110 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.runicgateway.app.MainActivity
import com.runicgateway.app.R
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds the notification channels and posts a notification for a received tickle
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
* carries nothing to render, so nothing is fetched to display the notification; tapping
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
*/
@Singleton
class PushNotifier @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val manager = NotificationManagerCompat.from(context)
private val nextId = AtomicInteger(1)
/** Create both channels; safe to call repeatedly (creation is idempotent). */
fun ensureChannels() {
val system = context.getSystemService(NotificationManager::class.java) ?: return
system.createNotificationChannel(
NotificationChannel(
CHANNEL_MESSAGES,
context.getString(R.string.push_channel_messages),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = context.getString(R.string.push_channel_messages_desc) },
)
system.createNotificationChannel(
NotificationChannel(
CHANNEL_SERVICE,
context.getString(R.string.push_channel_service),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = context.getString(R.string.push_channel_service_desc)
setShowBadge(false)
},
)
}
/** The persistent low-importance notification the foreground service runs under. */
fun serviceNotification(): Notification =
NotificationCompat.Builder(context, CHANNEL_SERVICE)
.setContentTitle(context.getString(R.string.push_service_title))
.setContentText(context.getString(R.string.push_service_text))
.setSmallIcon(R.drawable.ic_stat_name)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(deepLinkIntent(stream = null, ref = null))
.build()
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
fun notify(tickle: PushTickle) {
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
val title = context.getString(PushStreams.titleRes(tickle.stream))
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
.setContentTitle(title)
.setSmallIcon(R.drawable.ic_stat_name)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
.build()
try {
manager.notify(nextId.getAndIncrement(), notification)
} catch (_: SecurityException) {
// Racing a permission revoke — drop silently rather than crash.
}
}
private fun deepLinkIntent(stream: String?, ref: String?): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (stream != null) putExtra(EXTRA_STREAM, stream)
if (ref != null) putExtra(EXTRA_REF, ref)
}
// A distinct request code per stream so PendingIntents don't collapse into one.
val requestCode = stream?.hashCode() ?: 0
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
companion object {
const val CHANNEL_MESSAGES = "push_messages"
const val CHANNEL_SERVICE = "push_service"
/** Intent extras a tapped notification carries into [MainActivity] (§7 deep-links). */
const val EXTRA_STREAM = "com.runicgateway.app.push.STREAM"
const val EXTRA_REF = "com.runicgateway.app.push.REF"
}
}

View File

@@ -1,91 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.pushDataStore: DataStore<Preferences> by preferencesDataStore(name = "push")
/**
* Persists the app's push state (PLAN.md §11, M7 Part 2 work item 5). None of it is
* secret — the ntfy topic/endpoint's protection is being unguessable plus the
* content-free tickle — so plain DataStore is fine (tokens stay in the encrypted
* store). Holds the shard's ntfy base URL (from `/public/settings`), the minted
* topic + its endpoint URL, the backend-assigned device id (to unregister), and the
* user's opt-in flag (the source of truth for "push should be running").
*/
@Singleton
class PushPreferences @Inject constructor(
@param:ApplicationContext private val context: Context,
) {
private val store = context.pushDataStore
val enabled: Flow<Boolean> = store.data.map { it[KEY_ENABLED] ?: false }
val ntfyUrl: Flow<String?> = store.data.map { it[KEY_NTFY_URL] }
suspend fun snapshot(): Snapshot {
val p = store.data.first()
return Snapshot(
enabled = p[KEY_ENABLED] ?: false,
ntfyUrl = p[KEY_NTFY_URL],
topic = p[KEY_TOPIC],
endpoint = p[KEY_ENDPOINT],
deviceId = p[KEY_DEVICE_ID],
)
}
suspend fun setNtfyUrl(url: String?) = store.edit {
if (url.isNullOrBlank()) it.remove(KEY_NTFY_URL) else it[KEY_NTFY_URL] = url
}
suspend fun setEnabled(value: Boolean) = store.edit { it[KEY_ENABLED] = value }
/** Record the minted topic + its endpoint URL and the assigned device id together. */
suspend fun setRegistration(topic: String, endpoint: String, deviceId: Long) = store.edit {
it[KEY_TOPIC] = topic
it[KEY_ENDPOINT] = endpoint
it[KEY_DEVICE_ID] = deviceId
}
/**
* Forget the ephemeral device registration (topic/endpoint/device id) — used on
* sign-out and on an explicit disable. Deliberately leaves [KEY_ENABLED] and
* [KEY_NTFY_URL] intact so the user's opt-in intent survives a sign-out and push
* re-registers on the next sign-in; an explicit disable also calls [setEnabled]`(false)`.
*/
suspend fun clearRegistration() = store.edit {
it.remove(KEY_TOPIC)
it.remove(KEY_ENDPOINT)
it.remove(KEY_DEVICE_ID)
}
data class Snapshot(
val enabled: Boolean,
val ntfyUrl: String?,
val topic: String?,
val endpoint: String?,
val deviceId: Long?,
)
private companion object {
val KEY_ENABLED = booleanPreferencesKey("enabled")
val KEY_NTFY_URL = stringPreferencesKey("ntfy_url")
val KEY_TOPIC = stringPreferencesKey("topic")
val KEY_ENDPOINT = stringPreferencesKey("endpoint")
val KEY_DEVICE_ID = longPreferencesKey("device_id")
}
}

View File

@@ -1,95 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.ServiceCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The always-connected foreground service that IS the embedded distributor
* (PLAN.md §11, M7 Part 2 work item 1/3). It holds [NtfyStreamClient]'s persistent
* connection to the shard's ntfy open in the background — the price of Google-free,
* self-contained instant delivery — and posts a notification for each tickle. It runs
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
* and stops it as the user opts in/out or signs out.
*/
@AndroidEntryPoint
class PushService : Service() {
@Inject lateinit var streamClient: NtfyStreamClient
@Inject lateinit var notifier: PushNotifier
@Inject lateinit var prefs: PushPreferences
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var connectionJob: Job? = null
override fun onCreate() {
super.onCreate()
notifier.ensureChannels()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startAsForeground()
if (connectionJob == null) connectionJob = scope.launch { run() }
return START_STICKY
}
private fun startAsForeground() {
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, notifier.serviceNotification(), type)
}
private suspend fun run() {
val snapshot = prefs.snapshot()
val topic = snapshot.topic
if (topic.isNullOrBlank() || snapshot.ntfyUrl.isNullOrBlank()) {
// Nothing to subscribe to (should not happen — PushManager starts us only
// once a topic exists) — stop rather than hold a dead connection open.
stopSelf()
return
}
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
}
}
override fun onDestroy() {
connectionJob?.cancel()
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
private const val NOTIFICATION_ID = 42
fun start(context: Context) {
val intent = Intent(context, PushService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, PushService::class.java))
}
}
}

View File

@@ -1,39 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import androidx.annotation.StringRes
import com.runicgateway.app.R
/**
* The known push stream ids (mirrors the backend catalog in
* `config/notificationStreams.js`) and their localized notification titles.
* The subscribable catalog itself is fetched from
* `GET /auth/me/notifications/streams`; this fixed set is only what the receiver
* needs to title a content-free tickle without a network round-trip (§11).
*/
object PushStreams {
const val NEWS_POST = "news.post"
const val SERVER_STATUS = "server.status"
const val IDOC_WARNING = "idoc.warning"
const val CHAMP_START = "champ.start"
const val GOVERNOR_ELECTION = "governor.election"
const val VENDOR_SALE = "vendor.sale"
const val HOUSE_IDOC = "house.idoc"
const val ACCOUNT_LOGIN = "account.login"
/** A short, localized notification title for [streamId]; a generic fallback otherwise. */
@StringRes
fun titleRes(streamId: String): Int = when (streamId) {
NEWS_POST -> R.string.push_stream_news_post
SERVER_STATUS -> R.string.push_stream_server_status
IDOC_WARNING -> R.string.push_stream_idoc_warning
CHAMP_START -> R.string.push_stream_champ_start
GOVERNOR_ELECTION -> R.string.push_stream_governor_election
VENDOR_SALE -> R.string.push_stream_vendor_sale
HOUSE_IDOC -> R.string.push_stream_house_idoc
ACCOUNT_LOGIN -> R.string.push_stream_account_login
else -> R.string.push_stream_generic
}
}

View File

@@ -1,54 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* The content-free push tickle the backend publishes (PLAN.md §11): `{ stream, ref }`
* and nothing sensitive. [ref] is an opaque hint (a serial / city / timestamp) the
* app *could* use to pull real content over the authenticated API; v1 just deep-links
* to the stream's screen, so it is carried but not otherwise interpreted.
*/
@Serializable
data class PushTickle(
val stream: String,
val ref: String? = null,
)
/**
* Parse a tickle out of an ntfy SSE `data:` frame. ntfy wraps our published body in
* its own envelope — `{ event, topic, message, … }` — where `message` is the exact
* string we POSTed (our `{ stream, ref }` JSON). Only `event == "message"` frames
* carry a payload; `open` / `keepalive` frames return null, as does any malformed or
* unrecognized body (dropped, never thrown — §7). Pure + `internal` for unit testing.
*/
internal fun parseNtfyTickle(json: Json, data: String): PushTickle? {
val trimmed = data.trim()
if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
return try {
val envelope = json.parseToJsonElement(trimmed) as? JsonObject ?: return null
val event = envelope["event"]?.jsonPrimitive?.content
// ntfy lifecycle frames ("open", "keepalive", "poll_request") carry no message.
if (event != null && event != "message") return null
val messageEl = envelope["message"] ?: return null
if (messageEl is JsonNull) return null
val message = messageEl.jsonPrimitive.content
decodeTickle(json, message)
} catch (_: Exception) {
null
}
}
/** Decode our own `{ stream, ref }` body; a blank/missing stream is not a tickle. */
internal fun decodeTickle(json: Json, body: String): PushTickle? = try {
val tickle = json.decodeFromString(PushTickle.serializer(), body.trim())
tickle.takeIf { it.stream.isNotBlank() }
} catch (_: Exception) {
null
}

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.core.result package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException 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]. * Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
* Coroutine cancellation is rethrown so structured concurrency still works — it * Coroutine cancellation is rethrown so structured concurrency still works — it
* is control flow, not a network failure. * 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 { suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Ok(block()) ApiResult.Ok(block())
@@ -57,9 +46,4 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.HttpError(e.code(), e.message()) ApiResult.HttpError(e.code(), e.message())
} catch (e: IOException) { } catch (e: IOException) {
ApiResult.NetworkError(e) 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

@@ -26,8 +26,12 @@ class WebsiteUrls @Inject constructor(
/** Forgot / reset password (the flow built on the backend before app work, §8). */ /** Forgot / reset password (the flow built on the backend before app work, §8). */
fun forgotPassword(): String? = resolve(FORGOT) fun forgotPassword(): String? = resolve(FORGOT)
/** The website login page — carries the SSO provider buttons (§4.2). */
fun login(): String? = resolve(LOGIN)
private companion object { private companion object {
const val REGISTER = "account/register" const val REGISTER = "account/register"
const val FORGOT = "account/forgot" 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.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
@@ -28,15 +27,9 @@ import retrofit2.http.POST
interface AuthApi { interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER. // 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") @Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login") @POST("api/v1/auth/mobile/login")
suspend fun login( suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
@Body body: MobileLoginRequest,
@Header("X-Trust-Token") trustToken: String? = null,
): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout") @POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit> suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>

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.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto 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.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto 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 com.runicgateway.app.data.api.dto.UsernameResponse
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.PATCH import retrofit2.http.PATCH
@@ -62,28 +52,4 @@ interface MeApi {
// path template explicit alongside the provider argument. // path template explicit alongside the provider argument.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}") @HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit 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

@@ -1,43 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
/**
* 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.
*/
interface NotificationsApi {
@POST("api/v1/auth/me/devices")
suspend fun registerDevice(@Body body: RegisterDeviceRequest): PushDeviceDto
@GET("api/v1/auth/me/devices")
suspend fun listDevices(): List<PushDeviceDto>
@DELETE("api/v1/auth/me/devices/{id}")
suspend fun deleteDevice(@Path("id") id: Long): Unit
@GET("api/v1/auth/me/notifications/streams")
suspend fun streams(): NotificationStreamsDto
@GET("api/v1/auth/me/notifications/subscriptions")
suspend fun subscriptions(): NotificationSubscriptionsDto
@PUT("api/v1/auth/me/notifications/subscriptions")
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
}

View File

@@ -1,40 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
/**
* The native SSO bridge surface (PLAN.md §4.2, M9). Discovery lists the shard's
* enabled providers; exchange trades a callback authorization code (+ its PKCE
* verifier) for the same bearer pair as `/auth/mobile/login`.
*
* The redirect leg (`/auth/mobile/sso/start`) is **not** here — it is opened in a
* Custom Tab as a URL (the browser follows the 302 through the IdP), not called as
* an XHR. See [com.runicgateway.app.core.auth.sso.SsoAuthManager].
*
* Exchange is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER]: it
* carries no bearer (the user isn't signed in yet) and a `401` (bad/expired code or
* PKCE mismatch) must never be misread as an expired session or trip the refresh
* [com.runicgateway.app.core.net.TokenAuthenticator]. It returns a raw [Response]
* so the caller can distinguish `401` from other failures.
*/
interface SsoApi {
/** Public discovery — the enabled providers to render login buttons for. */
@GET("api/v1/auth/providers")
suspend fun providers(): List<SsoProviderDto>
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/sso/exchange")
suspend fun exchange(@Body body: MobileSsoExchangeRequest): Response<MobileTokenResponse>
}

View File

@@ -55,78 +55,9 @@ data class TotpSetupDto(
@Serializable @Serializable
data class TotpCodeRequest(val code: String) data class TotpCodeRequest(val code: String)
/** /** Result of enabling/disabling 2FA. */
* 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.
*/
@Serializable @Serializable
data class TotpStateDto( data class TotpStateDto(val totp_enabled: Boolean = false)
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())
/** A linked external identity (`GET /auth/me/account/identities`). */ /** A linked external identity (`GET /auth/me/account/identities`). */
@Serializable @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). * safe (§8, recorded for M1).
*/ */
/** /** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
* `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).
*/
@Serializable @Serializable
data class MobileLoginRequest( data class MobileLoginRequest(
val username: String, val username: String,
val password: String, val password: String,
val code: String? = null, val code: String? = null,
val recoveryCode: String? = null,
val trustDevice: Boolean? = null,
val device_name: String? = null,
) )
/** `POST /auth/mobile/refresh` body. */ /** `POST /auth/mobile/refresh` body. */
@@ -45,11 +34,6 @@ data class MobileLogoutRequest(
/** /**
* Success payload from login and refresh: the token pair, the access lifetime * 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. * (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 @Serializable
data class MobileTokenResponse( data class MobileTokenResponse(
@@ -57,9 +41,6 @@ data class MobileTokenResponse(
val refreshToken: String, val refreshToken: String,
val expiresIn: String? = null, val expiresIn: String? = null,
val user: SafeUserDto, 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). */ /** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */

View File

@@ -1,74 +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 opt-in push surface under `/auth/me` (PLAN.md §11, M7
* Part 2). Field names match the backend's `notifications.controller` /
* `pushDevices.model` exactly; every DTO ignores unknown keys (NetworkModule's
* lenient Json), so additive backend fields are safe (recorded for M1).
*/
/**
* `POST /auth/me/devices` body. [endpoint] is the ntfy topic URL the app's
* embedded distributor owns (`https://<ntfy-host>/<topic>`); the backend
* SSRF-validates it is HTTPS on the shard's allow-set before storing. [transport]
* is `unifiedpush` for the direct-ntfy relay (fcm reserved for a future flavor).
*/
@Serializable
data class RegisterDeviceRequest(
val endpoint: String,
val transport: String = "unifiedpush",
val platform: String? = null,
)
/** `POST/GET /auth/me/devices` — one registered device (endpoint) for this user. */
@Serializable
data class PushDeviceDto(
val id: Long = 0,
val transport: String = "",
val endpoint: String = "",
val platform: String? = null,
val createdAt: String? = null,
val lastSeenAt: String? = null,
)
/**
* One subscribable stream from `GET /auth/me/notifications/streams`. A [personal]
* stream is delivered only to the owning user and [requiresLinkedAccount] — the app
* greys its toggle until a game account is linked (§11).
*/
@Serializable
data class NotificationStreamDto(
val id: String = "",
val label: String = "",
val description: String = "",
val personal: Boolean = false,
val requiresLinkedAccount: Boolean = false,
)
/** `GET /auth/me/notifications/streams` — the catalog. */
@Serializable
data class NotificationStreamsDto(
val streams: List<NotificationStreamDto> = emptyList(),
)
/**
* `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>,
)

View File

@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5). * `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
* Presentation is text-only for v1 (no item icons / paperdoll). * 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 * In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers. * the public boards — these are separate endpoints with separate shapes.
*/ */
// ── Game-account linking ───────────────────────────────────────────────────── // ── Game-account linking ─────────────────────────────────────────────────────

View File

@@ -55,17 +55,6 @@ data class RegistrationFlagsDto(
val sso: Boolean = false, val sso: Boolean = false,
) )
/**
* Push-notification relay config (M7). [ntfyUrl] is the client-facing ntfy base
* URL the app's embedded distributor registers its device topic against; null (or
* absent, on an older backend) means push isn't configured for this shard and the
* Notifications screen shows it as unavailable.
*/
@Serializable
data class PushConfigDto(
val ntfyUrl: String? = null,
)
/** /**
* `GET /public/settings` — whitelisted settings + branding. Only the keys the * `GET /public/settings` — whitelisted settings + branding. Only the keys the
* app consumes are modeled; other whitelisted keys are ignored. * app consumes are modeled; other whitelisted keys are ignored.
@@ -78,6 +67,4 @@ data class SettingsDto(
val registration: RegistrationFlagsDto = RegistrationFlagsDto(), val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
val gameAccountSignup: Boolean = false, val gameAccountSignup: Boolean = false,
val brand: BrandDto = BrandDto(), val brand: BrandDto = BrandDto(),
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
val push: PushConfigDto = PushConfigDto(),
) )

View File

@@ -15,18 +15,13 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs. * `*.update` frames on `/public/shard/stream` decode into these same DTOs.
*/ */
/** /** A game actor (player/leader/governor) as embedded in board payloads. */
* 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, and [webId] is the linked site-user id as a
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
*/
@Serializable @Serializable
data class ActorDto( data class ActorDto(
val serial: String? = null, val serial: Long? = null,
val name: String? = null, val name: String? = null,
val acct: String? = null, val acct: String? = null,
val webId: String? = null, val webId: Long? = null,
) { ) {
/** Best display label for this actor. */ /** Best display label for this actor. */
val label: String get() = name ?: acct ?: "Someone" val label: String get() = name ?: acct ?: "Someone"
@@ -78,7 +73,7 @@ data class FeedEventDto(
*/ */
@Serializable @Serializable
data class OnlineStaffDto( data class OnlineStaffDto(
val serial: String? = null, val serial: Long? = null,
val name: String? = null, val name: String? = null,
val map: String? = null, val map: String? = null,
val x: Int? = null, val x: Int? = null,
@@ -92,7 +87,7 @@ data class OnlineStaffDto(
*/ */
@Serializable @Serializable
data class HouseDto( data class HouseDto(
val serial: String = "", val serial: Long = 0,
val name: String? = null, val name: String? = null,
val region: String? = null, val region: String? = null,
val map: String? = null, val map: String? = null,
@@ -109,7 +104,7 @@ data class HouseDto(
*/ */
@Serializable @Serializable
data class ChampDto( data class ChampDto(
val serial: String = "", val serial: Long = 0,
val category: String? = null, val category: String? = null,
val type: String? = null, val type: String? = null,
val name: String? = null, val name: String? = null,

View File

@@ -1,42 +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
/**
* Wire shapes for the Mobile SSO Authorization Bridge (PLAN.md §4.2, M9). The
* success payload of `/auth/mobile/sso/exchange` is the shared [MobileTokenResponse]
* (same pair as `/auth/mobile/login`) — this file only adds the two shapes unique
* to the bridge. Every DTO ignores unknown keys (NetworkModule's lenient Json), so
* additive backend fields stay safe (§8).
*/
/**
* One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is
* the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a
* button per provider from this list rather than hardcoding a set. [loginUrl] is
* the *website* start path (unused by the app, which builds its own
* `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly.
*/
@Serializable
data class SsoProviderDto(
val id: String,
val name: String,
val icon: String? = null,
val loginUrl: String? = null,
val priority: Int? = null,
)
/**
* `POST /auth/mobile/sso/exchange` body — the one-time authorization code from the
* callback deep link plus the PKCE verifier stashed at `/start` (Layer B). Wire
* name is snake_case to match the backend's `{ code, code_verifier }`.
*/
@Serializable
data class MobileSsoExchangeRequest(
val code: String,
@SerialName("code_verifier") val codeVerifier: String,
)

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.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto 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.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto 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 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -35,7 +26,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class AccountRepository @Inject constructor( class AccountRepository @Inject constructor(
private val api: MeApi, private val api: MeApi,
private val json: Json,
) { ) {
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() } suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
@@ -58,63 +48,4 @@ class AccountRepository @Inject constructor(
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> = suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) } 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,20 +3,13 @@
*/ */
package com.runicgateway.app.data.repository 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.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileLoginRequest import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse 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.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
@@ -32,61 +25,13 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthRepository @Inject constructor( class AuthRepository @Inject constructor(
private val authApi: AuthApi, private val authApi: AuthApi,
private val ssoApi: SsoApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json, 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.
*/
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
}
/** Outcome of a login attempt (§4.1). */ /** Outcome of a login attempt (§4.1). */
sealed interface LoginResult { sealed interface LoginResult {
/** data object Success : 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
/** The account has 2FA on — reveal the code field and resubmit with a code. */ /** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult data object TotpRequired : LoginResult
@@ -102,32 +47,9 @@ class AuthRepository @Inject constructor(
data object NetworkError : LoginResult data object NetworkError : LoginResult
} }
/** suspend fun login(username: String, password: String, code: String? = null): 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)
val response: Response<MobileTokenResponse> = try { val response: Response<MobileTokenResponse> = try {
authApi.login( authApi.login(MobileLoginRequest(username = username, password = password, code = code))
MobileLoginRequest(
username = username,
password = password,
code = code,
recoveryCode = recoveryCode,
trustDevice = trustDevice.takeIf { it },
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
),
trustToken = storedTrustToken,
)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: IOException) { } catch (_: IOException) {
@@ -136,14 +58,8 @@ class AuthRepository @Inject constructor(
if (response.isSuccessful) { if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError 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) sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success( return LoginResult.Success
trustLimitReached = body.trustLimitReached,
devices = body.devices,
)
} }
return when (response.code()) { return when (response.code()) {
@@ -153,36 +69,12 @@ 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 * 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 * tokens (§4.3). Best-effort: the local session is torn down even if the
* network call fails, so the user is always signed out locally. * network call fails, so the user is always signed out locally.
*/ */
suspend fun logout(allDevices: Boolean = false) { suspend fun logout(allDevices: Boolean = false) {
// Deregister this device's push endpoint while the bearer is still valid, so
// no orphan device row is left behind (§11). Keeps the opt-in intent so push
// resumes on the next sign-in; best-effort, never blocks the logout.
try {
pushManager.deregisterDevice()
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore — local session teardown proceeds regardless.
}
val refreshToken = sessionManager.currentRefreshToken() val refreshToken = sessionManager.currentRefreshToken()
try { try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices)) authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
@@ -220,8 +112,4 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
private companion object {
const val DISCOVERY_RETRY_DELAY_MS = 400L
}
} }

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.data.repository package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences import com.runicgateway.app.core.prefs.ServerPreferences
@@ -27,8 +26,6 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences, private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig, private val config: com.runicgateway.app.core.AppConfig,
) { ) {
@@ -99,18 +96,7 @@ class ConnectionRepository @Inject constructor(
* signed-out state against the new host. * signed-out state against the new host.
*/ */
suspend fun disconnect() { suspend fun disconnect() {
// Deregister the push endpoint on the current (old) host while still authed,
// then clear the shard's ntfy URL — the new host advertises its own (§11).
try {
pushManager.deregisterDevice()
} catch (_: Exception) {
// Best-effort; the reset proceeds regardless.
}
pushManager.setNtfyUrl(null)
sessionManager.onSignedOut() 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()
prefs.clear() prefs.clear()
baseUrlHolder.set(null) baseUrlHolder.set(null)
} }

View File

@@ -1,40 +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.NotificationsApi
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
import com.runicgateway.app.data.api.dto.PushDeviceDto
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Device registration + per-user stream subscriptions over the opt-in push surface
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
* 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).
*/
@Singleton
class NotificationsRepository @Inject constructor(
private val api: NotificationsApi,
) {
suspend fun registerDevice(endpoint: String, platform: String?): ApiResult<PushDeviceDto> =
safeApiCall { api.registerDevice(RegisterDeviceRequest(endpoint = endpoint, platform = platform)) }
suspend fun listDevices(): ApiResult<List<PushDeviceDto>> = safeApiCall { api.listDevices() }
suspend fun deleteDevice(id: Long): ApiResult<Unit> = safeApiCall { api.deleteDevice(id) }
suspend fun streams(): ApiResult<NotificationStreamsDto> = safeApiCall { api.streams() }
suspend fun subscriptions(): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.subscriptions() }
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
}

View File

@@ -3,7 +3,7 @@
*/ */
package com.runicgateway.app.data.repository 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.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.core.result.safeApiCall
@@ -35,7 +35,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class ShardRepository @Inject constructor( class ShardRepository @Inject constructor(
private val api: PublicApi, private val api: PublicApi,
private val stream: ShardStream, private val stream: ShardStreamClient,
private val json: Json, private val json: Json,
) { ) {
// ── Snapshots ──────────────────────────────────────────────────────── // ── Snapshots ────────────────────────────────────────────────────────

View File

@@ -9,18 +9,13 @@ import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor 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.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.MeApi 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.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.SsoApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -96,21 +91,10 @@ object NetworkModule {
@Singleton @Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java) 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 @Provides
@Singleton @Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
/** Native SSO discovery + code exchange (§4.2, M9) — on the main client. */
@Provides
@Singleton
fun provideSsoApi(retrofit: Retrofit): SsoApi = retrofit.create(SsoApi::class.java)
/** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */ /** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */
@Provides @Provides
@Singleton @Singleton
@@ -122,17 +106,6 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi = fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
retrofit.create(PlayerShardApi::class.java) retrofit.create(PlayerShardApi::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, * Token refresh runs on its own **bare** client — UA + host retargeting only,
* no auth interceptor and no authenticator — so a refresh can never recurse * no auth interceptor and no authenticator — so a refresh can never recurse

View File

@@ -3,21 +3,15 @@
*/ */
package com.runicgateway.app.di 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.EncryptedTokenStore
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
import com.runicgateway.app.core.auth.TokenStore 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 dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */ /** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class StorageModule { abstract class StorageModule {
@@ -25,17 +19,4 @@ abstract class StorageModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore 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
} }

View File

@@ -6,7 +6,6 @@ package com.runicgateway.app.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder 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.core.result.ApiResult
import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.repository.ConnectionRepository import com.runicgateway.app.data.repository.ConnectionRepository
@@ -28,7 +27,6 @@ class AppViewModel @Inject constructor(
private val connectionRepository: ConnectionRepository, private val connectionRepository: ConnectionRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val pushManager: PushManager,
) : ViewModel() { ) : ViewModel() {
sealed interface AppState { sealed interface AppState {
@@ -68,16 +66,8 @@ class AppViewModel @Inject constructor(
} }
} }
/** private suspend fun loadBrand(): BrandDto? =
* Load public settings for branding and feed the shard's push relay URL into the (settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
* [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 loadBrand(): BrandDto? {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return settings?.brand
}
/** /**
* Resolve a possibly site-relative asset path (branding logos, post images) * Resolve a possibly site-relative asset path (branding logos, post images)

View File

@@ -3,12 +3,9 @@
*/ */
package com.runicgateway.app.ui package com.runicgateway.app.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
@@ -51,8 +48,6 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen 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.auth.roleLabelRes
import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.home.HomeScreen
@@ -61,11 +56,6 @@ import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen 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.NotificationsScreen
import com.runicgateway.app.ui.page.PageScreen import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen import com.runicgateway.app.ui.player.CharacterSheetScreen
import com.runicgateway.app.ui.player.CharactersScreen import com.runicgateway.app.ui.player.CharactersScreen
@@ -85,9 +75,7 @@ import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */ /** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf( private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
Routes.NOTIFICATIONS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
) )
/** /**
@@ -103,8 +91,6 @@ fun RunicApp(
brand: BrandDto?, brand: BrandDto?,
onChangeServer: () -> Unit, onChangeServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
deepLinkStream: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel(),
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
@@ -119,16 +105,6 @@ fun RunicApp(
onPauseOrDispose { } onPauseOrDispose { }
} }
// A tapped push notification deep-links to its stream's screen (§11, item 7).
LaunchedEffect(deepLinkStream) {
val stream = deepLinkStream ?: return@LaunchedEffect
navController.navigate(Routes.forStream(stream)) {
popUpTo(Routes.HOME) { saveState = true }
launchSingleTop = true
}
onDeepLinkConsumed()
}
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
@@ -144,11 +120,6 @@ fun RunicApp(
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedTextColor = MaterialTheme.colorScheme.onSurface, 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)) Spacer(Modifier.height(12.dp))
Text( Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
@@ -205,7 +176,6 @@ fun RunicApp(
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
} }
}
}, },
) { ) {
Scaffold( Scaffold(
@@ -322,16 +292,8 @@ private fun RunicNavHost(
ContactScreen() ContactScreen()
} }
composable(Routes.LOGIN) { 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) { composable(Routes.ACCOUNT) {
// Only meaningful while signed in; a sign-out (here or from the drawer) // Only meaningful while signed in; a sign-out (here or from the drawer)
// sends the user home rather than leaving a stale identity on screen. // sends the user home rather than leaving a stale identity on screen.
@@ -341,35 +303,12 @@ private fun RunicNavHost(
roleLabel = stringResource(roleLabelRes(s.user.role)), roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut, onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere, onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
) )
Session.SignedOut -> LaunchedEffect(Unit) { Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME) 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 stale settings up. The backend gates every call regardless (§5).
when (session) {
is Session.SignedIn -> NotificationsScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
// ── Player game data (§6.3) — reached from the player-only menu groups. // ── Player game data (§6.3) — reached from the player-only menu groups.
// The server enforces the player gate on every call; these screens simply // The server enforces the player gate on every call; these screens simply
@@ -391,24 +330,6 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) { composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() } 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() }
}
} }
} }
@@ -430,23 +351,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. */ /** Navigate to a top-level menu destination: single instance, reset to it. */
private fun NavHostController.navigateTopLevel(route: String) { private fun NavHostController.navigateTopLevel(route: String) {
navigate(route) { navigate(route) {

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.Card
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.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 ->
Card(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 ->
Card(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.Card
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
/**
* 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,
) {
Card(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

@@ -65,8 +65,6 @@ fun AccountScreen(
roleLabel: String, roleLabel: String,
onSignOut: () -> Unit, onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit, onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
@@ -80,15 +78,10 @@ fun AccountScreen(
) { ) {
IdentityCard(username = username, roleLabel = roleLabel) 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) { when (val account = state.account) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp)) 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.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)) HorizontalDivider(Modifier.padding(vertical = 20.dp))
@@ -124,34 +117,13 @@ private fun AccountSections(
account: PlayerAccountDto, account: PlayerAccountDto,
state: AccountViewModel.State, state: AccountViewModel.State,
viewModel: AccountViewModel, viewModel: AccountViewModel,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
) { ) {
UsernameSection(account, state, viewModel) UsernameSection(account, state, viewModel)
PasswordSection(account, state, viewModel) PasswordSection(account, state, viewModel)
TwoFactorSection(account, state, viewModel) TwoFactorSection(account, state, viewModel)
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
IdentitiesSection(state, viewModel) 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 @Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {

View File

@@ -49,8 +49,6 @@ class AccountViewModel @Inject constructor(
val busy: Boolean = false, val busy: Boolean = false,
/** The pending TOTP enrollment (QR shown) between setup and enable. */ /** The pending TOTP enrollment (QR shown) between setup and enable. */
val totpSetup: TotpSetupDto? = null, 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, val feedback: Feedback? = null,
) )
@@ -124,12 +122,9 @@ class AccountViewModel @Inject constructor(
if (_state.value.busy) return if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch { viewModelScope.launch {
when (val result = accountRepository.totpEnable(code.trim())) { when (accountRepository.totpEnable(code.trim())) {
is ApiResult.Ok -> { is ApiResult.Ok -> {
// 2FA enable returns the fresh recovery-code batch once — surface it. _state.update { it.copy(totpSetup = null) }
_state.update {
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
}
finish(Section.TOTP, true, R.string.account_totp_enabled) finish(Section.TOTP, true, R.string.account_totp_enabled)
reloadAccount() 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) { fun disableTotp(code: String) {
if (_state.value.busy) return if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _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.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -17,22 +14,14 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -67,13 +56,6 @@ fun LoginScreen(
if (state.signedIn) onSignedIn() if (state.signedIn) onSignedIn()
} }
// Open a freshly-minted SSO /start URL in a Custom Tab, exactly once (§4.2).
LaunchedEffect(state.ssoLaunchUrl) {
val url = state.ssoLaunchUrl ?: return@LaunchedEffect
WebHandoff.open(context, url)
viewModel.onSsoLaunchConsumed()
}
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -125,24 +107,6 @@ fun LoginScreen(
) )
if (state.totpRequired) { 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( OutlinedTextField(
value = state.code, value = state.code,
onValueChange = viewModel::onCodeChange, onValueChange = viewModel::onCodeChange,
@@ -161,39 +125,6 @@ fun LoginScreen(
) )
} }
// Toggle between authenticator code and a single-use recovery code.
TextButton(
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
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,
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,
)
}
}
state.error?.let { err -> state.error?.let { err ->
Text( Text(
text = stringResource(loginErrorRes(err)), text = stringResource(loginErrorRes(err)),
@@ -224,48 +155,6 @@ 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() -> {
OutlinedButton(
onClick = {
val providers = state.ssoProviders
if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
else showSsoPicker = true
},
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
) {
Text(stringResource(R.string.login_sso_button))
}
}
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: 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 ── // ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
viewModel.registerUrl?.let { url -> viewModel.registerUrl?.let { url ->
TextButton( TextButton(
@@ -278,53 +167,11 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot)) Text(stringResource(R.string.login_forgot))
} }
} }
viewModel.ssoLoginUrl?.let { url ->
if (showSsoPicker) { TextButton(onClick = { WebHandoff.open(context, url) }) {
SsoProviderPicker( Text(stringResource(R.string.login_sso))
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
} }
} }
@@ -334,5 +181,4 @@ private fun loginErrorRes(error: LoginError): Int = when (error) {
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
LoginError.SERVER -> R.string.login_error_server LoginError.SERVER -> R.string.login_error_server
LoginError.NETWORK -> R.string.login_error_network LoginError.NETWORK -> R.string.login_error_network
LoginError.SSO -> R.string.login_error_sso
} }

View File

@@ -5,12 +5,9 @@ package com.runicgateway.app.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.web.WebsiteUrls 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
import com.runicgateway.app.data.repository.AuthRepository.LoginResult import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -28,180 +25,62 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val ssoAuthManager: SsoAuthManager,
private val websiteUrls: WebsiteUrls, private val websiteUrls: WebsiteUrls,
) : ViewModel() { ) : ViewModel() {
/** The transient error surfaced under the form after a failed attempt. */ /** The transient error surfaced under the form after a failed attempt. */
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK, SSO } enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK }
data class UiState( data class UiState(
val username: String = "", val username: String = "",
val password: String = "", val password: String = "",
val code: 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. */ /** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false, 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 submitting: Boolean = false,
val error: LoginError? = null, val error: LoginError? = null,
val signedIn: Boolean = false, val signedIn: Boolean = false,
/** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
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,
) )
private val _state = MutableStateFlow(UiState()) private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow() val state: StateFlow<UiState> = _state.asStateFlow()
init {
discoverSsoProviders()
// 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 {
ssoAuthManager.outcome.collect { outcome ->
when (outcome) {
SsoAuthManager.Outcome.Success -> {
ssoAuthManager.consumeOutcome()
_state.update { it.copy(submitting = false, signedIn = true) }
}
is SsoAuthManager.Outcome.Failed -> {
ssoAuthManager.consumeOutcome()
_state.update { it.copy(submitting = false, error = mapSsoError(outcome.reason)) }
}
SsoAuthManager.Outcome.Idle -> Unit
}
}
}
}
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) } fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) } fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onCodeChange(value: String) = fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) } _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 registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
val ssoLoginUrl: String? get() = websiteUrls.login()
/**
* 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)
}
}
}
}
/**
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
* `/start` URL for the screen to open in a Custom Tab. No-op (leaves a SERVER
* error) if the base URL isn't set yet — the website fallback still shows.
*/
fun onSsoProviderClick(provider: SsoProviderDto) {
if (_state.value.submitting) return
val url = ssoAuthManager.buildStartUrl(provider.id)
if (url == null) {
_state.update { it.copy(error = LoginError.SSO) }
return
}
_state.update { it.copy(error = null, ssoLaunchUrl = url) }
}
/** The screen has opened the Custom Tab; clear so it isn't re-launched on recompose. */
fun onSsoLaunchConsumed() = _state.update { it.copy(ssoLaunchUrl = null) }
private fun mapSsoError(reason: SsoAuthManager.Failure): LoginError = when (reason) {
SsoAuthManager.Failure.NETWORK -> LoginError.NETWORK
else -> LoginError.SSO
}
fun submit() { fun submit() {
val s = _state.value val s = _state.value
if (s.submitting) return if (s.submitting) return
val validationError = validateForSubmit(s) if (s.username.isBlank() || s.password.isBlank()) {
if (validationError != null) { _state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
_state.update { it.copy(error = validationError) } 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 return
} }
_state.update { it.copy(submitting = true, error = null) } _state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
// Only one second factor is sent; the recovery toggle picks which. val code = s.code.trim().takeIf { it.isNotBlank() }
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode } when (authRepository.login(s.username.trim(), s.password, code)) {
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode } LoginResult.Success ->
val result = authRepository.login(
username = s.username.trim(),
password = s.password,
code = code,
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
applyLoginResult(result)
}
}
/** 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
}
/** 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) } _state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired -> LoginResult.TotpRequired ->
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE. // Reveal the code field; a wrong code re-lands here as BAD_CODE.
_state.update { _state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy( it.copy(
submitting = false, submitting = false,
totpRequired = true, totpRequired = true,
error = if (hadFactor) LoginError.BAD_CODE else null, error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
) )
} }
@@ -217,4 +96,6 @@ class LoginViewModel @Inject constructor(
LoginResult.NetworkError -> LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) } _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.Card
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
/**
* 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") }
Card(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.Card
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
/**
* 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) {
Card(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

@@ -21,19 +21,8 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */ /** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN, SIGNED_IN,
/** /** Visible only to a player — the linked game-data groups (§6.3). */
* 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.
*/
PLAYER, 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( data class MenuEntry(
@@ -55,15 +44,9 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.page("about"), R.string.menu_about), MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact), MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN), MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER), 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_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
// 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),
) )
/** /**
@@ -75,8 +58,6 @@ fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry>
when (entry.access) { when (entry.access) {
MenuAccess.PUBLIC -> true MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff) MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
} }
} }

View File

@@ -18,13 +18,6 @@ object Routes {
const val LOGIN = "login" const val LOGIN = "login"
const val ACCOUNT = "account" 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"
/** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications"
/** Public shard hub (§6.2). */ /** Public shard hub (§6.2). */
const val SHARD = "shard" const val SHARD = "shard"
@@ -42,13 +35,6 @@ object Routes {
/** A single character sheet by in-game (hex) serial. */ /** A single character sheet by in-game (hex) serial. */
const val PLAYER_CHAR = "player/char/{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). */ /** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
const val PAGE = "page/{slug}" const val PAGE = "page/{slug}"
@@ -71,23 +57,4 @@ object Routes {
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */ /** The character-sheet route for an in-game serial (e.g. "0x24C"). */
fun playerChar(serial: String) = "player/char/$serial" fun playerChar(serial: String) = "player/char/$serial"
/**
* 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;
* unknown streams land on Home. Personal streams route to the player groups
* (a signed-out/demoted tap is caught by [com.runicgateway.app.ui.PlayerGate]).
*/
fun forStream(streamId: String): String = when (streamId) {
com.runicgateway.app.core.push.PushStreams.NEWS_POST -> NEWS
com.runicgateway.app.core.push.PushStreams.SERVER_STATUS,
com.runicgateway.app.core.push.PushStreams.CHAMP_START,
com.runicgateway.app.core.push.PushStreams.IDOC_WARNING,
com.runicgateway.app.core.push.PushStreams.GOVERNOR_ELECTION,
-> SHARD
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
else -> HOME
}
} }

View File

@@ -1,182 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.NotificationStreamDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
/**
* The Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6): the
* subscribable catalog with per-stream toggles. Personal streams are greyed until a
* game account is linked; turning a stream on requests the POST_NOTIFICATIONS
* permission (API 33+) and registers the device, turning them all off unregisters it.
*/
@Composable
fun NotificationsScreen(
modifier: Modifier = Modifier,
viewModel: NotificationsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
// Ask once for POST_NOTIFICATIONS when the user first enables a stream (API 33+).
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* granted or not, the subscription is already saved server-side */ }
fun ensureNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
text = stringResource(R.string.notifications_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.notifications_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
if (!state.supported) {
EmptyView(message = stringResource(R.string.notifications_unsupported))
return@Column
}
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
style = MaterialTheme.typography.bodyMedium,
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 12.dp),
)
}
when (val catalog = state.catalog) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(kind = catalog.kind, onRetry = viewModel::load)
is UiState.Success -> StreamList(
streams = catalog.data,
subscribed = state.subscribed,
hasLinkedAccount = state.hasLinkedAccount,
busy = state.busy,
onToggle = { stream, on ->
if (on) ensureNotificationPermission()
viewModel.setSubscribed(stream, on)
},
)
}
}
}
@Composable
private fun StreamList(
streams: List<NotificationStreamDto>,
subscribed: Set<String>,
hasLinkedAccount: Boolean,
busy: Boolean,
onToggle: (NotificationStreamDto, Boolean) -> Unit,
) {
if (streams.isEmpty()) {
EmptyView(message = stringResource(R.string.notifications_empty))
return
}
val (personal, general) = streams.partition { it.personal }
if (general.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_general))
Spacer(Modifier.height(8.dp))
general.forEach { stream ->
StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
onToggle(stream, on)
}
HorizontalDivider()
}
Spacer(Modifier.height(20.dp))
}
if (personal.isNotEmpty()) {
SectionLabel(stringResource(R.string.notifications_section_personal))
Spacer(Modifier.height(8.dp))
personal.forEach { stream ->
val selectable = streamSelectable(stream, hasLinkedAccount)
val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
onToggle(stream, on)
}
HorizontalDivider()
}
}
}
@Composable
private fun StreamRow(
stream: NotificationStreamDto,
checked: Boolean,
enabled: Boolean,
hint: String?,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = stream.label,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = hint ?: stream.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
)
}
Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
}
}

View File

@@ -1,135 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.notifications
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.NotificationStreamDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6):
* the stream catalog with per-stream toggles bound to
* `GET/PUT /auth/me/notifications/subscriptions`. A **personal** stream is greyed
* until the user has a linked game account (§11), and turning the opt-in set
* non-empty/empty drives the [PushManager] to register/unregister the device.
*/
@HiltViewModel
class NotificationsViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val playerShard: PlayerShardRepository,
private val pushManager: PushManager,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val catalog: UiState<List<NotificationStreamDto>> = UiState.Loading,
val subscribed: Set<String> = emptySet(),
/** Whether the user has ≥1 linked game account — personal streams need it. */
val hasLinkedAccount: Boolean = false,
/** Whether this shard advertises a push relay at all (else the screen says so). */
val supported: Boolean = true,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
viewModelScope.launch {
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
}
load()
}
fun load() {
_state.update { it.copy(catalog = UiState.Loading) }
viewModelScope.launch {
val catalog = notifications.streams().let { result ->
when (result) {
is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
is ApiResult.HttpError -> result
is ApiResult.NetworkError -> result
}
}
_state.update { it.copy(catalog = catalog.toUiState()) }
when (val subs = notifications.subscriptions()) {
is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
else -> Unit
}
// A linked game account gates the personal streams; failure → treat as none.
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
_state.update { it.copy(hasLinkedAccount = linked) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/** Toggle [stream]; refuses a personal stream with no linked account. */
fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
val s = _state.value
if (s.busy) return
if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val result = notifications.setSubscriptions(next.toList())) {
is ApiResult.Ok -> {
val stored = result.data.streams.toSet()
_state.update { it.copy(subscribed = stored) }
reconcilePush(stored)
}
is ApiResult.NetworkError -> finish(false, R.string.error_network)
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
}
}
}
/**
* Register or unregister the device to match the opted-in set (PLAN.md §11:
* register when signed-in + subscribed, unregister when the set empties).
*/
private suspend fun reconcilePush(subscribed: Set<String>) {
if (subscribed.isEmpty()) {
pushManager.disable()
finish(true, R.string.notifications_all_off)
return
}
when (val res = pushManager.enable()) {
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
is PushManager.PushResult.Failed ->
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
}
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}
/**
* Whether a stream's toggle is selectable for a user: a personal stream needs a
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
*/
fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
!stream.requiresLinkedAccount || hasLinkedAccount

View File

@@ -69,9 +69,7 @@ class ChampsViewModel @Inject constructor(
private fun applyFrame(frame: ShardStreamEvent.Frame) { private fun applyFrame(frame: ShardStreamEvent.Frame) {
when (frame.kind) { when (frame.kind) {
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) } "champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
// Serial is an opaque hex-string key ("0x…"), not a number — read as a "champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) }
// string (reading it as a Long silently dropped every champ.remove).
"champ.remove" -> FrameFields.stringField(frame.data, "serial")?.let { board.remove(it) }
else -> return else -> return
} }
// Only republish when the board actually changed (Success state only). // Only republish when the board actually changed (Success state only).

View File

@@ -69,9 +69,7 @@ class HousesViewModel @Inject constructor(
private fun applyFrame(frame: ShardStreamEvent.Frame) { private fun applyFrame(frame: ShardStreamEvent.Frame) {
if (frame.kind != "house.decay") return if (frame.kind != "house.decay") return
// Serials are opaque hex-string keys ("0x…"), not numbers — read as a string val serial = FrameFields.longField(frame.data, "serial") ?: return
// (reading it as a Long silently dropped every live IDOC update).
val serial = FrameFields.stringField(frame.data, "serial") ?: return
// `to` is the new decay stage; only IDOC belongs on the public board. // `to` is the new decay stage; only IDOC belongs on the public board.
val stage = FrameFields.stringField(frame.data, "to") val stage = FrameFields.stringField(frame.data, "to")
?: FrameFields.stringField(frame.data, "stage") ?: FrameFields.stringField(frame.data, "stage")

View File

@@ -46,92 +46,10 @@
<string name="menu_my_characters">My characters</string> <string name="menu_my_characters">My characters</string>
<string name="menu_my_vendors">My vendors</string> <string name="menu_my_vendors">My vendors</string>
<string name="menu_my_houses">My houses</string> <string name="menu_my_houses">My houses</string>
<string name="menu_admin_dashboard">Dashboard</string>
<string name="menu_admin_content">Content</string>
<string name="menu_admin_moderation">Moderation</string>
<string name="menu_admin_support">Support queue</string>
<string name="menu_sign_in">Sign in</string> <string name="menu_sign_in">Sign in</string>
<string name="menu_sign_out">Sign out</string> <string name="menu_sign_out">Sign out</string>
<string name="menu_change_server">Change server</string> <string name="menu_change_server">Change server</string>
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
<string name="admin_dashboard_site">Site</string>
<string name="admin_dashboard_counts">Counts</string>
<string name="admin_dashboard_recent_activity">Recent activity</string>
<string name="admin_site_live">Live</string>
<string name="admin_site_maintenance">Maintenance</string>
<string name="admin_site_switch_maintenance">Switch to maintenance</string>
<string name="admin_site_switch_live">Switch to live</string>
<string name="admin_site_changed_by">by %1$s</string>
<string name="admin_site_mode_updated">Site mode updated.</string>
<string name="admin_count_users">Users</string>
<string name="admin_count_posts">Posts</string>
<string name="admin_forbidden">You don\'t have permission for that action.</string>
<string name="admin_action_failed">That action couldn\'t be completed. Please try again.</string>
<string name="action_cancel">Cancel</string>
<!-- Staff content (posts + wiki) -->
<string name="admin_content_tab_posts">Posts</string>
<string name="admin_content_tab_wiki">Wiki</string>
<string name="admin_content_new_post">New post</string>
<string name="admin_content_new_category">New category</string>
<string name="admin_content_create">Create</string>
<string name="admin_content_published">Published</string>
<string name="admin_content_draft">Draft</string>
<string name="admin_content_publish">Publish</string>
<string name="admin_content_unpublish">Unpublish</string>
<string name="admin_content_delete">Delete</string>
<string name="admin_content_publish_now">Publish now</string>
<string name="admin_content_field_title">Title</string>
<string name="admin_content_field_excerpt">Excerpt</string>
<string name="admin_content_field_body">Body</string>
<string name="admin_content_field_slug">Slug</string>
<string name="admin_content_field_description">Description</string>
<string name="admin_content_field_sort">Sort order</string>
<!-- %1$s slug, %2$d page count -->
<string name="admin_content_cat_meta">%1$s · %2$d pages</string>
<!-- %1$s comma-separated tag labels -->
<string name="admin_content_tags">Tags: %1$s</string>
<string name="admin_content_post_created">Post created.</string>
<string name="admin_content_post_updated">Post updated.</string>
<string name="admin_content_post_deleted">Post deleted.</string>
<string name="admin_content_cat_created">Category created.</string>
<string name="admin_content_cat_deleted">Category deleted.</string>
<string name="admin_content_title_required">A title is required.</string>
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
<!-- Staff moderation (shard write plane) -->
<string name="admin_mod_account_action">Account action</string>
<string name="admin_mod_account">Account</string>
<string name="admin_mod_serial">Serial (0x…)</string>
<string name="admin_mod_reason">Reason (ban)</string>
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
<string name="admin_mod_kick">Kick</string>
<string name="admin_mod_ban">Ban</string>
<string name="admin_mod_unban">Unban</string>
<string name="admin_mod_broadcast_section">Broadcast</string>
<string name="admin_mod_broadcast_text">Message to everyone online</string>
<string name="admin_mod_broadcast">Broadcast</string>
<string name="admin_mod_kicked">Account kicked.</string>
<string name="admin_mod_banned">Account banned.</string>
<string name="admin_mod_unbanned">Ban cleared.</string>
<string name="admin_mod_broadcasted">Message broadcast.</string>
<string name="admin_mod_target_required">Enter an account or serial.</string>
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
<!-- Staff support queue -->
<string name="admin_support_empty">No open help pages.</string>
<string name="admin_support_reply">Reply</string>
<string name="admin_support_close">Close</string>
<string name="admin_support_send">Send</string>
<string name="admin_support_message">Reply message</string>
<string name="admin_support_close_after">Close the page after replying</string>
<string name="admin_support_responded">Reply sent.</string>
<string name="admin_support_closed">Page closed.</string>
<string name="admin_support_message_required">Enter a reply message.</string>
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── --> <!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
<string name="login_title">Sign in</string> <string name="login_title">Sign in</string>
<string name="login_subtitle">Sign in with your shard account.</string> <string name="login_subtitle">Sign in with your shard account.</string>
@@ -142,19 +60,12 @@
<string name="login_button">Sign in</string> <string name="login_button">Sign in</string>
<string name="login_register">Create an account</string> <string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string> <string name="login_forgot">Forgot your password?</string>
<!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). --> <string name="login_sso">Sign in with Google or Discord (on the website)</string>
<string name="login_sso_button">Sign in with SSO</string>
<string name="login_sso_pick_title">Choose a sign-in provider</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
<string name="login_sso_provider">Sign in with %1$s</string>
<string name="login_sso_loading">Loading sign-in options…</string>
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>
<string name="login_error_credentials">Incorrect username or password.</string> <string name="login_error_credentials">Incorrect username or password.</string>
<string name="login_error_code">That code didn\'t match. Try the current code.</string> <string name="login_error_code">That code didn\'t match. Try the current code.</string>
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string> <string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
<string name="login_error_server">Something went wrong. Please try again.</string> <string name="login_error_server">Something went wrong. Please try again.</string>
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string> <string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
<string name="login_error_sso">Couldn\'t complete that sign-in. Please try again.</string>
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── --> <!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
<string name="account_title">My account</string> <string name="account_title">My account</string>
@@ -201,49 +112,6 @@
<string name="account_identity_unlinked">Account unlinked.</string> <string name="account_identity_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</string> <string name="account_identity_error">Couldn\'t unlink that account.</string>
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
<!-- Login 2FA step -->
<string name="login_recovery_code">Recovery code</string>
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
<string name="login_use_recovery_instead">Use a recovery code instead</string>
<string name="login_use_totp_instead">Use your authenticator code instead</string>
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
<!-- Account: security section -->
<string name="account_security_title">Security</string>
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
<string name="trusted_devices_empty">No trusted devices yet.</string>
<string name="trusted_devices_unknown">Unknown device</string>
<string name="trusted_devices_last_used">Last used %1$s</string>
<string name="trusted_devices_revoke">Revoke</string>
<string name="trusted_devices_trust_this">Trust this device</string>
<string name="trusted_devices_untrust_all">Untrust all devices</string>
<string name="trusted_devices_trusted">This device is now trusted.</string>
<string name="trusted_devices_revoked">Device revoked.</string>
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
<!-- Recovery codes screen -->
<string name="recovery_codes_title">Recovery codes</string>
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
<string name="recovery_codes_remaining">%1$d codes remaining</string>
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
<string name="recovery_codes_regenerate">Generate new codes</string>
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
<string name="recovery_codes_new_title">Your new recovery codes</string>
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
<string name="recovery_codes_copy">Copy</string>
<string name="recovery_codes_share">Share</string>
<string name="recovery_codes_done">Done</string>
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── --> <!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
<string name="player_link_title">Link your game account</string> <string name="player_link_title">Link your game account</string>
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string> <string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
@@ -389,37 +257,4 @@
<string name="houses_empty">No houses are in danger right now.</string> <string name="houses_empty">No houses are in danger right now.</string>
<string name="houses_fallback_name">A house</string> <string name="houses_fallback_name">A house</string>
<string name="houses_idoc_badge">IDOC</string> <string name="houses_idoc_badge">IDOC</string>
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
<string name="menu_notifications">Notifications</string>
<string name="notifications_title">Notifications</string>
<string name="notifications_subtitle">Choose what this shard notifies you about. Nothing is sent unless you turn it on.</string>
<string name="notifications_section_general">General</string>
<string name="notifications_section_personal">Your game account</string>
<string name="notifications_requires_link">Link a game account to enable this.</string>
<string name="notifications_empty">This shard offers no notification streams yet.</string>
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
<string name="notifications_saved">Notification settings saved.</string>
<string name="notifications_all_off">Notifications turned off.</string>
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
<!-- Notification channels + the ongoing foreground-service notification. -->
<string name="push_channel_messages">Shard notifications</string>
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
<string name="push_channel_service">Background connection</string>
<string name="push_channel_service_desc">Keeps the connection open to deliver notifications.</string>
<string name="push_service_title">Notifications active</string>
<string name="push_service_text">Listening for shard notifications.</string>
<!-- Per-stream notification titles (content-free tickle → generic title, §11). -->
<string name="push_stream_news_post">New post</string>
<string name="push_stream_server_status">Shard status changed</string>
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
<string name="push_stream_champ_start">Champion spawn started</string>
<string name="push_stream_governor_election">New governor elected</string>
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
<string name="push_stream_house_idoc">Your house entered IDOC</string>
<string name="push_stream_account_login">Login to your account</string>
<string name="push_stream_generic">New notification</string>
</resources> </resources>

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
The app is purely an HTTPS API client of a shard's website backend, so the base
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
enabled" scanner finding, and stops any merged library manifest from re-enabling
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
platform socket layer — defense in depth.
The debug variant overrides this file (app/src/debug/res/xml/) to re-permit
cleartext to loopback only, for local dev against http://127.0.0.1:3000.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View File

@@ -1,49 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* PKCE Layer B primitives (PLAN.md §4.2). The challenge encoding must match the
* backend byte-for-byte (`base64url(SHA-256(verifier))`, no padding) or `/exchange`
* rejects every code — so it is pinned against the RFC 7636 test vector.
*/
class PkceTest {
// RFC 4648 §5 URL-safe base64 alphabet, no padding.
private val base64UrlNoPad = Regex("^[A-Za-z0-9_-]+$")
@Test fun `challenge matches the RFC 7636 vector`() {
// RFC 7636 Appendix B.
val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challengeOf(verifier))
}
@Test fun `verifier is url-safe base64 without padding`() {
val verifier = Pkce.newVerifier()
assertTrue("verifier charset: $verifier", base64UrlNoPad.matches(verifier))
// 32 random bytes → 43 base64 chars (no padding), inside RFC 7636's 43128.
assertEquals(43, verifier.length)
}
@Test fun `challenge is url-safe base64 without padding`() {
val challenge = Pkce.challengeOf(Pkce.newVerifier())
assertTrue("challenge charset: $challenge", base64UrlNoPad.matches(challenge))
// SHA-256 (32 bytes) → 43 base64 chars, no '=' padding.
assertEquals(43, challenge.length)
}
@Test fun `verifiers and states are unique per call`() {
assertNotEquals(Pkce.newVerifier(), Pkce.newVerifier())
assertNotEquals(Pkce.newState(), Pkce.newState())
}
@Test fun `state is url-safe base64 without padding`() {
assertTrue(base64UrlNoPad.matches(Pkce.newState()))
}
}

View File

@@ -1,251 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.StoredSession
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.SsoApi
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SafeUserDto
import com.runicgateway.app.data.api.dto.SsoProviderDto
import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
/**
* The native SSO bridge orchestration (PLAN.md §4.2, M9): start-URL building, the
* CSRF/state guard, the code→token exchange, and that a success drives the *same*
* [SessionManager] the password login uses. Runs over a fake [SsoApi] + a real
* [SessionManager] on a fake [TokenStore]; no Android framework types are touched.
*/
class SsoAuthManagerTest {
private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
override fun load(): StoredSession? = stored
override fun save(session: StoredSession) { stored = session }
override fun clear() { stored = null }
}
/** In-memory stand-in for the encrypted pending-SSO store (survives across
* manager instances the way the on-disk store survives process death). */
private class FakePendingSsoStore(var pending: PendingSso? = null) : PendingSsoStore {
override fun save(state: String, verifier: String) { pending = PendingSso(state, verifier) }
override fun load(): PendingSso? = pending
override fun clear() { pending = null }
}
/** Records the exchange it was called with and returns a scripted response. */
private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>,
) : SsoApi {
var exchangeCalls = 0
var lastRequest: MobileSsoExchangeRequest? = null
override suspend fun providers(): List<SsoProviderDto> = emptyList()
override suspend fun exchange(body: MobileSsoExchangeRequest): Response<MobileTokenResponse> {
exchangeCalls++
lastRequest = body
return exchangeResult()
}
}
private fun tokenPair() = MobileTokenResponse(
accessToken = "access-A",
refreshToken = "refresh-A",
expiresIn = "15m",
user = SafeUserDto(id = 7, username = "alice", role = "player"),
)
private fun error(code: Int): Response<MobileTokenResponse> =
Response.error(code, "".toResponseBody("application/json".toMediaTypeOrNull()))
private fun managerWith(
api: SsoApi,
session: SessionManager,
base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(),
): SsoAuthManager {
val holder = BaseUrlHolder()
if (base != null) holder.set(base.toHttpUrl())
return SsoAuthManager(api, session, holder, store)
}
/** Build a start URL and pull the generated `state` back out of it. */
private fun startAndState(mgr: SsoAuthManager, provider: String = "google"): String {
val url = mgr.buildStartUrl(SsoProviderDto(id = provider, name = "Google").id)!!
return url.toHttpUrl().queryParameter("state")!!
}
@Test fun `buildStartUrl carries provider, challenge, state and the fixed redirect`() {
val mgr = managerWith(FakeSsoApi { tokenPair().let { Response.success(it) } }, SessionManager(FakeTokenStore()))
val url = mgr.buildStartUrl("google")!!
val http = url.toHttpUrl()
assertTrue(url.startsWith("https://shard.example.com/api/v1/auth/mobile/sso/start"))
assertEquals("google", http.queryParameter("provider"))
assertEquals(SsoAuthManager.REDIRECT_URI, http.queryParameter("redirect_uri"))
assertTrue(!http.queryParameter("code_challenge").isNullOrBlank())
assertTrue(!http.queryParameter("state").isNullOrBlank())
}
@Test fun `buildStartUrl returns null when no base url is set`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
assertNull(mgr.buildStartUrl("google"))
}
@Test fun `successful callback exchanges and signs in`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())
val mgr = managerWith(api, session)
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
assertEquals(1, api.exchangeCalls)
assertEquals("auth-code-1", api.lastRequest?.code)
assertTrue(session.state.value is Session.SignedIn)
assertEquals("access-A", session.currentAccessToken())
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
}
@Test fun `state mismatch fails without exchanging`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val session = SessionManager(FakeTokenStore())
val mgr = managerWith(api, session)
startAndState(mgr) // establishes a pending with a different state
mgr.complete(state = "not-the-state", code = "auth-code-1", error = null)
assertEquals(0, api.exchangeCalls)
assertTrue(session.state.value is Session.SignedOut)
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
}
@Test fun `missing pending flow (process death) fails closed`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
// No buildStartUrl → nothing stashed; a callback can't be trusted.
mgr.complete(state = "anything", code = "auth-code-1", error = null)
assertEquals(0, api.exchangeCalls)
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
}
@Test fun `pending survives process death — a fresh manager on the same store completes`() = runTest {
// Persist the pending on one instance, then throw that instance away.
val store = FakePendingSsoStore()
val session = SessionManager(FakeTokenStore())
val started = managerWith(FakeSsoApi { Response.success(tokenPair()) }, session, store = store)
val state = startAndState(started)
// A brand-new manager (simulating the app relaunched after eviction) reads the
// persisted pending and completes the exchange — the old in-memory holder would
// have lost it and failed STATE_MISMATCH.
val api = FakeSsoApi { Response.success(tokenPair()) }
val revived = managerWith(api, session, store = store)
revived.complete(state = state, code = "auth-code-1", error = null)
assertEquals(1, api.exchangeCalls)
assertTrue(session.state.value is Session.SignedIn)
assertEquals(SsoAuthManager.Outcome.Success, revived.outcome.value)
}
@Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
val state = startAndState(mgr)
mgr.complete(state = state, code = null, error = "access_denied")
assertEquals(0, api.exchangeCalls)
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.DENIED), mgr.outcome.value)
}
@Test fun `401 exchange maps to expired code`() = runTest {
val api = FakeSsoApi { error(401) }
val session = SessionManager(FakeTokenStore())
val mgr = managerWith(api, session)
val state = startAndState(mgr)
mgr.complete(state = state, code = "stale-code", error = null)
assertEquals(1, api.exchangeCalls)
assertTrue(session.state.value is Session.SignedOut)
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.EXPIRED_CODE), mgr.outcome.value)
}
@Test fun `a second delivery of the same callback finds no pending`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) }
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
val state = startAndState(mgr)
mgr.complete(state = state, code = "auth-code-1", error = null)
mgr.complete(state = state, code = "auth-code-1", error = null) // replay
// Only the first delivery exchanged; the replay fails the state guard.
assertEquals(1, api.exchangeCalls)
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
}
@Test fun `matchesCallback only accepts the fixed scheme host and path`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
assertTrue(mgr.matchesCallback("runicgateway", "auth", "/callback"))
assertTrue(!mgr.matchesCallback("https", "auth", "/callback"))
assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback"))
}
// ── App Links (docs/android/APP_LINKS.md) ────────────────────────────────
@Test fun `matchesAppLinkCallback accepts only https, the app-link path, and the paired host`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
// Paired to shard.example.com (managerWith default base).
assertTrue(mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
// Host-trust: a foreign host is refused even over https + right path.
assertTrue(!mgr.matchesAppLinkCallback("https", "evil.example.com", "/mobile/callback"))
// Wrong scheme / wrong path.
assertTrue(!mgr.matchesAppLinkCallback("http", "shard.example.com", "/mobile/callback"))
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/callback"))
// Host match is case-insensitive.
assertTrue(mgr.matchesAppLinkCallback("https", "SHARD.EXAMPLE.COM", "/mobile/callback"))
}
@Test fun `matchesAppLinkCallback is false before a shard is paired`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
}
@Test fun `buildStartUrl requests the custom scheme when no app-link host is baked`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
// Generic build: appLinkHost defaults to BuildConfig.APP_LINK_HOST ("" in tests).
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
}
@Test fun `buildStartUrl requests the https app-link callback when the baked host matches the paired host`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
mgr.appLinkHost = "shard.example.com" // white-label build baked this shard's host
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals("https://shard.example.com/mobile/callback", redirect)
}
@Test fun `buildStartUrl falls back to the custom scheme when the baked host does not match the paired shard`() {
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
mgr.appLinkHost = "other-shard.example.com" // built for a different shard than the paired one
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
}
}

View File

@@ -1,42 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Tests for the app's own ntfy topic + endpoint URL building (PLAN.md §11, work
* item 1) — the heart of the embedded-distributor design.
*/
class NtfyTopicTest {
@Test fun generatesUnguessableTopicsInTheAllowedCharset() {
val a = NtfyTopic.generate()
val b = NtfyTopic.generate()
assertNotEquals(a, b)
assertTrue("prefixed", a.startsWith("up"))
assertTrue("length", a.length >= 24)
assertTrue("charset", a.all { it.isLetterOrDigit() })
}
@Test fun buildsEndpointAndSseUrls() {
assertEquals("https://ntfy.tld/up7", NtfyTopic.endpointUrl("https://ntfy.tld", "up7"))
assertEquals("https://ntfy.tld/up7/sse", NtfyTopic.sseUrl("https://ntfy.tld", "up7"))
}
@Test fun toleratesTrailingSlashOnBase() {
assertEquals("https://ntfy.tld/up7", NtfyTopic.endpointUrl("https://ntfy.tld/", "up7"))
}
@Test fun nullOrBlankInputsYieldNull() {
assertNull(NtfyTopic.endpointUrl(null, "up7"))
assertNull(NtfyTopic.endpointUrl("", "up7"))
assertNull(NtfyTopic.endpointUrl("https://ntfy.tld", " "))
assertNull(NtfyTopic.sseUrl(null, "up7"))
}
}

View File

@@ -1,52 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.push
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Parsing tests for the content-free push tickle over ntfy's SSE envelope
* (PLAN.md §11). A `message` frame yields `{ stream, ref }`; lifecycle frames and
* malformed bodies are dropped (never thrown, §7).
*/
class PushTickleTest {
private val json = Json { ignoreUnknownKeys = true }
@Test fun parsesMessageFrame() {
// ntfy wraps our POSTed body in { event:"message", message:"<our json>" }.
val data = """{"id":"x","time":1,"event":"message","topic":"up1","message":"{\"stream\":\"vendor.sale\",\"ref\":\"0x40001\"}"}"""
val tickle = parseNtfyTickle(json, data)
assertEquals(PushTickle("vendor.sale", "0x40001"), tickle)
}
@Test fun parsesMessageWithoutRef() {
val data = """{"event":"message","message":"{\"stream\":\"server.status\"}"}"""
val tickle = parseNtfyTickle(json, data)
assertEquals("server.status", tickle?.stream)
assertNull(tickle?.ref)
}
@Test fun dropsOpenAndKeepaliveFrames() {
assertNull(parseNtfyTickle(json, """{"event":"open","topic":"up1"}"""))
assertNull(parseNtfyTickle(json, """{"event":"keepalive","topic":"up1"}"""))
}
@Test fun dropsMalformedOrEmpty() {
assertNull(parseNtfyTickle(json, ""))
assertNull(parseNtfyTickle(json, ": keepalive comment"))
assertNull(parseNtfyTickle(json, "not json"))
// A message whose inner body isn't our shape → no stream → dropped.
assertNull(parseNtfyTickle(json, """{"event":"message","message":"{}"}"""))
assertNull(parseNtfyTickle(json, """{"event":"message","message":"garbage"}"""))
}
@Test fun decodeTickleRejectsBlankStream() {
assertNull(decodeTickle(json, """{"stream":"","ref":"x"}"""))
assertEquals(PushTickle("news.post"), decodeTickle(json, """{"stream":"news.post"}"""))
}
}

View File

@@ -1,41 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
* signal the player screens render as offline.
*/
class ApiResultExtrasTest {
@Test fun mapTransformsOkBody() {
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
assertEquals(ApiResult.Ok(3), mapped)
}
@Test fun mapPassesFailuresThroughUnchanged() {
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
assertSame(http, http.map { it + 1 })
val cause = RuntimeException("offline")
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
val out = network.map { it + 1 }
assertTrue(out is ApiResult.NetworkError)
assertSame(cause, (out as ApiResult.NetworkError).cause)
}
@Test fun isShardUnavailableOnlyForHttp503() {
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
}
}

View File

@@ -35,20 +35,6 @@ class ApiResultTest {
assertTrue(result is ApiResult.NetworkError) assertTrue(result is ApiResult.NetworkError)
} }
/**
* A body the app can't decode (a field whose type doesn't match its DTO) throws a
* [SerializationException] out of the Retrofit converter. It must degrade to a
* server-side error the UI renders, not escape and crash the app — the guild-board
* crash this fixes. `502` folds to [ui.ErrorKind.SERVER] via `toUiState`.
*/
@Test fun serializationExceptionBecomesServerError() = runTest {
val result = safeApiCall {
throw kotlinx.serialization.SerializationException("Unexpected symbol 'm' at path: \$[0].members")
}
assertTrue(result is ApiResult.HttpError)
assertEquals(502, (result as ApiResult.HttpError).status)
}
@Test fun cancellationIsRethrown() = runTest { @Test fun cancellationIsRethrown() = runTest {
assertThrows(CancellationException::class.java) { assertThrows(CancellationException::class.java) {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {

View File

@@ -54,66 +54,6 @@ class AccountDtoTest {
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled) assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
} }
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<TotpStateDto>(
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
)
assertTrue(dto.totp_enabled)
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
}
@Test fun totpStateDisableHasNoRecoveryCodes() {
// Disable (and older backends) omit the field — must decode to null, not crash.
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
assertFalse(dto.totp_enabled)
assertEquals(null, dto.recoveryCodes)
}
@Test fun trustedDeviceDecodes() {
val dto = json.decodeFromString<TrustedDeviceDto>(
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
"expiresAt":"2026-08-19T10:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("mobile", dto.platform)
assertEquals("Pixel 8", dto.deviceName)
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
}
@Test fun trustDeviceResultCarriesNativeToken() {
val dto = json.decodeFromString<TrustDeviceResultDto>(
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
)
assertTrue(dto.trusted)
assertEquals("opaque-token-abc", dto.trustToken)
}
@Test fun trustedDeviceLimitDecodesDevices() {
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
"""{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
)
assertEquals("trusted_device_limit", dto.error)
assertEquals(2, dto.devices.size)
assertEquals(2L, dto.devices[1].id)
}
@Test fun recoveryStatusAndCodesDecode() {
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
val codes = json.decodeFromString<RecoveryCodesDto>(
"""{"recoveryCodes":["c1","c2","c3"]}""",
)
assertEquals(3, codes.recoveryCodes.size)
}
@Test fun revokedResultsDecode() {
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
}
@Test fun linkedIdentityDecodes() { @Test fun linkedIdentityDecodes() {
val dto = json.decodeFromString<LinkedIdentityDto>( val dto = json.decodeFromString<LinkedIdentityDto>(
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""", """{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",

View File

@@ -1,127 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
*/
class AdminDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun dashboardDecodesNestedCountsAndActivity() {
val dto = json.decodeFromString<AdminDashboardDto>(
"""{
"site_mode":"maintenance",
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
"recent_activity":[
{"id":9,"username":"mod","action":"post.publish",
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
]
}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.lastChange.by)
assertEquals(4, dto.counts.posts["news"])
assertEquals(37, dto.counts.users)
assertEquals(1, dto.recentActivity.size)
assertEquals("post.publish", dto.recentActivity[0].action)
// `detail` is provider-shaped JSON kept as a raw element.
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
}
@Test fun dashboardDefaultsWhenKeysAbsent() {
val dto = json.decodeFromString<AdminDashboardDto>("{}")
assertEquals("live", dto.siteMode)
assertTrue(dto.counts.posts.isEmpty())
assertTrue(dto.recentActivity.isEmpty())
}
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
val published = json.decodeFromString<AdminPostDto>(
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
)
assertTrue(published.isPublished)
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
assertFalse(draft.isPublished)
}
@Test fun adminWikiCategoryAndTagDecodeCounts() {
val cat = json.decodeFromString<AdminWikiCategoryDto>(
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
)
assertEquals(2, cat.sortOrder)
assertEquals(5, cat.pageCount)
assertEquals(4, cat.publishedCount)
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
assertEquals("PvP", tag.label)
assertEquals(11, tag.publishedCount)
}
@Test fun supportPageDecodesSenderActor() {
val dto = json.decodeFromString<SupportPageDto>(
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
)
assertEquals("0x1A2B", dto.pageId)
assertEquals("Gwen", dto.sender?.name)
assertEquals("gwen01", dto.sender?.account)
assertEquals(false, dto.handled)
}
@Test fun supportPageToleratesMissingSender() {
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
assertNull(dto.sender)
assertNull(dto.type)
}
@Test fun siteModeStateDecodesAudit() {
val dto = json.decodeFromString<SiteModeStateDto>(
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.changedBy)
}
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
.contains("\"sort_order\":1"))
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
assertTrue(post.contains("\"image_url\":\"/img.png\""))
assertTrue(post.contains("\"category\":\"news\""))
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
assertTrue(ban.contains("\"durationSec\":3600"))
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
assertTrue(kick.contains("\"serial\":\"0xFF\""))
}
}

View File

@@ -68,35 +68,4 @@ class AuthDtoTest {
assertNull(dto.expiresIn) assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role) assertEquals("admin", dto.user.role)
} }
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustToken":"opaque-abc"}""",
)
assertEquals("opaque-abc", dto.trustToken)
assertFalse(dto.trustLimitReached)
}
@Test fun loginSignalsTrustLimitWithDevices() {
// At the cap: login still succeeds, but no token; the device list is returned.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
)
assertNull(dto.trustToken)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
)
assertNull(dto.trustToken)
assertFalse(dto.trustLimitReached)
assertTrue(dto.devices.isEmpty())
}
} }

View File

@@ -1,68 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
* the trusted-device outcome fields on the login response.
*/
class AuthRequestDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
val body = json.encodeToString(
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
)
assertTrue(body.contains("\"username\":\"gwen\""))
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
assertTrue(body.contains("\"trustDevice\":true"))
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
}
@Test fun loginRequestCarriesSecondFactorOnRetry() {
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
assertTrue(withCode.contains("\"code\":\"123456\""))
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
}
@Test fun refreshAndLogoutBodiesEncode() {
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
}
@Test fun tokenResponseDecodesTrustOutcome() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
"user":{"id":1,"username":"gwen","role":"player"},
"trustToken":"opaque"}""",
)
assertEquals("a", dto.accessToken)
assertEquals("opaque", dto.trustToken)
assertFalse(dto.trustLimitReached)
assertEquals("gwen", dto.user.username)
}
@Test fun tokenResponseDecodesTrustLimitReached() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
}

View File

@@ -1,82 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
*/
class ContentDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun postDetailDecodesBodyAndImage() {
val dto = json.decodeFromString<PostDto>(
"""{"id":10,"category":"news","title":"Update","slug":"update",
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(10L, dto.id)
assertEquals("<p>full</p>", dto.body)
assertEquals("/i.png", dto.imageUrl)
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
}
@Test fun postListRowToleratesMissingBody() {
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
assertNull(dto.body)
assertNull(dto.imageUrl)
}
@Test fun pageDecodesBlocksWithRawProps() {
val dto = json.decodeFromString<PageDto>(
"""{
"id":3,"slug":"about","title":"About","status":"published",
"blocks":[
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
{"type":"divider","props":{}}
],
"publishedAt":"2026-07-01T00:00:00Z"
}""",
)
assertEquals("about", dto.slug)
assertEquals(2, dto.blocks.size)
assertEquals("heading", dto.blocks[0].type)
// props stay a raw JSON object the renderer reads by key.
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
assertTrue(dto.blocks[1].visible) // default true when absent
}
@Test fun contactResponseSentAndFallbackVariants() {
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
assertTrue(sent.sent)
assertNull(sent.fallback)
val fallback = json.decodeFromString<ContactResponse>(
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
)
assertEquals("mailto", fallback.fallback)
assertEquals("a@b.c", fallback.email)
}
@Test fun contactRequestEncodesAllFields() {
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
assertTrue(body.contains("\"name\":\"Gwen\""))
assertTrue(body.contains("\"email\":\"g@x.c\""))
assertTrue(body.contains("\"message\":\"hi\""))
}
}

View File

@@ -1,85 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decoding tests for the opt-in push DTOs (PLAN.md §11, M7 Part 2). Shapes come
* from the merged backend (`notifications.controller` / `pushDevices.model`);
* unknown keys are ignored (additive fields, §8).
*/
class NotificationsDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun pushDeviceDecodes() {
val dto = json.decodeFromString<PushDeviceDto>(
"""{"id":9,"transport":"unifiedpush","endpoint":"https://ntfy.example.com/up123",
"platform":"android","createdAt":"2026-07-20T00:00:00Z","lastSeenAt":null}""",
)
assertEquals(9L, dto.id)
assertEquals("unifiedpush", dto.transport)
assertEquals("https://ntfy.example.com/up123", dto.endpoint)
assertEquals("android", dto.platform)
assertNull(dto.lastSeenAt)
}
@Test fun streamCatalogDecodesPersonalFlags() {
val dto = json.decodeFromString<NotificationStreamsDto>(
"""{"streams":[
{"id":"news.post","label":"News posts","description":"New posts.","personal":false,"requiresLinkedAccount":false},
{"id":"vendor.sale","label":"Your vendor sold","description":"A sale.","personal":true,"requiresLinkedAccount":true}
]}""",
)
assertEquals(2, dto.streams.size)
val news = dto.streams.first { it.id == "news.post" }
assertFalse(news.personal)
assertFalse(news.requiresLinkedAccount)
val vendor = dto.streams.first { it.id == "vendor.sale" }
assertTrue(vendor.personal)
assertTrue(vendor.requiresLinkedAccount)
}
@Test fun subscriptionsDecode() {
val dto = json.decodeFromString<NotificationSubscriptionsDto>(
"""{"streams":["news.post","champ.start"]}""",
)
assertEquals(listOf("news.post", "champ.start"), dto.streams)
}
@Test fun emptySubscriptionsStillSerializeStreamsField() {
// Regression: clearing the LAST subscription sends an empty set. The backend
// validator requires `streams`, so it must be present as `[]`, not omitted.
// Uses the production Json config (no encodeDefaults) to prove the field is
// always emitted because the DTO field has no default.
val body = json.encodeToString(NotificationSubscriptionsDto(emptyList()))
assertEquals("""{"streams":[]}""", body)
}
@Test fun settingsPushBlockDecodes() {
val dto = json.decodeFromString<SettingsDto>(
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",
)
assertEquals("https://ntfy.shard.tld", dto.push.ntfyUrl)
}
@Test fun settingsPushDefaultsNullOnOlderBackend() {
// A backend predating M7 omits `push` entirely — the app must still decode.
val dto = json.decodeFromString<SettingsDto>(
"""{"site_title":"Shard","brand":{"name":"Shard"}}""",
)
assertNull(dto.push.ntfyUrl)
}
}

View File

@@ -1,115 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the player self-service game-data DTOs
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
*/
class PlayerGameDataDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun linkRequestAndResultRoundTrip() {
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
assertTrue(result.linked)
assertEquals("acct1", result.account)
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
}
@Test fun linkedAccountDecodes() {
val dto = json.decodeFromString<ShardLinkDto>(
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
)
assertEquals("acct1", dto.account)
assertEquals(42L, dto.userId)
}
@Test fun rosterDecodesCharacters() {
val dto = json.decodeFromString<RosterDto>(
"""{"acct":"acct1","chars":[
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
)
assertEquals(2, dto.chars.size)
assertTrue(dto.chars[0].online)
assertEquals("0x24C", dto.chars[0].serial)
}
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
val dto = json.decodeFromString<CharProfileDto>(
"""{
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
"guild":{"name":"Knights","abbr":"KoT"},
"governorOf":["Britain"]
}""",
)
assertEquals("Gwen", dto.name)
assertEquals(100, dto.stats!!.str)
assertEquals(70, dto.stats!!.resist!!.phys)
assertEquals(110.0, dto.skills.first().value!!, 0.0)
assertEquals("OneHanded", dto.equipment.first().layer)
assertEquals("Glorious", dto.titles!!.fameKarma)
assertEquals("Knights", dto.guild!!.name)
assertEquals(listOf("Britain"), dto.governorOf)
}
@Test fun charSheetToleratesMinimalPayload() {
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
assertEquals(null, dto.stats)
assertTrue(dto.skills.isEmpty())
assertTrue(dto.equipment.isEmpty())
assertFalse(dto.online)
}
@Test fun vendorSnapshotAndListingsDecode() {
val dto = json.decodeFromString<VendorSnapshotDto>(
"""{"acct":"acct1","vendors":[
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
)
val vendor = dto.vendors.first()
assertEquals("Wares", vendor.shopName)
assertEquals(5000L, vendor.holdGold)
val listing = vendor.listings.first()
assertEquals(250L, listing.price)
assertTrue(listing.forSale)
}
@Test fun vendorSaleDecodes() {
val dto = json.decodeFromString<VendorSaleDto>(
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
)
assertEquals(1000L, dto.price)
assertEquals(50, dto.commission)
}
@Test fun playerHouseDecodesDecayFields() {
val dto = json.decodeFromString<PlayerHouseDto>(
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
)
assertEquals("LikeNew", dto.stage)
assertEquals("Keep", dto.name)
assertFalse(dto.isIdoc)
}
}

View File

@@ -1,73 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public site/identity DTOs (`/public/status`,
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
* branding/registration/push blocks, and the additive-field tolerance.
*/
class PublicDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun statusDecodesVersionAndMaintenanceFlag() {
val dto = json.decodeFromString<StatusDto>(
"""{"mode":"MAINTENANCE","status_message":"back soon",
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
)
assertTrue(dto.isMaintenance) // case-insensitive
assertEquals("back soon", dto.statusMessage)
assertEquals("1.2.3", dto.version.server)
}
@Test fun liveStatusIsNotMaintenance() {
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
}
@Test fun statusDefaultsWhenEmpty() {
val dto = json.decodeFromString<StatusDto>("{}")
assertEquals("live", dto.mode)
assertFalse(dto.isMaintenance)
assertEquals("", dto.version.api)
}
@Test fun settingsDecodesBrandRegistrationAndPush() {
val dto = json.decodeFromString<SettingsDto>(
"""{
"site_title":"UOMysticmoon","status_message":"welcome",
"registration":{"password":true,"sso":false},
"gameAccountSignup":true,
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
"push":{"ntfyUrl":"https://ntfy.example.com"}
}""",
)
assertEquals("UOMysticmoon", dto.siteTitle)
assertTrue(dto.registration.password)
assertFalse(dto.registration.sso)
assertTrue(dto.gameAccountSignup)
assertEquals("#7f99bd", dto.brand.accent)
assertEquals("/logo.png", dto.brand.logo)
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
}
@Test fun settingsDefaultsOnOlderBackend() {
// A backend that predates push/branding: nested blocks fall back to defaults.
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
assertFalse(dto.registration.password)
assertEquals("", dto.brand.name)
assertEquals(null, dto.push.ntfyUrl)
}
}

View File

@@ -1,104 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
* permissive board payloads (champ/guild/governor/house/presence).
*/
class ShardBoardDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
}
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
val online = json.decodeFromString<ShardStatusDto>(
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
)
assertTrue(online.isOnline)
assertEquals(42, online.onlineCount)
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
}
@Test fun champDecodesBossAndProgressFields() {
val dto = json.decodeFromString<ChampDto>(
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
)
assertTrue(dto.active)
assertTrue(dto.bossUp)
assertEquals(16, dto.maxLevel)
assertEquals(5000L, dto.hits)
}
@Test fun guildDecodesLeaderActor() {
val dto = json.decodeFromString<GuildDto>(
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
"leader":{"name":"Arthur","webId":"9931"}}""",
)
assertEquals("Knights", dto.name)
assertEquals("Arthur", dto.leader!!.label)
assertEquals("9931", dto.leader!!.webId)
}
@Test fun governorAndTermDecode() {
val gov = json.decodeFromString<GovernorDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
)
assertEquals("Britain", gov.city)
assertEquals("Dawn", gov.governor!!.label)
val term = json.decodeFromString<GovernorTermDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
)
assertEquals(50, term.votes)
assertEquals(2000L, term.endedAt)
}
@Test fun houseAndPresenceAndStaffDecode() {
val house = json.decodeFromString<HouseDto>(
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
)
assertTrue(house.isIdoc)
assertEquals("Tower", house.name)
val presence = json.decodeFromString<PresenceDto>(
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
)
assertEquals(30, presence.count)
assertEquals(10, presence.byFacet["Felucca"])
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
assertEquals("GM Bob", staff.name)
}
@Test fun feedEventDecodesPayloadObject() {
val ev = json.decodeFromString<FeedEventDto>(
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
)
assertEquals("champ.spawn", ev.kind)
assertTrue(ev.payload!!.containsKey("name"))
}
}

View File

@@ -43,31 +43,25 @@ class ShardDtoTest {
} }
@Test fun champUpdateFrameDecodesWithKindAndExtras() { @Test fun champUpdateFrameDecodesWithKindAndExtras() {
// A live champ.update frame: has `kind`, a hex-string `serial` (INTEGRATION.md // A live champ.update frame: has `kind`, `serial`, and category extras. The
// §1 — serials are opaque hex keys, never numbers), and category extras. The // `kind` field is ignored (not on the DTO) and the extras decode.
// `kind`/`rank`/`autoRestart` fields are ignored (not on the DTO); extras decode.
val dto = json.decodeFromString<ChampDto>( val dto = json.decodeFromString<ChampDto>(
"""{"kind":"champ.update","serial":"0x40012345","category":"champion","name":"Barracoon", """{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon",
"status":"active","active":true,"level":10,"rank":3,"maxKills":250,"kills":120, "status":"active","active":true,"level":10,"maxKills":250,"kills":120,
"autoRestart":true,"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""", "bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
) )
assertEquals("0x40012345", dto.serial) assertEquals(12345L, dto.serial)
assertEquals("champion", dto.category) assertEquals("champion", dto.category)
assertEquals(120, dto.kills) assertEquals(120, dto.kills)
assertTrue(dto.active) assertTrue(dto.active)
} }
@Test fun guildFrameDecodesLeaderActor() { @Test fun guildFrameDecodesLeaderActor() {
// The leader actor carries a hex-string serial and a string webId (the linked
// site-user id) — the exact wire shape from INTEGRATION.md §7.
val dto = json.decodeFromString<GuildDto>( val dto = json.decodeFromString<GuildDto>(
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12, """{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
"online":3,"alliance":"Light", "online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""",
"leader":{"serial":"0x1A2B","name":"Arthur","acct":"art","webId":"9931","player":true}}""",
) )
assertEquals(7L, dto.id) assertEquals(7L, dto.id)
assertEquals("0x1A2B", dto.leader?.serial)
assertEquals("9931", dto.leader?.webId)
assertEquals("Arthur", dto.leader?.label) assertEquals("Arthur", dto.leader?.label)
assertEquals(12, dto.members) assertEquals(12, dto.members)
} }
@@ -92,21 +86,13 @@ class ShardDtoTest {
@Test fun houseDecodesPublicIdocShape() { @Test fun houseDecodesPublicIdocShape() {
val dto = json.decodeFromString<HouseDto>( val dto = json.decodeFromString<HouseDto>(
"""{"serial":"0x40001234","name":"Tower","region":"Britain","map":"Felucca", """{"serial":999,"name":"Tower","region":"Britain","map":"Felucca",
"x":1,"y":2,"z":3,"isIdoc":true}""", "x":1,"y":2,"z":3,"isIdoc":true}""",
) )
assertEquals("0x40001234", dto.serial) assertEquals(999L, dto.serial)
assertTrue(dto.isIdoc) assertTrue(dto.isIdoc)
} }
@Test fun onlineStaffDecodesHexSerial() {
val dto = json.decodeFromString<OnlineStaffDto>(
"""{"serial":"0x24C","name":"Darrow"}""",
)
assertEquals("0x24C", dto.serial)
assertEquals("Darrow", dto.name)
}
@Test fun actorLabelFallsBackToAcctThenSomeone() { @Test fun actorLabelFallsBackToAcctThenSomeone() {
assertEquals("bob", ActorDto(acct = "bob").label) assertEquals("bob", ActorDto(acct = "bob").label)
assertEquals("Someone", ActorDto().label) assertEquals("Someone", ActorDto().label)

View File

@@ -1,46 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
* discovery is public (never secrets); the exchange body uses snake_case
* `code_verifier` to match the backend.
*/
class SsoDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun providerDecodesWithOptionalFields() {
val dto = json.decodeFromString<SsoProviderDto>(
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
)
assertEquals("discord", dto.id)
assertEquals("Discord", dto.name)
assertEquals(2, dto.priority)
}
@Test fun providerToleratesMissingOptionals() {
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
assertNull(dto.icon)
assertNull(dto.priority)
}
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
assertTrue(body.contains("\"code\":\"abc123\""))
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
}
}

View File

@@ -1,66 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
*/
class WikiDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun summaryRowDecodesWithCategory() {
val dto = json.decodeFromString<WikiSummaryDto>(
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("systems", dto.categorySlug)
assertEquals("Systems", dto.categoryTitle)
assertEquals("combat", dto.excerpt)
}
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
val dto = json.decodeFromString<WikiPageDto>(
"""{
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
"category_slug":"systems","category_title":"Systems",
"tags":[{"slug":"idoc","label":"IDOC"}],
"backlinks":[{"slug":"pvp","title":"PvP"}],
"missing_links":["nonexistent-page"]
}""",
)
assertEquals("<p>text</p>", dto.body)
assertEquals(1, dto.tags.size)
assertEquals("IDOC", dto.tags[0].label)
assertEquals("pvp", dto.backlinks[0].slug)
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
}
@Test fun pageDefaultsCollectionsWhenAbsent() {
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
assertTrue(dto.tags.isEmpty())
assertTrue(dto.backlinks.isEmpty())
assertTrue(dto.missingLinks.isEmpty())
}
@Test fun categoryAndTagDecodePublishedCounts() {
val cat = json.decodeFromString<WikiCategoryDto>(
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
)
assertEquals(12L, cat.publishedCount)
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
assertEquals(3L, tag.publishedCount)
}
}

View File

@@ -1,88 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
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.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
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.WikiCategoryRequest
import com.runicgateway.app.util.okUnit
import retrofit2.Response
/**
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
* branches. Set [error] to throw from every call (network / decode failure paths).
*/
class FakeAdminApi : AdminApi {
var error: Throwable? = null
var dashboard: AdminDashboardDto = AdminDashboardDto()
var siteMode: SiteModeStateDto = SiteModeStateDto()
var posts: List<AdminPostDto> = emptyList()
var createdPost: AdminPostDto = AdminPostDto(id = 0)
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
var wikiTags: List<AdminWikiTagDto> = emptyList()
var supportPages: List<SupportPageDto> = emptyList()
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
var unitResponse: Response<Unit> = okUnit()
/** Bodies seen by write calls, so a test can assert what was sent. */
var lastPostCreate: PostCreateRequest? = null
var lastBan: BanRequest? = null
var lastRespond: Pair<String, PageRespondRequest>? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
override suspend fun posts(): List<AdminPostDto> = reply(posts)
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
lastPostCreate = body
return reply(createdPost)
}
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
override suspend fun ban(body: BanRequest): Response<Unit> {
lastBan = body
return reply(unitResponse)
}
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
lastRespond = id to body
return reply(unitResponse)
}
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
}

View File

@@ -1,47 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkRequest
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
/**
* A configurable fake of [PlayerShardApi] for the player self-service repository /
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
* (drives the `503 shard offline` / `403 not-linked` / network paths).
*/
class FakePlayerShardApi : PlayerShardApi {
var error: Throwable? = null
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
var accounts: List<ShardLinkDto> = emptyList()
var roster: RosterDto = RosterDto()
var char: CharProfileDto = CharProfileDto()
var vendors: VendorSnapshotDto = VendorSnapshotDto()
var sales: List<VendorSaleDto> = emptyList()
var houses: List<PlayerHouseDto> = emptyList()
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
override suspend fun roster(account: String): RosterDto = reply(roster)
override suspend fun char(serial: String): CharProfileDto = reply(char)
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
}

View File

@@ -1,98 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
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.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.dto.WikiTagDto
/**
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
* relevant `var` to the body a call should return; set [error] to make every call
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
* empty/neutral so a call an assertion doesn't care about never crashes.
*/
class FakePublicApi : PublicApi {
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
var error: Throwable? = null
var status: StatusDto = StatusDto()
var settings: SettingsDto = SettingsDto()
var posts: List<PostDto> = emptyList()
var post: PostDto = PostDto(id = 0)
var page: PageDto = PageDto(id = 0)
var wikiPages: List<WikiSummaryDto> = emptyList()
var wikiCategories: List<WikiCategoryDto> = emptyList()
var wikiTags: List<WikiTagDto> = emptyList()
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
var contactResponse: ContactResponse = ContactResponse(sent = true)
var shardStatus: ShardStatusDto = ShardStatusDto()
var shardFeed: List<FeedEventDto> = emptyList()
var shardEconomy: List<EconomySampleDto> = emptyList()
var shardOnline: List<OnlineStaffDto> = emptyList()
var shardPresence: PresenceDto = PresenceDto()
var champs: List<ChampDto> = emptyList()
var guilds: List<GuildDto> = emptyList()
var governors: List<GovernorDto> = emptyList()
var governorHistory: List<GovernorTermDto> = emptyList()
var houses: List<HouseDto> = emptyList()
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
var lastContact: ContactRequest? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
override suspend fun getStatus(): StatusDto = reply(status)
override suspend fun getSettings(): SettingsDto = reply(settings)
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
override suspend fun getPage(slug: String): PageDto = reply(page)
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
reply(wikiPages)
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
override suspend fun postContact(body: ContactRequest): ContactResponse {
lastContact = body
return reply(contactResponse)
}
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
reply(governorHistory)
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
}

View File

@@ -1,19 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
* is exercised deterministically.
*/
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
}

View File

@@ -1,120 +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.data.api.MeApi
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 com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
/**
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
*/
class AccountTrustedDevicesTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
private open class FakeMeApi(
var trustResponse: Response<TrustDeviceResultDto>? = null,
var devices: List<TrustedDeviceDto> = emptyList(),
var revokeFlag: Boolean = true,
var revokeCount: Int = 0,
var remaining: Int = 0,
var generated: List<String> = emptyList(),
) : MeApi {
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
RecoveryCodesDto(generated)
// Unused by these tests.
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String) = Unit
}
private fun repo(api: MeApi) = AccountRepository(api, json)
@Test fun trustThisDeviceReturnsToken() = runTest {
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
val outcome = repo(api).trustThisDevice("Pixel")
assertTrue(outcome is TrustOutcome.Trusted)
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
}
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
val body = """{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
.toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(409, body))
val outcome = repo(api).trustThisDevice(null)
assertTrue(outcome is TrustOutcome.LimitReached)
val devices = (outcome as TrustOutcome.LimitReached).devices
assertEquals(2, devices.size)
assertEquals("Pixel", devices[1].deviceName)
}
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(500, body))
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
}
@Test fun revokeMapsFlagAndCount() = runTest {
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
assertTrue(revoked is ApiResult.Ok && revoked.data)
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
assertTrue(all is ApiResult.Ok && all.data == 3)
}
@Test fun recoveryStatusAndGenerateMap() = runTest {
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
assertTrue(gen is ApiResult.Ok)
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
}
}

View File

@@ -1,120 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
import com.runicgateway.app.ui.news.PostViewModel
import com.runicgateway.app.ui.page.PageViewModel
import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the public content APIs (news, CMS pages, wiki, home status). */
class ContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private val content = ContentRepository(api)
private val wiki = WikiRepository(api)
private val settings = SettingsRepository(api)
// ── News hub ──────────────────────────────────────────────────────────
@Test fun newsLoadsSelectedCategory() {
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
val vm = NewsViewModel(content)
assertTrue(vm.state.value is UiState.Success)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsSelectCategoryReloads() {
val vm = NewsViewModel(content)
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsServerErrorIsUiError() {
api.error = httpError(500)
assertTrue(NewsViewModel(content).state.value is UiState.Error)
}
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
@Test fun postDetailLoadsForKnownCategory() {
api.post = PostDto(id = 7, category = "news", title = "Update", body = "<p>x</p>")
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "news", Routes.Args.ID_OR_SLUG to "update"),
)
val vm = PostViewModel(content, handle)
assertEquals("Update", (vm.state.value as UiState.Success).data.title)
}
@Test fun postDetailUnknownCategoryIsNotFoundWithoutApiCall() {
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "bogus", Routes.Args.ID_OR_SLUG to "x"),
)
val state = PostViewModel(content, handle).state.value
assertTrue(state is UiState.Error)
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
}
// ── CMS page ────────────────────────────────────────────────────────────
@Test fun pageLoadsBySlug() {
api.page = PageDto(id = 3, slug = "about", title = "About")
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "about")))
assertEquals("About", (vm.state.value as UiState.Success).data.title)
}
@Test fun pageNotFoundIsUiError() {
api.error = httpError(404)
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "missing")))
assertEquals(ErrorKind.NOT_FOUND, (vm.state.value as UiState.Error).kind)
}
// ── Wiki index + detail ─────────────────────────────────────────────────
@Test fun wikiIndexLoadsAndTracksQuery() {
api.wikiPages = listOf(WikiSummaryDto(id = 1, slug = "pvp", title = "PvP"))
val vm = WikiViewModel(wiki)
assertTrue(vm.state.value is UiState.Success)
vm.onQueryChange("housing")
assertEquals("housing", vm.query.value)
}
@Test fun wikiPageLoadsBySlug() {
api.wikiPage = WikiPageDto(id = 9, slug = "housing", title = "Housing", body = "b")
val vm = WikiPageViewModel(wiki, SavedStateHandle(mapOf(Routes.Args.SLUG to "housing")))
assertEquals("Housing", (vm.state.value as UiState.Success).data.title)
}
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
}
}

View File

@@ -1,78 +0,0 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
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.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminContentViewModel(AdminRepository(api))
@Test fun loadsPostsAndWikiOnInit() {
api.posts = listOf(AdminPostDto(id = 1, title = "A", published = 1))
api.wikiCategories = listOf(AdminWikiCategoryDto(id = 2, slug = "lore", title = "Lore"))
api.wikiTags = listOf(AdminWikiTagDto(id = 3, slug = "pvp", label = "PvP"))
val vm = viewModel()
assertTrue(vm.state.value.posts is UiState.Success)
assertEquals(1, (vm.state.value.posts as UiState.Success).data.size)
assertEquals(1, vm.state.value.tags.size)
}
@Test fun createPostRejectsBlankTitleWithoutCallingApi() {
val vm = viewModel()
vm.createPost(category = "news", title = " ", excerpt = "", body = "", published = false)
assertFalse(vm.state.value.feedback!!.ok)
assertEquals(R.string.admin_content_title_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastPostCreate) // never reached the API
}
@Test fun createPostTrimsAndNullsBlanksThenReloads() {
val vm = viewModel()
vm.createPost(category = "news", title = " Hello ", excerpt = "", body = "b", published = true)
val sent = api.lastPostCreate!!
assertEquals("Hello", sent.title)
assertEquals(null, sent.excerpt) // blank -> null
assertEquals("b", sent.body)
assertTrue(vm.state.value.feedback!!.ok)
assertFalse(vm.state.value.busy)
}
@Test fun togglePublishForbiddenSurfacesForbiddenCopy() {
api.unitResponse = errorUnit(403)
api.error = httpError(403)
val vm = viewModel()
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
@Test fun createCategoryRejectsBlankFields() {
val vm = viewModel()
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
assertEquals(R.string.admin_content_cat_fields_required, vm.state.value.feedback!!.messageRes)
}
@Test fun deletePostNetworkErrorShowsNetworkCopy() {
api.error = java.io.IOException("offline")
val vm = viewModel()
vm.deletePost(9)
assertEquals(R.string.error_network, vm.state.value.feedback!!.messageRes)
}
}

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