Compare commits
51 Commits
81f10fbca4
...
v0.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe7a7e2a3 | |||
| b10dd444b3 | |||
| f3da6ea618 | |||
| ae170670d9 | |||
| 7fc497a1a4 | |||
| 4e3bb914ff | |||
| efe14d3828 | |||
| 43215b49a0 | |||
| a6446b04d8 | |||
| 9c52a3dafa | |||
| f0a3b6c03e | |||
| 3aeb295342 | |||
| 03d4ef6fad | |||
| a1fa4901ef | |||
| befbc01670 | |||
| 1a14d47d5c | |||
| d6d966882b | |||
| 422892f1ed | |||
| 7f876377f0 | |||
| c5596845c1 | |||
| ac99a012b0 | |||
| 44d039d2a0 | |||
| 0f93f3dcd3 | |||
| c69d704881 | |||
| 26b8eecde6 | |||
| f729b772fc | |||
| 402d750138 | |||
| 0ea6495d9e | |||
| 3050443aac | |||
| 987ddb54f8 | |||
| ab68fab382 | |||
| 7665975d59 | |||
| d97c06d6e1 | |||
| e2ced06a83 | |||
| d0fb6bbdfc | |||
| 9268579c5f | |||
| 0c395b2527 | |||
| 2d84a930e5 | |||
| b6a0fa1f5d | |||
| bc09593550 | |||
| 9514172b71 | |||
| de79bf547c | |||
| 0df862a6af | |||
| e497e6c8a7 | |||
| 52e06da8fc | |||
| 2e1bea4220 | |||
| d4f7fcb241 | |||
| ca704caaaf | |||
| 1c56eda64b | |||
| 199df6cd64 | |||
| c8f4e76370 |
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/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()
|
||||
194
.gitea/workflows/release.yml
Normal file
194
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,194 @@
|
||||
# Automated release for the Runic Gateway Android app.
|
||||
#
|
||||
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
|
||||
# build never has to push to protected `main`; the tag *is* the release input.
|
||||
#
|
||||
# To cut a release:
|
||||
# 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 +
|
||||
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
|
||||
# into app/build.gradle.kts for the build only — nothing is committed back to main.
|
||||
#
|
||||
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release)
|
||||
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
||||
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
||||
#
|
||||
# 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
|
||||
# tools + JDK 17 (not actions/setup-java), install the exact SDK packages, and
|
||||
# `chmod +x ./gradlew` in-step (checkout drops the exec bit).
|
||||
|
||||
name: Release APK
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing v* tag to (re)build and release'
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Android-app
|
||||
GRADLE_MODULE: app
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on
|
||||
# the self-hosted runner) instead of hanging forever and — because concurrency
|
||||
# is `cancel-in-progress: false` — blocking every later release behind it.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check out the release tag (full history for the changelog)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
# ── Derive version + changelog straight from the tag ─────────────────
|
||||
- name: Plan the release (version + changelog from the tag)
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||
case "$TAG" in
|
||||
v[0-9]*) : ;;
|
||||
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
|
||||
esac
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
|
||||
IFS=. read -r MA MI PA <<< "$VERSION"
|
||||
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
|
||||
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
|
||||
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
||||
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||
echo "### All changes"
|
||||
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
|
||||
echo "$SUBJECTS" | sed 's/^/- /'
|
||||
echo
|
||||
echo "---"
|
||||
echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run."
|
||||
} > dist/CHANGELOG.md
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
|
||||
|
||||
# ── SDK + signing keystore ───────────────────────────────────────────
|
||||
- name: Set up Android SDK
|
||||
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
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Decode signing keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release."
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
|
||||
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
# ── Set the version, build the signed APK ────────────────────────────
|
||||
- name: Set the app version to match the tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
|
||||
# Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks).
|
||||
sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||
sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts"
|
||||
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
|
||||
|
||||
- name: Unit tests + signed release APK
|
||||
env:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
||||
|
||||
- name: Package APK + SHA256SUMS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||
test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; }
|
||||
cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk"
|
||||
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
||||
ls -l dist && cat dist/SHA256SUMS
|
||||
|
||||
# ── Create the Gitea release + upload assets (no push to main) ───────
|
||||
- name: Create Gitea release and upload assets
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
||||
BODY="$(cat dist/CHANGELOG.md)"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
90
.gitea/workflows/sonarqube.yml
Normal file
90
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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 }}
|
||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
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
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -7,8 +10,32 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
jacoco
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.12"
|
||||
}
|
||||
|
||||
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||
// order of precedence: a local gitignored `keystore.properties` at the repo root,
|
||||
// then environment variables (how CI injects the decoded keystore + secrets). When
|
||||
// none is present, the release build is simply left unsigned — `assembleDebug` and
|
||||
// the PR gate are unaffected, so contributors without the keystore can still build.
|
||||
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||
val keystoreProps = Properties().apply {
|
||||
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
|
||||
}
|
||||
fun signingValue(propKey: String, envKey: String): String? =
|
||||
keystoreProps.getProperty(propKey) ?: System.getenv(envKey)
|
||||
|
||||
val ksStoreFilePath = signingValue("storeFile", "ANDROID_KEYSTORE_FILE")
|
||||
val ksStorePassword = signingValue("storePassword", "ANDROID_KEYSTORE_PASSWORD")
|
||||
val ksKeyAlias = signingValue("keyAlias", "ANDROID_KEY_ALIAS")
|
||||
val ksKeyPassword = signingValue("keyPassword", "ANDROID_KEY_PASSWORD")
|
||||
val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
|
||||
ksKeyAlias != null && ksKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.runicgateway.app"
|
||||
compileSdk = 35
|
||||
@@ -18,20 +45,64 @@ android {
|
||||
applicationId = "com.runicgateway.app"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||
// release.yml's conventional-commit engine bumps versionName here and commits
|
||||
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
|
||||
// so it stays monotonic. Both remain overridable via -P for local/manual builds.
|
||||
versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
|
||||
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
|
||||
|
||||
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 {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = file(ksStoreFilePath!!)
|
||||
storePassword = ksStorePassword
|
||||
keyAlias = ksKeyAlias
|
||||
keyPassword = ksKeyPassword
|
||||
// Sign with both v1 (JAR) and v2 (APK) schemes for broad compatibility.
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
|
||||
isMinifyEnabled = false
|
||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||
// release APK). Keep rules live in proguard-rules.pro.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Signed only when the keystore material is present (local or CI); an
|
||||
// unsigned APK is produced otherwise. The direct-APK release (§10) runs
|
||||
// through release.yml, which supplies the keystore from a Gitea secret.
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +164,9 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
// Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// Images
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
@@ -106,3 +180,35 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
||||
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
|
||||
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
|
||||
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
|
||||
// JVM unit tests can't execute composable bodies without Robolectric.
|
||||
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
dependsOn("testDebugUnitTest")
|
||||
group = "verification"
|
||||
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
val coverageExcludes = listOf(
|
||||
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
|
||||
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
|
||||
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
|
||||
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
|
||||
)
|
||||
val buildDirFile = layout.buildDirectory.get().asFile
|
||||
classDirectories.setFrom(
|
||||
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
|
||||
)
|
||||
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
|
||||
executionData.setFrom(
|
||||
fileTree(buildDirFile) {
|
||||
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
93
app/licenses/Cinzel-OFL.txt
Normal file
93
app/licenses/Cinzel-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
62
app/proguard-rules.pro
vendored
62
app/proguard-rules.pro
vendored
@@ -1,3 +1,59 @@
|
||||
# Runic Gateway Android app — ProGuard/R8 rules.
|
||||
# Minification is disabled until M6 (release hardening); real keep rules for
|
||||
# kotlinx.serialization DTOs and Retrofit models are added there.
|
||||
# Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
|
||||
#
|
||||
# The dependency stack ships its own consumer rules that R8 applies automatically:
|
||||
# Retrofit 2.11, OkHttp 4.12, kotlinx.serialization 1.7 (core), Hilt/Dagger, Coil 2.7.
|
||||
# The rules below are defensive belt-and-suspenders for the areas full-mode R8 is
|
||||
# most likely to over-strip in this app: the kotlinx.serialization generated
|
||||
# serializers and our own @Serializable wire DTOs.
|
||||
|
||||
# ── kotlinx.serialization (canonical keep rules) ────────────────────────────
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
# Keep the Companion of @Serializable classes so `.serializer()` resolves.
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$Companion Companion;
|
||||
}
|
||||
-keepclassmembers class <2>$Companion {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
# Keep `INSTANCE.serializer()` of @Serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
# Keep the synthesized $$serializer classes and their descriptor field.
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
|
||||
# ── Our wire DTOs ───────────────────────────────────────────────────────────
|
||||
# All request/response models decoded by kotlinx.serialization. Keeping them
|
||||
# (and their generated serializers) guarantees additive backend fields and
|
||||
# @SerialName mappings survive minification. DTOs are small, so keeping them
|
||||
# whole is cheap insurance against a full-mode strip.
|
||||
-keep @kotlinx.serialization.Serializable class com.runicgateway.app.** { *; }
|
||||
-keepclassmembers class com.runicgateway.app.data.api.dto.** { *; }
|
||||
|
||||
# ── Retrofit service interfaces ─────────────────────────────────────────────
|
||||
# Retrofit reads method + parameter annotations reflectively; keep our API
|
||||
# interfaces' generic signatures so return types (suspend .../Call<T>) resolve.
|
||||
-keep,allowobfuscation interface com.runicgateway.app.data.api.*Api
|
||||
-keepattributes Signature, Exceptions
|
||||
|
||||
# Kotlin metadata is needed for reflection over Kotlin types (serialization/Retrofit).
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# ── Tink / EncryptedSharedPreferences (androidx.security-crypto) ─────────────
|
||||
# Tink references Error Prone compile-only annotations that are absent at runtime;
|
||||
# they are safe to ignore (they carry no runtime behaviour). Suppresses the R8
|
||||
# "Missing class com.google.errorprone.annotations.*" errors.
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
|
||||
|
||||
20
app/src/debug/res/xml/network_security_config.xml
Normal file
20
app/src/debug/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?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>
|
||||
@@ -6,6 +6,13 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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
|
||||
android:name=".RunicGatewayApp"
|
||||
android:allowBackup="true"
|
||||
@@ -13,19 +20,61 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.RunicGateway">
|
||||
|
||||
<!-- singleTop so the SSO Custom Tab returning via the deep link reuses the
|
||||
running task (onNewIntent) instead of stacking a second activity. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.RunicGateway">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</manifest>
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -3,16 +3,25 @@
|
||||
*/
|
||||
package com.runicgateway.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||
import com.runicgateway.app.core.push.PushNotifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.ui.AppViewModel
|
||||
@@ -24,6 +33,8 @@ import com.runicgateway.app.ui.connect.ConnectScreen
|
||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||
@@ -33,9 +44,27 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||
handleSsoCallback(intent)
|
||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
|
||||
setContent {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -53,11 +82,47 @@ class MainActivity : ComponentActivity() {
|
||||
AppState.NeedsConnection ->
|
||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||
is AppState.Ready ->
|
||||
RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
|
||||
RunicApp(
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
/**
|
||||
* [TokenStore] backed by Jetpack Security's [EncryptedSharedPreferences]
|
||||
* (Tink/AES-256-GCM), so the token pair is encrypted at rest (PLAN.md §2, §4.3).
|
||||
* The base URL stays in plain DataStore ([com.runicgateway.app.core.prefs.ServerPreferences]);
|
||||
* only tokens live here.
|
||||
*
|
||||
* The prefs handle is created lazily so a first-launch device (no session yet)
|
||||
* pays the keystore cost only once a user actually signs in.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedTokenStore @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : TokenStore {
|
||||
|
||||
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 load(): StoredSession? {
|
||||
val access = prefs.getString(KEY_ACCESS, null) ?: return null
|
||||
val refresh = prefs.getString(KEY_REFRESH, null) ?: return null
|
||||
val username = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
val role = prefs.getString(KEY_ROLE, null) ?: return null
|
||||
val id = prefs.getLong(KEY_USER_ID, -1L)
|
||||
if (id < 0) return null
|
||||
return StoredSession(access, refresh, id, username, role)
|
||||
}
|
||||
|
||||
override fun save(session: StoredSession) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACCESS, session.accessToken)
|
||||
.putString(KEY_REFRESH, session.refreshToken)
|
||||
.putLong(KEY_USER_ID, session.userId)
|
||||
.putString(KEY_USERNAME, session.username)
|
||||
.putString(KEY_ROLE, session.role)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "runic_session"
|
||||
const val KEY_ACCESS = "access_token"
|
||||
const val KEY_REFRESH = "refresh_token"
|
||||
const val KEY_USER_ID = "user_id"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_ROLE = "role"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
69
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
69
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
|
||||
/**
|
||||
* The signed-in identity the app carries (PLAN.md §4.3, §5). Role is *advisory*
|
||||
* for menu rendering only — the backend re-checks every gated call, so the app
|
||||
* treats a 403 as authoritative and never assumes access from this value.
|
||||
*/
|
||||
data class SessionUser(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val role: Role,
|
||||
) {
|
||||
val isPlayer: Boolean get() = role == Role.PLAYER
|
||||
|
||||
/** Any staff role (moderator/editor/admin) — the staff-operations surface (§1, M10). */
|
||||
val isStaff: Boolean get() = role.isStaff
|
||||
|
||||
/** Admin or moderator — moderation actions + the support queue (`modAccess`). */
|
||||
val isModerator: Boolean get() = role == Role.ADMIN || role == Role.MODERATOR
|
||||
|
||||
/** Admin only — site-mode and other `adminOnly` controls. */
|
||||
val isAdmin: Boolean get() = role == Role.ADMIN
|
||||
}
|
||||
|
||||
/**
|
||||
* The account roles the backend issues. The three staff roles are gated by
|
||||
* capability, not rank (PLAN.md §5); [UNKNOWN] absorbs any future role so an
|
||||
* additive backend change never crashes the menu.
|
||||
*/
|
||||
enum class Role(val wire: String) {
|
||||
PLAYER("player"),
|
||||
MODERATOR("moderator"),
|
||||
EDITOR("editor"),
|
||||
ADMIN("admin"),
|
||||
UNKNOWN("");
|
||||
|
||||
val isStaff: Boolean get() = this == MODERATOR || this == EDITOR || this == ADMIN
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): Role =
|
||||
entries.firstOrNull { it.wire.equals(value, ignoreCase = true) } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/** The two auth states the UI observes. */
|
||||
sealed interface Session {
|
||||
data object SignedOut : Session
|
||||
data class SignedIn(val user: SessionUser) : Session
|
||||
}
|
||||
|
||||
internal fun SafeUserDto.toSessionUser(): SessionUser =
|
||||
SessionUser(id = id, username = username, role = Role.fromWire(role))
|
||||
|
||||
internal fun SafeUserDto.toStored(accessToken: String, refreshToken: String): StoredSession =
|
||||
StoredSession(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
userId = id,
|
||||
username = username,
|
||||
role = role,
|
||||
)
|
||||
|
||||
internal fun StoredSession.toSessionUser(): SessionUser =
|
||||
SessionUser(id = userId, username = username, role = Role.fromWire(role))
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The single source of truth for the current session (PLAN.md §4.3). It holds the
|
||||
* in-memory token pair the network layer reads on every call, mirrors the
|
||||
* signed-in identity into an observable [state] the UI + menu react to, and keeps
|
||||
* the encrypted [TokenStore] in sync.
|
||||
*
|
||||
* Threading: [state] and the token holders are read from the UI thread and
|
||||
* written from both coroutines (login/logout) and the OkHttp
|
||||
* [com.runicgateway.app.core.net.TokenAuthenticator] dispatcher thread (silent
|
||||
* refresh), so tokens live in [AtomicReference]s and the mutators are
|
||||
* `@Synchronized` to keep the token pair and [state] consistent with each other.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionManager @Inject constructor(
|
||||
private val store: TokenStore,
|
||||
) {
|
||||
private val accessRef = AtomicReference<String?>(null)
|
||||
private val refreshRef = AtomicReference<String?>(null)
|
||||
|
||||
private val _state: MutableStateFlow<Session>
|
||||
val state: StateFlow<Session>
|
||||
|
||||
init {
|
||||
val restored = store.load()
|
||||
if (restored != null) {
|
||||
accessRef.set(restored.accessToken)
|
||||
refreshRef.set(restored.refreshToken)
|
||||
_state = MutableStateFlow(Session.SignedIn(restored.toSessionUser()))
|
||||
} else {
|
||||
_state = MutableStateFlow(Session.SignedOut)
|
||||
}
|
||||
state = _state.asStateFlow()
|
||||
}
|
||||
|
||||
/** The bearer for the current request, or null when signed out. */
|
||||
fun currentAccessToken(): String? = accessRef.get()
|
||||
|
||||
/** The refresh token the authenticator rotates, or null when signed out. */
|
||||
fun currentRefreshToken(): String? = refreshRef.get()
|
||||
|
||||
val isSignedIn: Boolean get() = _state.value is Session.SignedIn
|
||||
|
||||
/** Establish a session from a successful login (§4.1). */
|
||||
@Synchronized
|
||||
fun onSignedIn(accessToken: String, refreshToken: String, user: SafeUserDto) {
|
||||
accessRef.set(accessToken)
|
||||
refreshRef.set(refreshToken)
|
||||
store.save(user.toStored(accessToken, refreshToken))
|
||||
_state.value = Session.SignedIn(user.toSessionUser())
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a rotated token pair after a silent refresh (§4.3). Keeps the current
|
||||
* user; if somehow signed out already, it is a no-op (the refresh raced a
|
||||
* logout and must not resurrect the session).
|
||||
*/
|
||||
@Synchronized
|
||||
fun onRefreshed(accessToken: String, refreshToken: String, user: SafeUserDto) {
|
||||
if (_state.value !is Session.SignedIn) return
|
||||
accessRef.set(accessToken)
|
||||
refreshRef.set(refreshToken)
|
||||
store.save(user.toStored(accessToken, refreshToken))
|
||||
// Refresh may carry an updated role — reflect it so the menu stays honest.
|
||||
_state.value = Session.SignedIn(user.toSessionUser())
|
||||
}
|
||||
|
||||
/** Refresh the cached identity from a `/auth/me` re-validation (§4.3). */
|
||||
@Synchronized
|
||||
fun onUserRefreshed(user: SafeUserDto) {
|
||||
val current = _state.value
|
||||
if (current !is Session.SignedIn) return
|
||||
val access = accessRef.get() ?: return
|
||||
val refresh = refreshRef.get() ?: return
|
||||
store.save(user.toStored(access, refresh))
|
||||
_state.value = Session.SignedIn(user.toSessionUser())
|
||||
}
|
||||
|
||||
/** Tear the session down — user logout, dead refresh, or a server switch (§3). */
|
||||
@Synchronized
|
||||
fun onSignedOut() {
|
||||
accessRef.set(null)
|
||||
refreshRef.set(null)
|
||||
store.clear()
|
||||
_state.value = Session.SignedOut
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
/**
|
||||
* The at-rest home for a signed-in session (PLAN.md §4.3): the access + refresh
|
||||
* tokens plus the cached safe-user. Tokens are sensitive, so the production
|
||||
* implementation stores them in EncryptedSharedPreferences — never plain
|
||||
* DataStore or logs. Kept behind an interface so [SessionManager] is unit-testable
|
||||
* against an in-memory fake.
|
||||
*/
|
||||
interface TokenStore {
|
||||
/** The persisted session restored on launch, or null when signed out. */
|
||||
fun load(): StoredSession?
|
||||
|
||||
/** Persist (overwrite) the current session atomically. */
|
||||
fun save(session: StoredSession)
|
||||
|
||||
/** Wipe every stored token — sign-out and the Settings → Server hard reset (§3). */
|
||||
fun clear()
|
||||
}
|
||||
|
||||
/** A persisted session: the token pair and the non-sensitive user it belongs to. */
|
||||
data class StoredSession(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val userId: Long,
|
||||
val username: String,
|
||||
val role: String,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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)
|
||||
50
app/src/main/java/com/runicgateway/app/core/auth/sso/Pkce.kt
Normal file
50
app/src/main/java/com/runicgateway/app/core/auth/sso/Pkce.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 43–128 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* 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.auth.TrustTokenStore
|
||||
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,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
) {
|
||||
|
||||
/** 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
|
||||
}
|
||||
// The user ticked "trust this device" on the TOTP form inside the Custom
|
||||
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
|
||||
// the token the exchange handed back is what lets a native PASSWORD login
|
||||
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
|
||||
// username exactly like the password path, so it is never replayed for a
|
||||
// different account on a shared device. Saved BEFORE onSignedIn so a
|
||||
// process death mid-callback can't lose it.
|
||||
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
_outcome.value = Outcome.Success
|
||||
return
|
||||
}
|
||||
|
||||
_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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Attaches the current bearer access token to outbound calls (PLAN.md §4.1).
|
||||
* Public endpoints simply carry a token the backend ignores; the credential
|
||||
* endpoints (login/refresh) tag themselves [Http.NO_SESSION_HEADER] and are left
|
||||
* bare so a credential `401` is never mistaken for an expired session. A request
|
||||
* that already set its own Authorization (the authenticator's retry) is untouched.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.header(Http.NO_SESSION_HEADER) != null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
if (request.header(Http.AUTHORIZATION) != null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val token = sessionManager.currentAccessToken()
|
||||
?: return chain.proceed(request)
|
||||
val authed = request.newBuilder()
|
||||
.header(Http.AUTHORIZATION, Http.bearer(token))
|
||||
.build()
|
||||
return chain.proceed(authed)
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
/** Shared HTTP constants for the auth layer (PLAN.md §4). */
|
||||
object Http {
|
||||
/**
|
||||
* Marks the credential endpoints (login, refresh) that must run *without* a
|
||||
* bearer and must never trigger the refresh-on-401 [TokenAuthenticator].
|
||||
* [AuthInterceptor] sees it and skips attaching a token; the authenticator
|
||||
* sees it on the failed request and declines to refresh. It is a harmless
|
||||
* unknown header to the backend.
|
||||
*/
|
||||
const val NO_SESSION_HEADER = "X-Runic-No-Session"
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
|
||||
fun bearer(token: String): String = "Bearer $token"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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>
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
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 kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
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
|
||||
|
||||
/**
|
||||
* Consumes the public live-event SSE stream (`GET /public/shard/stream`, safe kinds
|
||||
* only) and re-emits each frame as a [ShardStreamEvent] (PLAN.md §6.2, §7).
|
||||
*
|
||||
* Unlike the browser's `EventSource`, OkHttp's does **not** auto-reconnect, so the
|
||||
* reconnect/backoff loop lives here: on any disconnect the connection is torn down
|
||||
* and re-opened after a growing delay (reset once a connection opens), and while no
|
||||
* shard site is configured yet the flow simply idles. The stream is exposed as a
|
||||
* cold [Flow]; a `viewModelScope` collect opens it and cancellation closes it, so a
|
||||
* dropped feed degrades to "offline" rather than crashing.
|
||||
*/
|
||||
@Singleton
|
||||
class ShardStreamClient @Inject constructor(
|
||||
baseClient: OkHttpClient,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val json: Json,
|
||||
) : ShardStream {
|
||||
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
||||
// so the read timeout must be disabled or the idle stream would be killed.
|
||||
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
private val factory = EventSources.createFactory(sseClient)
|
||||
|
||||
/**
|
||||
* A cold flow of stream lifecycle + frame events, reconnecting with backoff
|
||||
* until the collector cancels. [ShardStreamEvent.Open] / [ShardStreamEvent.Closed]
|
||||
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||
* `{ kind, … }` payload the boards merge in place.
|
||||
*/
|
||||
override fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
while (isActive) {
|
||||
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||
if (url == null) {
|
||||
// No shard site configured (or an unresolvable base) — idle, don't spin.
|
||||
trySend(ShardStreamEvent.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(ShardStreamEvent.Open)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String,
|
||||
) {
|
||||
parseFrame(data)?.let { (kind, obj) ->
|
||||
trySend(ShardStreamEvent.Frame(kind, obj))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
trySend(ShardStreamEvent.Closed)
|
||||
ended.complete(Unit)
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?,
|
||||
) {
|
||||
trySend(ShardStreamEvent.Closed)
|
||||
ended.complete(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
val source = factory.newEventSource(request, listener)
|
||||
try {
|
||||
// Park until this connection ends; collector cancellation propagates
|
||||
// out of await() and is handled by the finally + the while guard.
|
||||
ended.await()
|
||||
} finally {
|
||||
source.cancel()
|
||||
}
|
||||
|
||||
// A connection that opened before dropping reconnects promptly; a run of
|
||||
// failures that never opened backs off further to avoid hammering a down site.
|
||||
backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE `data:` line into `(kind, object)`, dropping keepalive comments
|
||||
* and any frame without a string `kind`. Kept internal + pure for unit testing.
|
||||
*/
|
||||
internal fun parseFrame(data: String): Pair<String, JsonObject>? {
|
||||
val trimmed = data.trim()
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
|
||||
return try {
|
||||
val obj = json.parseToJsonElement(trimmed).jsonObject
|
||||
val kindEl = obj["kind"] ?: return null
|
||||
if (kindEl is JsonNull) return null
|
||||
val kind = kindEl.jsonPrimitive.content
|
||||
if (kind.isEmpty()) null else kind to obj
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)
|
||||
|
||||
private companion object {
|
||||
const val STREAM_PATH = "api/v1/public/shard/stream"
|
||||
const val INITIAL_BACKOFF_MS = 2_000L
|
||||
const val MAX_BACKOFF_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* A lifecycle or data event from the public shard SSE stream (PLAN.md §6.2).
|
||||
*
|
||||
* - [Open] — a connection was established (drive the live indicator on).
|
||||
* - [Closed] — the connection dropped or none is available (indicator off);
|
||||
* [ShardStreamClient] will reconnect with backoff.
|
||||
* - [Frame] — a live event: its `kind` plus the raw JSON object, which the
|
||||
* boards decode into their DTO (`champ.update` → `ChampDto`, …).
|
||||
*/
|
||||
sealed interface ShardStreamEvent {
|
||||
data object Open : ShardStreamEvent
|
||||
data object Closed : ShardStreamEvent
|
||||
data class Frame(val kind: String, val data: JsonObject) : ShardStreamEvent
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Transparently refreshes an expired access token on a bearer `401` and replays
|
||||
* the request (PLAN.md §4.1, §4.3). Refresh tokens are single-use and rotated, so
|
||||
* this is serialized behind a mutex: concurrent 401s trigger exactly one refresh
|
||||
* and the losers reuse its result. A refresh that comes back `401` means the
|
||||
* session is truly dead → sign out; a network error leaves the session intact so
|
||||
* a later call can retry.
|
||||
*
|
||||
* The refresh call runs on [AuthRefreshApi] (its own bare client with no
|
||||
* authenticator), so it can never recurse back into here.
|
||||
*/
|
||||
@Singleton
|
||||
class TokenAuthenticator @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
private val refreshApi: AuthRefreshApi,
|
||||
) : Authenticator {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
val failed = response.request
|
||||
// Credential endpoints (login/refresh) must never be "refreshed".
|
||||
if (failed.header(Http.NO_SESSION_HEADER) != null) return null
|
||||
// Give up after a single refresh+replay to avoid an auth loop.
|
||||
if (priorResponseCount(response) >= 2) return null
|
||||
|
||||
val attemptedAuth = failed.header(Http.AUTHORIZATION)
|
||||
|
||||
synchronized(lock) {
|
||||
// Another thread may have already refreshed while we waited on the lock.
|
||||
val current = sessionManager.currentAccessToken()
|
||||
if (current != null && Http.bearer(current) != attemptedAuth) {
|
||||
return failed.retryWith(current)
|
||||
}
|
||||
|
||||
val refreshToken = sessionManager.currentRefreshToken()
|
||||
?: return null // already signed out
|
||||
|
||||
val refreshed = try {
|
||||
refreshApi.refresh(MobileRefreshRequest(refreshToken)).execute()
|
||||
} catch (_: IOException) {
|
||||
// Transient — surface the original 401 but keep the session.
|
||||
return null
|
||||
}
|
||||
|
||||
val body = refreshed.body()
|
||||
if (!refreshed.isSuccessful || body == null) {
|
||||
// The refresh token is dead (401/expired/revoked) → session is over.
|
||||
sessionManager.onSignedOut()
|
||||
return null
|
||||
}
|
||||
|
||||
sessionManager.onRefreshed(body.accessToken, body.refreshToken, body.user)
|
||||
return failed.retryWith(body.accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Request.retryWith(accessToken: String): Request =
|
||||
newBuilder().header(Http.AUTHORIZATION, Http.bearer(accessToken)).build()
|
||||
|
||||
private fun priorResponseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count++
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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" }
|
||||
}
|
||||
156
app/src/main/java/com/runicgateway/app/core/push/PushManager.kt
Normal file
156
app/src/main/java/com/runicgateway/app/core/push/PushManager.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
110
app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt
Normal file
110
app/src/main/java/com/runicgateway/app/core/push/PushNotifier.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.SerializationException
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
@@ -37,6 +38,16 @@ inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (th
|
||||
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
|
||||
* Coroutine cancellation is rethrown so structured concurrency still works — it
|
||||
* is control flow, not a network failure.
|
||||
*
|
||||
* A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g.
|
||||
* a live-shaped `guild.update` snapshot carrying an unexpected value) throws a
|
||||
* [SerializationException] out of the Retrofit converter. That is a broken contract
|
||||
* with the backend, not a bug to crash on: the request completed but the response is
|
||||
* unusable — an invalid upstream response — so it is surfaced as a server-side error
|
||||
* (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry",
|
||||
* exactly the graceful-degradation the layer promises (never throw for an expected
|
||||
* failure). Without this catch the exception escapes the collecting coroutine and
|
||||
* takes down the whole app.
|
||||
*/
|
||||
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
ApiResult.Ok(block())
|
||||
@@ -46,4 +57,9 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
ApiResult.HttpError(e.code(), e.message())
|
||||
} catch (e: IOException) {
|
||||
ApiResult.NetworkError(e)
|
||||
} catch (e: SerializationException) {
|
||||
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
|
||||
}
|
||||
|
||||
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
|
||||
private const val MALFORMED_RESPONSE_STATUS = 502
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.web
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
|
||||
/**
|
||||
* Opens the website's own pages in a Chrome Custom Tab (PLAN.md §4.2):
|
||||
* registration, invite acceptance, forgot/reset password, and SSO all stay
|
||||
* website-handled, so the app hands off rather than rebuilding those flows. The
|
||||
* user completes them in the browser and returns to sign in natively (§4.1).
|
||||
*/
|
||||
object WebHandoff {
|
||||
|
||||
/**
|
||||
* Launch [url] in a Custom Tab. Returns false if no browser could handle it
|
||||
* (extremely rare on Android) so the caller can surface a fallback.
|
||||
*/
|
||||
fun open(context: Context, url: String): Boolean = try {
|
||||
CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
.launchUrl(context, Uri.parse(url))
|
||||
true
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.web
|
||||
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Resolves the website's front-end page paths against the configured base URL,
|
||||
* for the Custom-Tab hand-offs (PLAN.md §4.2). These are the React SPA routes
|
||||
* (mirrored from `website/client` `App.jsx`), not API endpoints. Null before a
|
||||
* shard site is configured.
|
||||
*/
|
||||
@Singleton
|
||||
class WebsiteUrls @Inject constructor(
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
) {
|
||||
private fun resolve(path: String): String? =
|
||||
baseUrlHolder.current?.resolve(path)?.toString()
|
||||
|
||||
/** Create an account on the website. */
|
||||
fun register(): String? = resolve(REGISTER)
|
||||
|
||||
/** Forgot / reset password (the flow built on the backend before app work, §8). */
|
||||
fun forgotPassword(): String? = resolve(FORGOT)
|
||||
|
||||
private companion object {
|
||||
const val REGISTER = "account/register"
|
||||
const val FORGOT = "account/forgot"
|
||||
}
|
||||
}
|
||||
97
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
97
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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>
|
||||
}
|
||||
47
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
47
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.MeResponse
|
||||
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* The native bearer-auth surface (PLAN.md §4.1). Login and logout run on the main
|
||||
* OkHttp client; [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
||||
* access token to logout + `/auth/me`, and [com.runicgateway.app.core.net.TokenAuthenticator]
|
||||
* transparently refreshes on a `401`.
|
||||
*
|
||||
* Login is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER] so it
|
||||
* carries no bearer and a credential `401` (bad password / `totpRequired`) is not
|
||||
* misread as an expired session. It returns a raw [Response] so the caller can
|
||||
* inspect the status and parse the `{ totpRequired }` error body.
|
||||
*/
|
||||
interface AuthApi {
|
||||
|
||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
||||
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
|
||||
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
|
||||
// header entirely when it is null, so an untrusted device sends nothing.
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/login")
|
||||
suspend fun login(
|
||||
@Body body: MobileLoginRequest,
|
||||
@Header("X-Trust-Token") trustToken: String? = null,
|
||||
): Response<MobileTokenResponse>
|
||||
|
||||
@POST("api/v1/auth/mobile/logout")
|
||||
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||
|
||||
/** Current user — the app's authoritative role source, re-validated on resume (§4.3). */
|
||||
@GET("api/v1/auth/me")
|
||||
suspend fun me(): MeResponse
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* The token-rotation endpoint, isolated onto its own **bare** OkHttp client
|
||||
* (no auth interceptor, no authenticator) so refreshing can never recurse
|
||||
* through the very [com.runicgateway.app.core.net.TokenAuthenticator] that calls
|
||||
* it (PLAN.md §4.3). It is a blocking [Call] because the authenticator runs on an
|
||||
* OkHttp dispatcher thread, outside any coroutine, and executes it synchronously.
|
||||
*
|
||||
* Tagged `NO_SESSION` so it carries no stale bearer.
|
||||
*/
|
||||
interface AuthRefreshApi {
|
||||
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/refresh")
|
||||
fun refresh(@Body body: MobileRefreshRequest): Call<MobileTokenResponse>
|
||||
}
|
||||
89
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
89
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* The role-agnostic self-service surface (PLAN.md §6.3, §6.4): account, credential
|
||||
* changes, TOTP enrollment, and linked SSO identities under `/auth/me/account*`.
|
||||
* The app calls these regardless of role and never touches `/admin`. 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.
|
||||
*/
|
||||
interface MeApi {
|
||||
|
||||
@GET("api/v1/auth/me/account")
|
||||
suspend fun getAccount(): PlayerAccountDto
|
||||
|
||||
@PATCH("api/v1/auth/me/account/username")
|
||||
suspend fun changeUsername(@Body body: ChangeUsernameRequest): UsernameResponse
|
||||
|
||||
@PATCH("api/v1/auth/me/account/password")
|
||||
suspend fun changePassword(@Body body: ChangePasswordRequest): Unit
|
||||
|
||||
@POST("api/v1/auth/me/account/totp/setup")
|
||||
suspend fun totpSetup(): TotpSetupDto
|
||||
|
||||
@POST("api/v1/auth/me/account/totp/enable")
|
||||
suspend fun totpEnable(@Body body: TotpCodeRequest): TotpStateDto
|
||||
|
||||
@POST("api/v1/auth/me/account/totp/disable")
|
||||
suspend fun totpDisable(@Body body: TotpCodeRequest): TotpStateDto
|
||||
|
||||
@GET("api/v1/auth/me/account/identities")
|
||||
suspend fun identities(): List<LinkedIdentityDto>
|
||||
|
||||
// DELETE with no body — a plain @DELETE would suffice, but @HTTP keeps the
|
||||
// path template explicit alongside the provider argument.
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
|
||||
|
||||
@GET("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustedDevices(): List<TrustedDeviceDto>
|
||||
|
||||
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
|
||||
// which a thrown HttpException would discard.
|
||||
@POST("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices/{id}")
|
||||
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices")
|
||||
suspend fun revokeAllTrustedDevices(): RevokedCountDto
|
||||
|
||||
// ── Recovery (backup) codes ──────────────────────────────────────────────
|
||||
|
||||
@GET("api/v1/auth/me/account/recovery-codes/status")
|
||||
suspend fun recoveryCodesStatus(): RecoveryStatusDto
|
||||
|
||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
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
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* A player's own game data + game-account linking (PLAN.md §6.3), over the
|
||||
* bearer-gated `/player/shard/…` surface. Every read is ownership-checked
|
||||
* server-side; a `503` means the shard/sidecar is down → the UI renders "offline,
|
||||
* retry" (§7). All reads ride the main authed client (bearer + refresh-on-401).
|
||||
*/
|
||||
interface PlayerShardApi {
|
||||
|
||||
/** Confirm an in-game `[link` one-time code, tagging the game account to the user. */
|
||||
@POST("api/v1/player/shard/link")
|
||||
suspend fun link(@Body body: ShardLinkRequest): ShardLinkResultDto
|
||||
|
||||
/** Provision a game account (hybrid signup) and auto-link it to the caller. */
|
||||
@POST("api/v1/player/shard/account")
|
||||
suspend fun createAccount(@Body body: CreateGameAccountRequest): ShardLinkResultDto
|
||||
|
||||
/** The caller's linked game accounts. */
|
||||
@GET("api/v1/player/shard/accounts")
|
||||
suspend fun accounts(): List<ShardLinkDto>
|
||||
|
||||
/** Character roster for a linked account. */
|
||||
@GET("api/v1/player/shard/roster/{account}")
|
||||
suspend fun roster(@Path("account") account: String): RosterDto
|
||||
|
||||
/** A character sheet — only for a character on the caller's linked account. */
|
||||
@GET("api/v1/player/shard/char/{serial}")
|
||||
suspend fun char(@Path("serial") serial: String): CharProfileDto
|
||||
|
||||
/** Player vendors for a linked account. */
|
||||
@GET("api/v1/player/shard/vendors/{account}")
|
||||
suspend fun vendors(@Path("account") account: String): VendorSnapshotDto
|
||||
|
||||
/** Recent player-vendor sales across the caller's linked accounts. */
|
||||
@GET("api/v1/player/shard/sales")
|
||||
suspend fun sales(): List<VendorSaleDto>
|
||||
|
||||
/** The caller's own houses (home/decay status). */
|
||||
@GET("api/v1/player/shard/houses")
|
||||
suspend fun houses(): List<PlayerHouseDto>
|
||||
}
|
||||
@@ -3,11 +3,21 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
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
|
||||
@@ -24,8 +34,10 @@ import retrofit2.http.Url
|
||||
* The public (unauthenticated) surface consumed in M1: site status/settings,
|
||||
* news posts, CMS pages, wiki, and the contact form (PLAN.md §6.1). Paths are
|
||||
* relative to the sentinel base host; [com.runicgateway.app.core.net.HostSelectionInterceptor]
|
||||
* retargets them onto the configured shard site. Auth (§4) and the shard widgets
|
||||
* (§6.2) arrive in later milestones.
|
||||
* retargets them onto the configured shard site. The public shard widgets (§6.2)
|
||||
* are added in M2; auth (§4) and player game data (§6.3) arrive in later milestones.
|
||||
* The live SSE stream (`/public/shard/stream`) is not a Retrofit call — it is
|
||||
* consumed via OkHttp in [com.runicgateway.app.core.net.ShardStreamClient].
|
||||
*/
|
||||
interface PublicApi {
|
||||
|
||||
@@ -79,4 +91,41 @@ interface PublicApi {
|
||||
// ── Contact ──────────────────────────────────────────────────────────
|
||||
@POST("api/v1/public/contact")
|
||||
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||
|
||||
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||
@GET("api/v1/public/shard/status")
|
||||
suspend fun getShardStatus(): ShardStatusDto
|
||||
|
||||
@GET("api/v1/public/shard/feed")
|
||||
suspend fun getShardFeed(
|
||||
@Query("kind") kind: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): List<FeedEventDto>
|
||||
|
||||
@GET("api/v1/public/shard/economy")
|
||||
suspend fun getShardEconomy(@Query("limit") limit: Int? = null): List<EconomySampleDto>
|
||||
|
||||
@GET("api/v1/public/shard/online")
|
||||
suspend fun getShardOnline(): List<OnlineStaffDto>
|
||||
|
||||
@GET("api/v1/public/shard/presence")
|
||||
suspend fun getShardPresence(): PresenceDto
|
||||
|
||||
@GET("api/v1/public/shard/champs")
|
||||
suspend fun getShardChamps(): List<ChampDto>
|
||||
|
||||
@GET("api/v1/public/shard/guilds")
|
||||
suspend fun getShardGuilds(): List<GuildDto>
|
||||
|
||||
@GET("api/v1/public/shard/governors")
|
||||
suspend fun getShardGovernors(): List<GovernorDto>
|
||||
|
||||
@GET("api/v1/public/shard/governors/{city}/history")
|
||||
suspend fun getShardGovernorHistory(
|
||||
@Path("city") city: String,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): List<GovernorTermDto>
|
||||
|
||||
@GET("api/v1/public/shard/houses")
|
||||
suspend fun getShardHouses(): List<HouseDto>
|
||||
}
|
||||
|
||||
40
app/src/main/java/com/runicgateway/app/data/api/SsoApi.kt
Normal file
40
app/src/main/java/com/runicgateway/app/data/api/SsoApi.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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>
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The role-agnostic self-service ("me") wire shapes (PLAN.md §6.3, §6.4). Field
|
||||
* names match the backend's `account.controller` handlers exactly, surfaced for
|
||||
* the app under `/auth/me/account*`. Every DTO ignores unknown keys (NetworkModule's
|
||||
* lenient Json), so additive backend fields are safe (recorded for M1).
|
||||
*/
|
||||
|
||||
/** `GET /auth/me/account` — the current account (any role). */
|
||||
@Serializable
|
||||
data class PlayerAccountDto(
|
||||
val id: Long = 0,
|
||||
val username: String = "",
|
||||
val role: String = "",
|
||||
val email: String? = null,
|
||||
val status: String? = null,
|
||||
val totp_enabled: Boolean = false,
|
||||
/** False for an SSO-provisioned account that has not set a password yet. */
|
||||
val has_password: Boolean = false,
|
||||
)
|
||||
|
||||
/** `PATCH /auth/me/account/username` body. */
|
||||
@Serializable
|
||||
data class ChangeUsernameRequest(val username: String)
|
||||
|
||||
/** The `{ username }` returned by a successful username change. */
|
||||
@Serializable
|
||||
data class UsernameResponse(val username: String = "")
|
||||
|
||||
/**
|
||||
* `PATCH /auth/me/account/password` body. [currentPassword] is omitted only for an
|
||||
* SSO-provisioned account setting its initial password (has_password == false).
|
||||
*/
|
||||
@Serializable
|
||||
data class ChangePasswordRequest(
|
||||
val newPassword: String,
|
||||
val currentPassword: String? = null,
|
||||
)
|
||||
|
||||
/** Enrollment material from `POST /auth/me/account/totp/setup`. */
|
||||
@Serializable
|
||||
data class TotpSetupDto(
|
||||
val otpauthUrl: String? = null,
|
||||
/** QR code as a `data:image/png;base64,…` URL. */
|
||||
val qr: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/me/account/totp/enable|disable` body — a current authenticator code. */
|
||||
@Serializable
|
||||
data class TotpCodeRequest(val code: String)
|
||||
|
||||
/**
|
||||
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
|
||||
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
|
||||
* app shows them for the user to save and never persists them.
|
||||
*/
|
||||
@Serializable
|
||||
data class TotpStateDto(
|
||||
val totp_enabled: Boolean = false,
|
||||
val recoveryCodes: List<String>? = null,
|
||||
)
|
||||
|
||||
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
|
||||
|
||||
/**
|
||||
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
|
||||
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
|
||||
* strings shown as-is (advisory display).
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceDto(
|
||||
val id: Long = 0,
|
||||
val platform: String? = null,
|
||||
val deviceName: String? = null,
|
||||
val userAgent: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
|
||||
@Serializable
|
||||
data class TrustDeviceRequest(val deviceName: String? = null)
|
||||
|
||||
/**
|
||||
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
|
||||
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustDeviceResultDto(
|
||||
val trusted: Boolean = false,
|
||||
val trustToken: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
|
||||
* the app lists [devices] and asks the user to revoke one, then retry.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceLimitDto(
|
||||
val error: String? = null,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
|
||||
@Serializable
|
||||
data class RevokedFlagDto(val revoked: Boolean = false)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
|
||||
@Serializable
|
||||
data class RevokedCountDto(val revoked: Int = 0)
|
||||
|
||||
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||
@Serializable
|
||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||
|
||||
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
|
||||
@Serializable
|
||||
data class RecoveryGenerateRequest(val currentPassword: String? = null)
|
||||
|
||||
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
|
||||
@Serializable
|
||||
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
|
||||
|
||||
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||
@Serializable
|
||||
data class LinkedIdentityDto(
|
||||
val provider: String = "",
|
||||
val email: String? = null,
|
||||
val linked_at: String? = null,
|
||||
)
|
||||
179
app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt
Normal file
179
app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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)
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The mobile bearer-auth wire shapes (PLAN.md §4.1). Field names match the
|
||||
* backend's `auth/mobile` controller and `/auth/me` exactly; every DTO ignores
|
||||
* unknown keys (NetworkModule's lenient Json), so additive backend fields are
|
||||
* safe (§8, recorded for M1).
|
||||
*/
|
||||
|
||||
/**
|
||||
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
|
||||
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
|
||||
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
|
||||
* future logins skip the second factor — on success the response carries a
|
||||
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
|
||||
* [device_name] labels the resulting trusted-device / session row (snake_case to
|
||||
* match the backend field exactly).
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: String? = null,
|
||||
val recoveryCode: String? = null,
|
||||
val trustDevice: Boolean? = null,
|
||||
val device_name: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/mobile/refresh` body. */
|
||||
@Serializable
|
||||
data class MobileRefreshRequest(val refreshToken: String)
|
||||
|
||||
/** `POST /auth/mobile/logout` body — revoke this session or (with [all]) every session. */
|
||||
@Serializable
|
||||
data class MobileLogoutRequest(
|
||||
val refreshToken: String? = null,
|
||||
val all: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Success payload from login and refresh: the token pair, the access lifetime
|
||||
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
|
||||
*
|
||||
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
|
||||
* [trustToken] is the opaque token to persist + replay (present only when the trust
|
||||
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
|
||||
* (the login itself still succeeded). Refresh never sets these.
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileTokenResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: String? = null,
|
||||
val user: SafeUserDto,
|
||||
val trustToken: String? = null,
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||
@Serializable
|
||||
data class SafeUserDto(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
/** `GET /auth/me` envelope — the role source, re-validated on resume (§4.3). */
|
||||
@Serializable
|
||||
data class MeResponse(val user: SafeUserDto)
|
||||
|
||||
/**
|
||||
* The `401 { totpRequired: true }` body the single-request 2FA flow returns when
|
||||
* an account has TOTP on and no/invalid code accompanied the login (§4.1). Parsed
|
||||
* from the error body since it is not a 2xx response.
|
||||
*/
|
||||
@Serializable
|
||||
data class TotpRequiredError(
|
||||
val totpRequired: Boolean = false,
|
||||
val message: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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>,
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* DTOs for a player's OWN game data (PLAN.md §6.3), read over the bearer-gated
|
||||
* `/player/shard/…` surface. The roster / char / vendor reads return the sidecar
|
||||
* payload verbatim (a permissive object), so only the fields the app renders are
|
||||
* modeled — unknown keys are ignored by the JSON parser (matching the website's
|
||||
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||
*
|
||||
* In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on
|
||||
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
|
||||
*/
|
||||
|
||||
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||
|
||||
/** `GET /player/shard/accounts` — a linked in-game account. */
|
||||
@Serializable
|
||||
data class ShardLinkDto(
|
||||
val account: String = "",
|
||||
val userId: Long? = null,
|
||||
val charName: String? = null,
|
||||
val linkedAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /player/shard/link` body — the one-time code shown by `[link` in game. */
|
||||
@Serializable
|
||||
data class ShardLinkRequest(val code: String)
|
||||
|
||||
/** `POST /player/shard/link` result — the confirmed link. */
|
||||
@Serializable
|
||||
data class ShardLinkResultDto(
|
||||
val linked: Boolean = false,
|
||||
val account: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /player/shard/account` (hybrid signup) body. */
|
||||
@Serializable
|
||||
data class CreateGameAccountRequest(
|
||||
val account: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
// ── Character roster + sheet ─────────────────────────────────────────────────
|
||||
|
||||
/** `GET /player/shard/roster/:account` — the account's characters (incl. offline). */
|
||||
@Serializable
|
||||
data class RosterDto(
|
||||
val acct: String? = null,
|
||||
val chars: List<RosterCharDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** One character in a roster; the picker fetches the full sheet on demand. */
|
||||
@Serializable
|
||||
data class RosterCharDto(
|
||||
val slot: Int? = null,
|
||||
val serial: String = "",
|
||||
val name: String? = null,
|
||||
val body: Int? = null,
|
||||
val online: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /player/shard/char/:serial` — a character sheet. `guild` / `governorOf` are
|
||||
* best-effort cross-links the backend decorates in (never fail the sheet).
|
||||
*/
|
||||
@Serializable
|
||||
data class CharProfileDto(
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val title: String? = null,
|
||||
val online: Boolean = false,
|
||||
val acct: String? = null,
|
||||
val stats: CharStatsDto? = null,
|
||||
val skills: List<SkillDto> = emptyList(),
|
||||
val equipment: List<EquipmentDto> = emptyList(),
|
||||
val titles: TitlesDto? = null,
|
||||
val guild: GuildRefDto? = null,
|
||||
val governorOf: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharStatsDto(
|
||||
val str: Int? = null,
|
||||
val dex: Int? = null,
|
||||
val int: Int? = null,
|
||||
val hits: Int? = null,
|
||||
val hitsMax: Int? = null,
|
||||
val mana: Int? = null,
|
||||
val manaMax: Int? = null,
|
||||
val stam: Int? = null,
|
||||
val stamMax: Int? = null,
|
||||
val resist: ResistDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResistDto(
|
||||
val phys: Int? = null,
|
||||
val fire: Int? = null,
|
||||
val cold: Int? = null,
|
||||
val pois: Int? = null,
|
||||
val energy: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A skill line. `base` is the trained value, `value` includes item/temp bonuses,
|
||||
* `cap` is the cap — do NOT assume base ≤ cap (GM chars exceed it). Doubles, as the
|
||||
* shard reports tenths.
|
||||
*/
|
||||
@Serializable
|
||||
data class SkillDto(
|
||||
val n: String? = null,
|
||||
val base: Double? = null,
|
||||
val value: Double? = null,
|
||||
val cap: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* An equipped item. Names are usually clilocs (numeric), not strings, and the app
|
||||
* ships no cliloc table, so the text-only sheet renders layer + id + hue + mods.
|
||||
* [mods] is a flattened map of non-zero AOS attributes (empty for plain items);
|
||||
* kept as a raw object since values may be numbers or strings.
|
||||
*/
|
||||
@Serializable
|
||||
data class EquipmentDto(
|
||||
val serial: String? = null,
|
||||
val layer: String? = null,
|
||||
val itemId: Int? = null,
|
||||
val hue: Int? = null,
|
||||
val mods: JsonObject? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
||||
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
||||
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
||||
*/
|
||||
@Serializable
|
||||
data class TitlesDto(
|
||||
val selected: Int? = null,
|
||||
val reward: List<String> = emptyList(),
|
||||
val fameKarma: String? = null,
|
||||
val skill: String? = null,
|
||||
)
|
||||
|
||||
/** The guild a character leads (cross-linked from board data). */
|
||||
@Serializable
|
||||
data class GuildRefDto(
|
||||
val name: String? = null,
|
||||
val abbr: String? = null,
|
||||
)
|
||||
|
||||
// ── Player vendors + sales ───────────────────────────────────────────────────
|
||||
|
||||
/** `GET /player/shard/vendors/:account` — every player vendor on the account. */
|
||||
@Serializable
|
||||
data class VendorSnapshotDto(
|
||||
val acct: String? = null,
|
||||
val vendors: List<VendorDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VendorDto(
|
||||
val serial: String? = null,
|
||||
val shopName: String? = null,
|
||||
val holdGold: Long? = null,
|
||||
val ownerSerial: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val listings: List<VendorListingDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** A single vendor listing. Item names are clilocs (see [EquipmentDto]); text-only. */
|
||||
@Serializable
|
||||
data class VendorListingDto(
|
||||
val serial: String? = null,
|
||||
val itemId: Int? = null,
|
||||
val amount: Int? = null,
|
||||
val price: Long? = null,
|
||||
val forSale: Boolean = false,
|
||||
)
|
||||
|
||||
/** `GET /player/shard/sales` — a player-vendor sale, visible only to the owner. */
|
||||
@Serializable
|
||||
data class VendorSaleDto(
|
||||
/** Sale time, epoch ms. */
|
||||
val t: Long? = null,
|
||||
val itemType: String? = null,
|
||||
val amount: Int? = null,
|
||||
val price: Long? = null,
|
||||
val commission: Int? = null,
|
||||
val ownerAcct: String? = null,
|
||||
)
|
||||
|
||||
// ── Player houses ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `GET /player/shard/houses` — the caller's OWN houses, with full decay/IDOC
|
||||
* detail (their own property). Serial is a hex string here.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerHouseDto(
|
||||
val serial: String = "",
|
||||
val stage: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val region: String? = null,
|
||||
val name: String? = null,
|
||||
val ownerSerial: String? = null,
|
||||
val ownerAcct: String? = null,
|
||||
val builtOn: String? = null,
|
||||
val lastRefreshed: String? = null,
|
||||
val isIdoc: Boolean = false,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
@@ -55,6 +55,17 @@ data class RegistrationFlagsDto(
|
||||
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
|
||||
* app consumes are modeled; other whitelisted keys are ignored.
|
||||
@@ -67,4 +78,6 @@ data class SettingsDto(
|
||||
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
|
||||
val gameAccountSignup: Boolean = false,
|
||||
val brand: BrandDto = BrandDto(),
|
||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
||||
val push: PushConfigDto = PushConfigDto(),
|
||||
)
|
||||
|
||||
174
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
174
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* DTOs for the public shard widgets (PLAN.md §6.2). Shapes mirror the website's
|
||||
* `public/shard.controller.js` responses and the live SSE frames emitted by
|
||||
* `utils/shardBroadcast.js`. The champ/guild/governor board reads return the
|
||||
* stored event payload verbatim (a permissive object), so only the fields the app
|
||||
* renders are modeled; unknown keys are ignored by the JSON parser, and the live
|
||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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
|
||||
data class ActorDto(
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val acct: String? = null,
|
||||
val webId: String? = null,
|
||||
) {
|
||||
/** Best display label for this actor. */
|
||||
val label: String get() = name ?: acct ?: "Someone"
|
||||
}
|
||||
|
||||
/** A gold-supply sample (`economy.supply`), oldest → newest in the series. */
|
||||
@Serializable
|
||||
data class EconomySampleDto(
|
||||
val accounts: Int? = null,
|
||||
val gold: Double? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/shard/status` — connection state + online count + latest economy. */
|
||||
@Serializable
|
||||
data class ShardStatusDto(
|
||||
val enabled: Boolean = false,
|
||||
/** Sidecar link state: `connected` / `disconnected` / … */
|
||||
val status: String? = null,
|
||||
/** Whether the in-game plugin is currently connected to the sidecar. */
|
||||
val pluginConnected: Boolean = false,
|
||||
/** ISO timestamp of the last ingested event, or null. */
|
||||
val lastEventAt: String? = null,
|
||||
val onlineCount: Int = 0,
|
||||
val economy: EconomySampleDto? = null,
|
||||
) {
|
||||
/** True when the shard is live (link enabled and the plugin is connected). */
|
||||
val isOnline: Boolean get() = enabled && pluginConnected
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /public/shard/feed` — a stored notable event. The domain fields live under
|
||||
* [payload]; the live SSE frames carry those same fields at the top level (see
|
||||
* `ShardEventText`).
|
||||
*/
|
||||
@Serializable
|
||||
data class FeedEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "",
|
||||
val t: Long? = null,
|
||||
val bootId: String? = null,
|
||||
val payload: JsonObject? = null,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/shard/online` — a staff member currently in-world. Location is only
|
||||
* present for privileged viewers server-side; anonymous/app callers see name+serial.
|
||||
*/
|
||||
@Serializable
|
||||
data class OnlineStaffDto(
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A house on the public IDOC board (`GET /public/shard/houses`) — location only.
|
||||
* Owner/price/decay detail is staff-only and never reaches the app.
|
||||
*/
|
||||
@Serializable
|
||||
data class HouseDto(
|
||||
val serial: String = "",
|
||||
val name: String? = null,
|
||||
val region: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val isIdoc: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* A champion-spawn board entry (`GET /public/shard/champs` + live `champ.update`).
|
||||
* Three families share the board (`category`: champion / mini / sea); the
|
||||
* category-specific fields are all nullable.
|
||||
*/
|
||||
@Serializable
|
||||
data class ChampDto(
|
||||
val serial: String = "",
|
||||
val category: String? = null,
|
||||
val type: String? = null,
|
||||
val name: String? = null,
|
||||
val status: String? = null,
|
||||
val active: Boolean = false,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val bossUp: Boolean = false,
|
||||
val boss: String? = null,
|
||||
val level: Int? = null,
|
||||
val maxLevel: Int? = null,
|
||||
val kills: Int? = null,
|
||||
val maxKills: Int? = null,
|
||||
val hits: Long? = null,
|
||||
val hitsMax: Long? = null,
|
||||
val restartAt: String? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** A guild board entry (`GET /public/shard/guilds` + live `guild.update`). */
|
||||
@Serializable
|
||||
data class GuildDto(
|
||||
val id: Long = 0,
|
||||
val name: String? = null,
|
||||
val abbr: String? = null,
|
||||
val members: Int? = null,
|
||||
val online: Int? = null,
|
||||
val alliance: String? = null,
|
||||
val leader: ActorDto? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** A town-governor board entry (`GET /public/shard/governors` + live `city.update`). */
|
||||
@Serializable
|
||||
data class GovernorDto(
|
||||
val city: String = "",
|
||||
val governor: ActorDto? = null,
|
||||
val governorElect: ActorDto? = null,
|
||||
val electionPhase: String? = null,
|
||||
val t: Long? = null,
|
||||
)
|
||||
|
||||
/** One term in a city's governor ledger (`GET /public/shard/governors/:city/history`). */
|
||||
@Serializable
|
||||
data class GovernorTermDto(
|
||||
val city: String? = null,
|
||||
val governor: ActorDto? = null,
|
||||
val startedAt: Long? = null,
|
||||
val endedAt: Long? = null,
|
||||
val votes: Int? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/shard/presence` — the online-population aggregate (live `presence.online`). */
|
||||
@Serializable
|
||||
data class PresenceDto(
|
||||
val count: Int = 0,
|
||||
val byFacet: Map<String, Int> = emptyMap(),
|
||||
val byRegion: Map<String, Int> = emptyMap(),
|
||||
val t: Long? = null,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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,
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.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.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Self-service account management over the role-agnostic `/auth/me/account*`
|
||||
* surface (PLAN.md §6.3, §6.4). Every call returns a typed [ApiResult] so the
|
||||
* screens can map known statuses (409 taken, 400 wrong password / invalid code,
|
||||
* 429 rate-limited) to friendly copy without a repository ever throwing (§7).
|
||||
*/
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val api: MeApi,
|
||||
private val json: Json,
|
||||
) {
|
||||
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||
|
||||
suspend fun changeUsername(username: String): ApiResult<UsernameResponse> =
|
||||
safeApiCall { api.changeUsername(ChangeUsernameRequest(username)) }
|
||||
|
||||
/** [currentPassword] is null only for an SSO account setting its first password. */
|
||||
suspend fun changePassword(newPassword: String, currentPassword: String?): ApiResult<Unit> =
|
||||
safeApiCall { api.changePassword(ChangePasswordRequest(newPassword, currentPassword)) }
|
||||
|
||||
suspend fun totpSetup(): ApiResult<TotpSetupDto> = safeApiCall { api.totpSetup() }
|
||||
|
||||
suspend fun totpEnable(code: String): ApiResult<TotpStateDto> =
|
||||
safeApiCall { api.totpEnable(TotpCodeRequest(code)) }
|
||||
|
||||
suspend fun totpDisable(code: String): ApiResult<TotpStateDto> =
|
||||
safeApiCall { api.totpDisable(TotpCodeRequest(code)) }
|
||||
|
||||
suspend fun identities(): ApiResult<List<LinkedIdentityDto>> = safeApiCall { api.identities() }
|
||||
|
||||
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||
safeApiCall { api.unlinkIdentity(provider) }
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
|
||||
|
||||
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
|
||||
safeApiCall { api.trustedDevices() }
|
||||
|
||||
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
|
||||
sealed interface TrustOutcome {
|
||||
/** Trusted; [trustToken] is the opaque token to persist (native). */
|
||||
data class Trusted(val trustToken: String?) : TrustOutcome
|
||||
|
||||
/** At the per-user cap — [devices] must be pruned before retrying. */
|
||||
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
|
||||
data object NetworkError : TrustOutcome
|
||||
data object ServerError : TrustOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust the current device. Reads the raw response so the `409 { error, devices }`
|
||||
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
|
||||
*/
|
||||
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
|
||||
val response = try {
|
||||
api.trustThisDevice(TrustDeviceRequest(deviceName))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return TrustOutcome.NetworkError
|
||||
} catch (_: Exception) {
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
return TrustOutcome.Trusted(response.body()?.trustToken)
|
||||
}
|
||||
if (response.code() == 409) {
|
||||
val devices = runCatching {
|
||||
val raw = response.errorBody()?.string()
|
||||
if (raw.isNullOrBlank()) emptyList()
|
||||
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
|
||||
}.getOrDefault(emptyList())
|
||||
return TrustOutcome.LimitReached(devices)
|
||||
}
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
|
||||
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
|
||||
safeApiCall { api.revokeTrustedDevice(id).revoked }
|
||||
|
||||
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
|
||||
safeApiCall { api.revokeAllTrustedDevices().revoked }
|
||||
|
||||
// ── Recovery (backup) codes ────────────────────────────────────────────
|
||||
|
||||
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
|
||||
safeApiCall { api.recoveryCodesStatus() }
|
||||
|
||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
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.MobileLogoutRequest
|
||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Native username/password (+TOTP) auth — the app's only native credential flow
|
||||
* (PLAN.md §4.1). It drives the [SessionManager]: a successful login establishes
|
||||
* the session; logout revokes it. Registration/invite/reset/SSO are website
|
||||
* hand-offs (§4.2), not here.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthRepository @Inject constructor(
|
||||
private val authApi: AuthApi,
|
||||
private val ssoApi: SsoApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
/** The three outcomes of SSO provider discovery, so the login screen can tell a
|
||||
* shard that offers no SSO ([None]) apart from a discovery that failed
|
||||
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
|
||||
* a broken call behind a dead website hand-off (§4.2). */
|
||||
sealed interface SsoDiscovery {
|
||||
/** At least one enabled provider — render a native button per entry. */
|
||||
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
|
||||
|
||||
/** Discovery succeeded but the shard has no SSO providers configured. */
|
||||
data object None : SsoDiscovery
|
||||
|
||||
/** The discovery call failed (offline / server error) — surface a retry. */
|
||||
data object Unavailable : SsoDiscovery
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the shard's enabled SSO providers for the native login buttons (§4.2).
|
||||
* Public discovery, never secrets. Retries once before reporting [Unavailable],
|
||||
* so a single transient blip doesn't strand the user.
|
||||
*/
|
||||
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). */
|
||||
sealed interface LoginResult {
|
||||
/**
|
||||
* Signed in. [trustLimitReached] is true when "trust this device" was asked
|
||||
* for but the per-user cap blocked it (the login still succeeded, but no trust
|
||||
* token was issued); [devices] then lists the trusted devices to manage.
|
||||
*/
|
||||
data class Success(
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
) : LoginResult
|
||||
|
||||
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||
data object TotpRequired : LoginResult
|
||||
data object InvalidCredentials : LoginResult
|
||||
|
||||
/** Guarded by per-IP backoff → slowdown → hard cap; back off and retry. */
|
||||
data object RateLimited : LoginResult
|
||||
|
||||
/** Any other server failure (5xx / unexpected). */
|
||||
data object ServerError : LoginResult
|
||||
|
||||
/** No answer — offline, DNS, TLS, timeout. */
|
||||
data object NetworkError : LoginResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
|
||||
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
|
||||
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
|
||||
* [trustDevice], the server may return a fresh trust token to persist for next time.
|
||||
*/
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
code: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
trustDevice: Boolean = false,
|
||||
): LoginResult {
|
||||
val storedTrustToken = trustTokenStore.tokenFor(username)
|
||||
val response: Response<MobileTokenResponse> = try {
|
||||
authApi.login(
|
||||
MobileLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = trustDevice.takeIf { it },
|
||||
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
|
||||
),
|
||||
trustToken = storedTrustToken,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return LoginResult.NetworkError
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return LoginResult.ServerError
|
||||
// Persist a freshly minted trust token (scoped to this account) so the next
|
||||
// login skips the second factor — it deliberately outlives logout.
|
||||
body.trustToken?.let { trustTokenStore.save(username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
return LoginResult.Success(
|
||||
trustLimitReached = body.trustLimitReached,
|
||||
devices = body.devices,
|
||||
)
|
||||
}
|
||||
|
||||
return when (response.code()) {
|
||||
401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials
|
||||
429 -> LoginResult.RateLimited
|
||||
else -> LoginResult.ServerError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a trust token minted by the self-service "trust this device" action
|
||||
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
|
||||
*/
|
||||
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
|
||||
|
||||
/**
|
||||
* Drop the locally stored trust token so this device stops skipping the TOTP step
|
||||
* (used after "untrust all" and on a Settings → Server switch). Server-side
|
||||
* revocation makes any surviving token inert anyway — the next login just prompts
|
||||
* for the code — so this is a client-side cleanliness step, never load-bearing.
|
||||
*/
|
||||
fun clearTrustToken() = trustTokenStore.clear()
|
||||
|
||||
/**
|
||||
* Revoke this session (or, with [allDevices], every session) and clear local
|
||||
* tokens (§4.3). Best-effort: the local session is torn down even if the
|
||||
* network call fails, so the user is always signed out locally.
|
||||
*/
|
||||
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()
|
||||
try {
|
||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Ignore — we still drop the local session below.
|
||||
}
|
||||
sessionManager.onSignedOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
|
||||
* success refreshes the cached role (roles change server-side); a `401` that
|
||||
* survives the silent refresh means the session is dead → sign out. Transient
|
||||
* failures are ignored so a flaky network doesn't bounce the user.
|
||||
*/
|
||||
suspend fun revalidate() {
|
||||
if (!sessionManager.isSignedIn) return
|
||||
try {
|
||||
val me = authApi.me()
|
||||
sessionManager.onUserRefreshed(me.user)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: retrofit2.HttpException) {
|
||||
if (e.code() == 401) sessionManager.onSignedOut()
|
||||
} catch (_: IOException) {
|
||||
// Offline — keep the session; the next authed call will re-check.
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTotpRequired(response: Response<*>): Boolean = try {
|
||||
val raw = response.errorBody()?.string()
|
||||
!raw.isNullOrBlank() && json.decodeFromString<TotpRequiredError>(raw).totpRequired
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DISCOVERY_RETRY_DELAY_MS = 400L
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.ServerUrl
|
||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||
@@ -24,6 +26,9 @@ class ConnectionRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
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,
|
||||
) {
|
||||
|
||||
@@ -34,6 +39,13 @@ class ConnectionRepository @Inject constructor(
|
||||
|
||||
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
|
||||
data object NotRunicGateway : ProbeResult
|
||||
|
||||
/**
|
||||
* A Runic Gateway backend, but speaking an API version this app build does
|
||||
* not support (§3 version guard) — refuse rather than mis-render. [serverApi]
|
||||
* is what the site reported; [supportedApi] is what this app speaks.
|
||||
*/
|
||||
data class VersionMismatch(val serverApi: String, val supportedApi: String) : ProbeResult
|
||||
data class ServerError(val status: Int) : ProbeResult
|
||||
data class Unreachable(val cause: Throwable) : ProbeResult
|
||||
}
|
||||
@@ -66,14 +78,15 @@ class ConnectionRepository @Inject constructor(
|
||||
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
||||
|
||||
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
||||
is ApiResult.Ok -> {
|
||||
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
||||
ProbeResult.NotRunicGateway
|
||||
} else {
|
||||
is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
|
||||
is VersionVerdict.Ok -> {
|
||||
prefs.setBaseUrl(normalized.toString())
|
||||
baseUrlHolder.set(normalized)
|
||||
ProbeResult.Success(result.data)
|
||||
}
|
||||
is VersionVerdict.NotRunicGateway -> ProbeResult.NotRunicGateway
|
||||
is VersionVerdict.Mismatch ->
|
||||
ProbeResult.VersionMismatch(serverApi = verdict.serverApi, supportedApi = SUPPORTED_API)
|
||||
}
|
||||
is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
|
||||
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
|
||||
@@ -81,15 +94,59 @@ class ConnectionRepository @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset for a Settings → Server switch (§3): clear the saved URL and
|
||||
* deactivate it. Token/cache clearing joins here in M3 once sessions exist.
|
||||
* Hard reset for a Settings → Server switch (§3): sign out (clear stored
|
||||
* tokens), clear the saved URL, and deactivate it — the app returns to a
|
||||
* signed-out state against the new host.
|
||||
*/
|
||||
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()
|
||||
// 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()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The pure outcome of inspecting a probed site's [VersionDto] (§3 version guard). */
|
||||
internal sealed interface VersionVerdict {
|
||||
data object Ok : VersionVerdict
|
||||
data object NotRunicGateway : VersionVerdict
|
||||
data class Mismatch(val serverApi: String) : VersionVerdict
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
const val RUNIC_SERVICE_ID = "runic-gateway"
|
||||
|
||||
/** The backend API major version this app build speaks (matches `/public/version` `api`). */
|
||||
const val SUPPORTED_API = "v1"
|
||||
|
||||
/**
|
||||
* Decide whether a probed site is a Runic Gateway backend this app can talk
|
||||
* to. Pure (no I/O) so it is unit-testable without a live site. Lenient on a
|
||||
* blank `api` (an older backend that predates version surfacing); refuses only
|
||||
* an API version we positively know we can't parse (e.g. a future `v2`).
|
||||
*/
|
||||
fun evaluateVersion(
|
||||
version: com.runicgateway.app.data.api.dto.VersionDto,
|
||||
supportedApi: String = SUPPORTED_API,
|
||||
): VersionVerdict {
|
||||
if (!version.service.trim().equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
||||
return VersionVerdict.NotRunicGateway
|
||||
}
|
||||
val serverApi = version.api.trim()
|
||||
return when {
|
||||
serverApi.isEmpty() -> VersionVerdict.Ok
|
||||
serverApi.equals(supportedApi, ignoreCase = true) -> VersionVerdict.Ok
|
||||
else -> VersionVerdict.Mismatch(serverApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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)) }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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.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
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* A player's own game data + game-account linking (PLAN.md §6.3), over the
|
||||
* bearer-gated `/player/shard/…` surface. Every read returns a typed [ApiResult]
|
||||
* so a down shard (`503`) renders as "offline, retry" and a not-linked account
|
||||
* (`403`) is handled cleanly — the repository never throws for an expected
|
||||
* failure (§7). Ownership is enforced server-side.
|
||||
*/
|
||||
@Singleton
|
||||
class PlayerShardRepository @Inject constructor(
|
||||
private val api: PlayerShardApi,
|
||||
) {
|
||||
suspend fun link(code: String): ApiResult<ShardLinkResultDto> =
|
||||
safeApiCall { api.link(ShardLinkRequest(code)) }
|
||||
|
||||
suspend fun createAccount(account: String, password: String): ApiResult<ShardLinkResultDto> =
|
||||
safeApiCall { api.createAccount(CreateGameAccountRequest(account, password)) }
|
||||
|
||||
suspend fun accounts(): ApiResult<List<ShardLinkDto>> = safeApiCall { api.accounts() }
|
||||
|
||||
suspend fun roster(account: String): ApiResult<RosterDto> = safeApiCall { api.roster(account) }
|
||||
|
||||
suspend fun char(serial: String): ApiResult<CharProfileDto> = safeApiCall { api.char(serial) }
|
||||
|
||||
suspend fun vendors(account: String): ApiResult<VendorSnapshotDto> =
|
||||
safeApiCall { api.vendors(account) }
|
||||
|
||||
suspend fun sales(): ApiResult<List<VendorSaleDto>> = safeApiCall { api.sales() }
|
||||
|
||||
suspend fun houses(): ApiResult<List<PlayerHouseDto>> = safeApiCall { api.houses() }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
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.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The public shard widgets (PLAN.md §6.2): point-in-time board snapshots over
|
||||
* the `/public/shard/…` GETs plus the live SSE stream. Every read returns a typed
|
||||
* [ApiResult] so a down shard renders as offline (§7); [liveEvents] is the shared
|
||||
* SSE feed the boards merge in place. Live `*.update` frames decode into the same
|
||||
* DTOs as the snapshot reads via the `*Frame` decoders.
|
||||
*/
|
||||
@Singleton
|
||||
class ShardRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val stream: ShardStream,
|
||||
private val json: Json,
|
||||
) {
|
||||
// ── Snapshots ────────────────────────────────────────────────────────
|
||||
suspend fun status(): ApiResult<ShardStatusDto> = safeApiCall { api.getShardStatus() }
|
||||
|
||||
suspend fun feed(limit: Int = 40): ApiResult<List<FeedEventDto>> =
|
||||
safeApiCall { api.getShardFeed(limit = limit) }
|
||||
|
||||
suspend fun economy(limit: Int = 100): ApiResult<List<EconomySampleDto>> =
|
||||
safeApiCall { api.getShardEconomy(limit) }
|
||||
|
||||
suspend fun online(): ApiResult<List<OnlineStaffDto>> = safeApiCall { api.getShardOnline() }
|
||||
|
||||
suspend fun presence(): ApiResult<PresenceDto> = safeApiCall { api.getShardPresence() }
|
||||
|
||||
suspend fun champs(): ApiResult<List<ChampDto>> = safeApiCall { api.getShardChamps() }
|
||||
|
||||
suspend fun guilds(): ApiResult<List<GuildDto>> = safeApiCall { api.getShardGuilds() }
|
||||
|
||||
suspend fun governors(): ApiResult<List<GovernorDto>> = safeApiCall { api.getShardGovernors() }
|
||||
|
||||
suspend fun governorHistory(city: String, limit: Int = 25): ApiResult<List<GovernorTermDto>> =
|
||||
safeApiCall { api.getShardGovernorHistory(city, limit) }
|
||||
|
||||
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
|
||||
|
||||
// ── Live stream ──────────────────────────────────────────────────────
|
||||
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||
|
||||
// Decode a live `*.update` frame into the board DTO it mirrors; null on shape
|
||||
// mismatch so a malformed frame is skipped rather than crashing the board.
|
||||
fun champFrame(obj: JsonObject): ChampDto? = decode(obj, ChampDto.serializer())
|
||||
fun guildFrame(obj: JsonObject): GuildDto? = decode(obj, GuildDto.serializer())
|
||||
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
|
||||
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
|
||||
|
||||
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||
json.decodeFromJsonElement(serializer, obj)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,21 @@ package com.runicgateway.app.di
|
||||
import android.os.Build
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.net.AuthInterceptor
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.TokenAuthenticator
|
||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.NotificationsApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -42,16 +53,25 @@ object NetworkModule {
|
||||
return UserAgentInterceptor(ua)
|
||||
}
|
||||
|
||||
/**
|
||||
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
||||
* retarget onto the configured shard host, then attach the bearer; the
|
||||
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||
* so it observes the final, authed request.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||
userAgentInterceptor: UserAgentInterceptor,
|
||||
authInterceptor: AuthInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
// User-Agent first, then host retargeting, so both apply to every call.
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
builder.addInterceptor(
|
||||
@@ -75,4 +95,66 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||
|
||||
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
|
||||
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideShardStream(client: ShardStreamClient): ShardStream = client
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
|
||||
/** 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. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
|
||||
|
||||
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
||||
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,
|
||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||
* back through [TokenAuthenticator] (§4.3). This throwaway client/Retrofit is
|
||||
* not exposed as a bean, so there is no ambiguous [OkHttpClient] binding.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRefreshApi(
|
||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||
userAgentInterceptor: UserAgentInterceptor,
|
||||
json: Json,
|
||||
): AuthRefreshApi {
|
||||
val bareClient = OkHttpClient.Builder()
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.build()
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
|
||||
.client(bareClient)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
return retrofit.create(AuthRefreshApi::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
41
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
41
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.di
|
||||
|
||||
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
|
||||
|
||||
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.ui
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||
@@ -27,6 +28,7 @@ class AppViewModel @Inject constructor(
|
||||
private val connectionRepository: ConnectionRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val pushManager: PushManager,
|
||||
) : ViewModel() {
|
||||
|
||||
sealed interface AppState {
|
||||
@@ -66,8 +68,16 @@ class AppViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadBrand(): BrandDto? =
|
||||
(settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
|
||||
/**
|
||||
* Load public settings for branding and feed the shard's push relay URL into the
|
||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
|
||||
*/
|
||||
private suspend fun 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)
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
@@ -15,6 +17,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
@@ -22,13 +25,20 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -37,37 +47,55 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||
import com.runicgateway.app.ui.player.CharactersScreen
|
||||
import com.runicgateway.app.ui.player.MyHousesScreen
|
||||
import com.runicgateway.app.ui.player.VendorsScreen
|
||||
import com.runicgateway.app.ui.session.SessionViewModel
|
||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||
import com.runicgateway.app.ui.shard.HousesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A navigation menu entry (PLAN.md §5). For M1 every entry is public. */
|
||||
private data class MenuEntry(val route: String, val labelRes: Int)
|
||||
|
||||
private val PUBLIC_MENU = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
)
|
||||
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.CONTACT, Routes.PAGE,
|
||||
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.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||
)
|
||||
|
||||
/**
|
||||
* The main app shell once a shard site is configured (PLAN.md §5): one shared,
|
||||
* declarative navigation drawer over the public content graph, plus the
|
||||
* Settings → Server switch. The signed-in menu groups and auth toggle join in M3.
|
||||
* declarative, access-level navigation drawer whose entries are filtered by the
|
||||
* current session, plus the Sign in / Sign out toggle and the Settings → Server
|
||||
* switch. The signed-in role is re-validated against the backend on every resume
|
||||
* (§4.3), so a server-side demotion drops menu access promptly.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -75,29 +103,62 @@ fun RunicApp(
|
||||
brand: BrandDto?,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
deepLinkStream: String? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||
LifecycleResumeEffect(Unit) {
|
||||
sessionViewModel.revalidate()
|
||||
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 currentRoute = backStackEntry?.destination?.route
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
val entries = visibleEntries(APP_MENU, session)
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = isTopLevel,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
val drawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
// Scroll the drawer: a signed-in session adds Account, Notifications, and
|
||||
// the player groups, and the full list overflows a phone's drawer height —
|
||||
// without this the lower entries (Notifications included) are clipped and
|
||||
// unreachable. See RunicGateway M10.
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PUBLIC_MENU.forEach { entry ->
|
||||
entries.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(entry.labelRes)) },
|
||||
selected = currentRoute == entry.route,
|
||||
@@ -105,10 +166,34 @@ fun RunicApp(
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateTopLevel(entry.route)
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Sign in / Sign out toggles on the session (§5).
|
||||
val signInLabel = if (session is Session.SignedIn) {
|
||||
R.string.menu_sign_out
|
||||
} else {
|
||||
R.string.menu_sign_in
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(signInLabel)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
if (session is Session.SignedIn) {
|
||||
sessionViewModel.signOut()
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
} else {
|
||||
navController.navigate(Routes.LOGIN)
|
||||
}
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.menu_change_server)) },
|
||||
selected = false,
|
||||
@@ -116,8 +201,10 @@ fun RunicApp(
|
||||
scope.launch { drawerState.close() }
|
||||
onChangeServer()
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -125,10 +212,19 @@ fun RunicApp(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name),
|
||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)).uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -151,6 +247,9 @@ fun RunicApp(
|
||||
RunicNavHost(
|
||||
navController = navController,
|
||||
brand = brand,
|
||||
session = session,
|
||||
onSignOut = { sessionViewModel.signOut() },
|
||||
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
@@ -161,6 +260,9 @@ fun RunicApp(
|
||||
private fun RunicNavHost(
|
||||
navController: NavHostController,
|
||||
brand: BrandDto?,
|
||||
session: Session,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
@@ -185,6 +287,22 @@ private fun RunicNavHost(
|
||||
) {
|
||||
PostScreen()
|
||||
}
|
||||
composable(Routes.SHARD) {
|
||||
ShardScreen(onOpenBoard = { board ->
|
||||
navController.navigate(
|
||||
when (board) {
|
||||
ShardBoard.CHAMPS -> Routes.SHARD_CHAMPS
|
||||
ShardBoard.GUILDS -> Routes.SHARD_GUILDS
|
||||
ShardBoard.GOVERNORS -> Routes.SHARD_GOVERNORS
|
||||
ShardBoard.HOUSES -> Routes.SHARD_HOUSES
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
composable(Routes.SHARD_CHAMPS) { ChampsScreen() }
|
||||
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
|
||||
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
|
||||
composable(Routes.SHARD_HOUSES) { HousesScreen() }
|
||||
composable(Routes.WIKI) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
}
|
||||
@@ -203,9 +321,132 @@ private fun RunicNavHost(
|
||||
composable(Routes.CONTACT) {
|
||||
ContactScreen()
|
||||
}
|
||||
composable(Routes.LOGIN) {
|
||||
// Leave the login screen as soon as the session is established — whether by
|
||||
// password or the SSO bridge. Keying off the shared session (not just the
|
||||
// login VM's local flag) makes this robust to the deep-link/recomposition
|
||||
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
|
||||
if (session is Session.SignedIn) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
|
||||
} else {
|
||||
LoginScreen(onSignedIn = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT) {
|
||||
// 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.
|
||||
when (val s = session) {
|
||||
is Session.SignedIn -> AccountScreen(
|
||||
username = s.user.username,
|
||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||
onSignOut = onSignOut,
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
|
||||
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
|
||||
when (session) {
|
||||
is Session.SignedIn -> TrustedDevicesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_RECOVERY_CODES) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> RecoveryCodesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving 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.
|
||||
// The server enforces the player gate on every call; these screens simply
|
||||
// render 401/403/503 as clean states (§7).
|
||||
composable(Routes.PLAYER_CHARACTERS) {
|
||||
PlayerGate(session, navController) {
|
||||
CharactersScreen(onOpenChar = { serial -> navController.navigate(Routes.playerChar(serial)) })
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = Routes.PLAYER_CHAR,
|
||||
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
|
||||
) {
|
||||
CharacterSheetScreen()
|
||||
}
|
||||
composable(Routes.PLAYER_VENDORS) {
|
||||
PlayerGate(session, navController) { VendorsScreen() }
|
||||
}
|
||||
composable(Routes.PLAYER_HOUSES) {
|
||||
PlayerGate(session, navController) { MyHousesScreen() }
|
||||
}
|
||||
|
||||
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
|
||||
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||
// the menu's visibility so a signed-out/demoted user isn't left on a stale screen.
|
||||
composable(Routes.ADMIN_DASHBOARD) {
|
||||
StaffGate(session, navController) {
|
||||
AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
|
||||
}
|
||||
}
|
||||
composable(Routes.ADMIN_CONTENT) {
|
||||
StaffGate(session, navController) { AdminContentScreen() }
|
||||
}
|
||||
composable(Routes.ADMIN_MODERATION) {
|
||||
StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
|
||||
}
|
||||
composable(Routes.ADMIN_SUPPORT) {
|
||||
StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A UX guard for the player-only groups: while signed in, render [content]; if the
|
||||
* session drops (sign-out, or a server-side demotion caught on resume, §4.3), send
|
||||
* the user home instead of leaving a stale player screen up. The backend remains
|
||||
* the authority — this only mirrors the menu's visibility rule.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlayerGate(
|
||||
session: Session,
|
||||
navController: NavHostController,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> content()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for
|
||||
* a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is
|
||||
* sent home rather than left on a stale admin screen. The backend is the authority —
|
||||
* every `/admin/…` call re-checks role — so this only mirrors the menu's visibility.
|
||||
*/
|
||||
@Composable
|
||||
private fun StaffGate(
|
||||
session: Session,
|
||||
navController: NavHostController,
|
||||
require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val ok = (session as? Session.SignedIn)?.user?.let(require) == true
|
||||
if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
|
||||
/** Navigate to a top-level menu destination: single instance, reset to it. */
|
||||
private fun NavHostController.navigateTopLevel(route: String) {
|
||||
navigate(route) {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
390
app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt
Normal file
390
app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt
Normal file
@@ -0,0 +1,390 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.auth.AccountViewModel.Feedback
|
||||
import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service
|
||||
* over `/auth/me/account*` (change username/password, TOTP, linked identities),
|
||||
* and the sign-out controls. Credential-provisioning flows (register / reset / SSO
|
||||
* link) stay website hand-offs (§4.2) and are not rebuilt here.
|
||||
*/
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
username: String,
|
||||
roleLabel: String,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
IdentityCard(username = username, roleLabel = roleLabel)
|
||||
|
||||
// One-time recovery codes surfaced right after enabling 2FA — save them now.
|
||||
state.recoveryCodesOnce?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
|
||||
}
|
||||
|
||||
when (val account = state.account) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.account_sign_out))
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSignOutEverywhere,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.account_sign_out_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||
StatusPill(
|
||||
text = roleLabel,
|
||||
tone = PillTone.Info,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountSections(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
) {
|
||||
UsernameSection(account, state, viewModel)
|
||||
PasswordSection(account, state, viewModel)
|
||||
TwoFactorSection(account, state, viewModel)
|
||||
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
IdentitiesSection(state, viewModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Links to the dedicated trusted-device and recovery-code screens
|
||||
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
|
||||
SectionCard(R.string.account_security_title) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenTrustedDevices,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_security_trusted_devices)) }
|
||||
OutlinedButton(
|
||||
onClick = onOpenRecoveryCodes,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.account_security_recovery_codes)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsernameSection(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
var username by rememberSaveable(account.username) { mutableStateOf(account.username) }
|
||||
SectionCard(R.string.account_username_title) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.changeUsername(username) },
|
||||
enabled = !state.busy && username.trim() != account.username && username.trim().length >= 3,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_username_action)) }
|
||||
SectionFeedback(state.feedback, Section.USERNAME)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordSection(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
val hasPassword = account.has_password
|
||||
var current by rememberSaveable { mutableStateOf("") }
|
||||
var next by rememberSaveable { mutableStateOf("") }
|
||||
SectionCard(if (hasPassword) R.string.account_password_title else R.string.account_password_set_title) {
|
||||
if (!hasPassword) {
|
||||
Text(
|
||||
stringResource(R.string.account_password_set_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (hasPassword) {
|
||||
OutlinedTextField(
|
||||
value = current,
|
||||
onValueChange = { current = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_current)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = next,
|
||||
onValueChange = { next = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_new)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.changePassword(next, if (hasPassword) current else null)
|
||||
current = ""
|
||||
next = ""
|
||||
},
|
||||
enabled = !state.busy && next.length >= 8 && (!hasPassword || current.isNotBlank()),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) {
|
||||
Text(stringResource(if (hasPassword) R.string.account_password_action else R.string.account_password_set_action))
|
||||
}
|
||||
SectionFeedback(state.feedback, Section.PASSWORD)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TwoFactorSection(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
SectionCard(R.string.account_totp_title) {
|
||||
StatusPill(
|
||||
text = stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
|
||||
tone = if (account.totp_enabled) PillTone.Success else PillTone.Neutral,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
// Enabled → offer disable via a current code.
|
||||
account.totp_enabled -> {
|
||||
CodeField(code, { code = it }, !state.busy)
|
||||
Button(
|
||||
onClick = { viewModel.disableTotp(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_totp_disable)) }
|
||||
}
|
||||
// Mid-enrollment → show QR + confirm.
|
||||
state.totpSetup != null -> {
|
||||
Text(
|
||||
stringResource(R.string.account_totp_scan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
rememberQrBitmap(state.totpSetup.qr)?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = stringResource(R.string.account_totp_qr_desc),
|
||||
modifier = Modifier.padding(top = 12.dp).size(180.dp),
|
||||
)
|
||||
}
|
||||
CodeField(code, { code = it }, !state.busy)
|
||||
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.enableTotp(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
) { Text(stringResource(R.string.account_totp_confirm)) }
|
||||
OutlinedButton(onClick = { viewModel.cancelTotp(); code = "" }, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.account_totp_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not enabled, not enrolling → start.
|
||||
else -> {
|
||||
Button(
|
||||
onClick = viewModel::beginTotp,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_totp_setup)) }
|
||||
}
|
||||
}
|
||||
SectionFeedback(state.feedback, Section.TOTP)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentitiesSection(state: AccountViewModel.State, viewModel: AccountViewModel) {
|
||||
SectionCard(R.string.account_identities_title) {
|
||||
if (state.identities.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.account_identities_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
state.identities.forEach { identity -> IdentityRow(identity, state.busy, viewModel) }
|
||||
}
|
||||
SectionFeedback(state.feedback, Section.IDENTITY)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: AccountViewModel) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
identity.provider.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
identity.email?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.account_identity_unlink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeField(code: String, onChange: (String) -> Unit, enabled: Boolean) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { onChange(it.filter(Char::isDigit).take(8)) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionFeedback(feedback: Feedback?, section: Section) {
|
||||
if (feedback == null || feedback.section != section) return
|
||||
Text(
|
||||
text = stringResource(feedback.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Decode a `data:image/png;base64,…` URL (the TOTP QR) into an [ImageBitmap]. */
|
||||
@Composable
|
||||
private fun rememberQrBitmap(dataUrl: String?): ImageBitmap? = remember(dataUrl) {
|
||||
if (dataUrl.isNullOrBlank()) return@remember null
|
||||
val comma = dataUrl.indexOf(',')
|
||||
if (comma < 0) return@remember null
|
||||
runCatching {
|
||||
val bytes = Base64.decode(dataUrl.substring(comma + 1), Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Human label for a role (advisory display only — §4.3). */
|
||||
@StringRes
|
||||
fun roleLabelRes(role: Role): Int = when (role) {
|
||||
Role.PLAYER -> R.string.role_player
|
||||
Role.MODERATOR -> R.string.role_moderator
|
||||
Role.EDITOR -> R.string.role_editor
|
||||
Role.ADMIN -> R.string.role_admin
|
||||
Role.UNKNOWN -> R.string.role_unknown
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
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 signed-in self-service surface (PLAN.md §6.3, §6.4) over
|
||||
* `/auth/me/account*`: change username/password, enroll/disable TOTP, and manage
|
||||
* linked SSO identities. Each mutation folds its [ApiResult] into a section-scoped
|
||||
* [Feedback] so the screen shows friendly, localized copy inline (§7). A username
|
||||
* change also re-validates the session so the shell reflects the new name at once.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Which section an action's [Feedback] belongs to, so it renders in place. */
|
||||
enum class Section { USERNAME, PASSWORD, TOTP, IDENTITY }
|
||||
|
||||
/** A one-shot result banner under a section. */
|
||||
data class Feedback(val section: Section, val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val account: UiState<PlayerAccountDto> = UiState.Loading,
|
||||
val identities: List<LinkedIdentityDto> = emptyList(),
|
||||
/** True while any mutation is in flight (disables that section's controls). */
|
||||
val busy: Boolean = false,
|
||||
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||
val totpSetup: TotpSetupDto? = null,
|
||||
/** The single-use recovery codes returned once when 2FA was just enabled. */
|
||||
val recoveryCodesOnce: List<String>? = null,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(account = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
val account = accountRepository.getAccount()
|
||||
_state.update { it.copy(account = account.toUiState()) }
|
||||
// Identities are non-critical — an empty list on failure is fine.
|
||||
when (val ids = accountRepository.identities()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
// ── Username ─────────────────────────────────────────────────────────
|
||||
fun changeUsername(username: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.changeUsername(username.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.USERNAME, true, R.string.account_username_changed)
|
||||
// Reflect the new name in the shell + refresh the loaded account.
|
||||
authRepository.revalidate()
|
||||
reloadAccount()
|
||||
}
|
||||
else -> finish(Section.USERNAME, false, usernameErrorRes(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password ─────────────────────────────────────────────────────────
|
||||
fun changePassword(newPassword: String, currentPassword: String?) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.changePassword(newPassword, currentPassword?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Ok -> finish(Section.PASSWORD, true, R.string.account_password_changed)
|
||||
is ApiResult.HttpError -> finish(Section.PASSWORD, false, R.string.account_password_error)
|
||||
is ApiResult.NetworkError -> finish(Section.PASSWORD, false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOTP ─────────────────────────────────────────────────────────────
|
||||
fun beginTotp() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.totpSetup()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(busy = false, totpSetup = result.data) }
|
||||
else -> finish(Section.TOTP, false, R.string.account_totp_setup_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTotp() = _state.update { it.copy(totpSetup = null, feedback = null) }
|
||||
|
||||
fun enableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.totpEnable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
// 2FA enable returns the fresh recovery-code batch once — surface it.
|
||||
_state.update {
|
||||
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
|
||||
}
|
||||
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||
reloadAccount()
|
||||
}
|
||||
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
|
||||
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
|
||||
|
||||
fun disableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.totpDisable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.TOTP, true, R.string.account_totp_disabled)
|
||||
reloadAccount()
|
||||
}
|
||||
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identities ───────────────────────────────────────────────────────
|
||||
fun unlinkIdentity(provider: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.unlinkIdentity(provider)) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
|
||||
when (val ids = accountRepository.identities()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
else -> finish(Section.IDENTITY, false, R.string.account_identity_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadAccount() {
|
||||
when (val account = accountRepository.getAccount()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(account = UiState.Success(account.data)) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(section: Section, ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(section, ok, messageRes)) }
|
||||
|
||||
private fun usernameErrorRes(result: ApiResult<*>): Int = when {
|
||||
result is ApiResult.HttpError && result.status == 409 -> R.string.account_username_taken
|
||||
result is ApiResult.NetworkError -> R.string.error_network
|
||||
else -> R.string.account_username_error
|
||||
}
|
||||
}
|
||||
338
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
338
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.web.WebHandoff
|
||||
import com.runicgateway.app.ui.auth.LoginViewModel.LoginError
|
||||
|
||||
/**
|
||||
* Native username/password (+TOTP) login (PLAN.md §4.1) — the app's only native
|
||||
* credential screen. Registration, forgot-password, and SSO are website hand-offs
|
||||
* opened in a Custom Tab (§4.2); the user completes them in the browser and
|
||||
* returns here to sign in.
|
||||
*/
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onSignedIn: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.signedIn) {
|
||||
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(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.login_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = if (state.totpRequired) ImeAction.Next else ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
if (state.totpRequired) {
|
||||
if (state.useRecoveryCode) {
|
||||
OutlinedTextField(
|
||||
value = state.recoveryCode,
|
||||
onValueChange = viewModel::onRecoveryCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_recovery_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle between authenticator code and a single-use recovery code.
|
||||
TextButton(
|
||||
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
|
||||
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 ->
|
||||
Text(
|
||||
text = stringResource(loginErrorRes(err)),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = viewModel::submit,
|
||||
enabled = !state.submitting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
) {
|
||||
if (state.submitting) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.login_button))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 ──
|
||||
viewModel.registerUrl?.let { url ->
|
||||
TextButton(
|
||||
onClick = { WebHandoff.open(context, url) },
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
) { Text(stringResource(R.string.login_register)) }
|
||||
}
|
||||
viewModel.forgotPasswordUrl?.let { url ->
|
||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||
Text(stringResource(R.string.login_forgot))
|
||||
}
|
||||
}
|
||||
|
||||
if (showSsoPicker) {
|
||||
SsoProviderPicker(
|
||||
providers = state.ssoProviders,
|
||||
onDismiss = { showSsoPicker = false },
|
||||
onPick = { provider ->
|
||||
showSsoPicker = false
|
||||
viewModel.onSsoProviderClick(provider)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO
|
||||
* providers so a single "Sign in with SSO" button can serve several IdPs without a
|
||||
* website chooser page. Each row opens the Custom-Tab bridge for that provider.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SsoProviderPicker(
|
||||
providers: List<com.runicgateway.app.data.api.dto.SsoProviderDto>,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_sso_pick_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
providers.forEach { provider ->
|
||||
TextButton(
|
||||
onClick = { onPick(provider) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_sso_provider, provider.name),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginErrorRes(error: LoginError): Int = when (error) {
|
||||
LoginError.INVALID_CREDENTIALS -> R.string.login_error_credentials
|
||||
LoginError.BAD_CODE -> R.string.login_error_code
|
||||
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
|
||||
LoginError.SERVER -> R.string.login_error_server
|
||||
LoginError.NETWORK -> R.string.login_error_network
|
||||
LoginError.SSO -> R.string.login_error_sso
|
||||
}
|
||||
220
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
220
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||
import com.runicgateway.app.core.web.WebsiteUrls
|
||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
||||
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the native login screen (PLAN.md §4.1): username/password, single-request
|
||||
* TOTP (the code field is revealed once the backend answers `totpRequired`), and
|
||||
* friendly 429 backoff handling. Registration / forgot-password / SSO are website
|
||||
* hand-offs whose URLs it exposes (§4.2).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val ssoAuthManager: SsoAuthManager,
|
||||
private val websiteUrls: WebsiteUrls,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The transient error surfaced under the form after a failed attempt. */
|
||||
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK, SSO }
|
||||
|
||||
data class UiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val code: String = "",
|
||||
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
|
||||
val recoveryCode: String = "",
|
||||
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||
val totpRequired: Boolean = false,
|
||||
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
|
||||
val useRecoveryCode: Boolean = false,
|
||||
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
|
||||
val trustDevice: Boolean = false,
|
||||
val submitting: Boolean = false,
|
||||
val error: LoginError? = null,
|
||||
val signedIn: Boolean = false,
|
||||
/** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
|
||||
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())
|
||||
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 onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
||||
fun onCodeChange(value: String) =
|
||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||
|
||||
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
|
||||
fun onRecoveryCodeChange(value: String) =
|
||||
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
|
||||
|
||||
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
|
||||
|
||||
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
|
||||
fun onUseRecoveryCodeChange(value: Boolean) =
|
||||
_state.update { it.copy(useRecoveryCode = value, error = null) }
|
||||
|
||||
val registerUrl: String? get() = websiteUrls.register()
|
||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||
|
||||
/**
|
||||
* Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
|
||||
* affordance instead of the old dead website-login hand-off, which was never
|
||||
* mobile-formatted and could not deep-link the session back.
|
||||
*/
|
||||
fun discoverSsoProviders() {
|
||||
_state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
|
||||
viewModelScope.launch {
|
||||
when (val result = authRepository.ssoProviders()) {
|
||||
is SsoDiscovery.Available ->
|
||||
_state.update {
|
||||
it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
|
||||
}
|
||||
SsoDiscovery.None ->
|
||||
_state.update {
|
||||
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
|
||||
}
|
||||
SsoDiscovery.Unavailable ->
|
||||
_state.update {
|
||||
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
val s = _state.value
|
||||
if (s.submitting) return
|
||||
val validationError = validateForSubmit(s)
|
||||
if (validationError != null) {
|
||||
_state.update { it.copy(error = validationError) }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
// Only one second factor is sent; the recovery toggle picks which.
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
|
||||
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
|
||||
val result = authRepository.login(
|
||||
username = s.username.trim(),
|
||||
password = s.password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = s.trustDevice,
|
||||
)
|
||||
applyLoginResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (hadFactor) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
LoginResult.InvalidCredentials ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
|
||||
|
||||
LoginResult.RateLimited ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
|
||||
|
||||
LoginResult.ServerError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
|
||||
|
||||
LoginResult.NetworkError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* 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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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)) }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.ui.theme.ShardCardBottom
|
||||
import com.runicgateway.app.ui.theme.ShardCardTop
|
||||
import com.runicgateway.app.ui.theme.ShardDanger
|
||||
import com.runicgateway.app.ui.theme.ShardDangerBg
|
||||
import com.runicgateway.app.ui.theme.ShardElevated
|
||||
import com.runicgateway.app.ui.theme.ShardFaint
|
||||
import com.runicgateway.app.ui.theme.ShardOutline
|
||||
import com.runicgateway.app.ui.theme.ShardPillBg
|
||||
import com.runicgateway.app.ui.theme.ShardPillFg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||
import com.runicgateway.app.ui.theme.ShardWarning
|
||||
import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||
|
||||
/**
|
||||
* Shared visual building blocks for the M5 shard-website design pass
|
||||
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||
* no state, no data dependencies — so any screen can adopt them.
|
||||
*/
|
||||
|
||||
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||
enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
||||
|
||||
private data class PillColors(val fg: Color, val bg: Color)
|
||||
|
||||
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
||||
}
|
||||
|
||||
/**
|
||||
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
||||
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val c = toneColors(tone)
|
||||
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = c.fg,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The small colored status dot (green when live), matched to a [PillTone]. */
|
||||
@Composable
|
||||
fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val color = when (tone) {
|
||||
PillTone.Success -> ShardSuccessDot
|
||||
PillTone.Warning -> ShardWarning
|
||||
PillTone.Danger -> ShardDanger
|
||||
PillTone.Neutral, PillTone.Info -> ShardFaint
|
||||
}
|
||||
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
|
||||
/**
|
||||
* The muted, uppercase, letter-spaced section header the design uses above grouped
|
||||
* content ("Attributes", "Vitals", "Password", "Linked accounts").
|
||||
*/
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ShardFaint,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
||||
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||
* vendor card. [content] is laid out in a padded [Column].
|
||||
*/
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: Int = 18,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Column(Modifier.padding(contentPadding.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||
* the slate accent over a bordered dark track.
|
||||
*/
|
||||
@Composable
|
||||
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||
val pct = fraction.coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(ShardElevated)
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(pct)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.secondary),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ private fun errorMessage(error: ConnectError): String = when (error) {
|
||||
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
|
||||
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
|
||||
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
|
||||
is ConnectError.VersionMismatch -> stringResource(R.string.connect_error_version, error.serverApi)
|
||||
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
|
||||
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ConnectViewModel @Inject constructor(
|
||||
data object UnsupportedScheme : ConnectError
|
||||
data object Insecure : ConnectError
|
||||
data object NotRunicGateway : ConnectError
|
||||
data class VersionMismatch(val serverApi: String) : ConnectError
|
||||
data object Unreachable : ConnectError
|
||||
data class Server(val status: Int) : ConnectError
|
||||
}
|
||||
@@ -61,6 +62,7 @@ class ConnectViewModel @Inject constructor(
|
||||
}
|
||||
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
|
||||
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
|
||||
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
|
||||
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
|
||||
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
package com.runicgateway.app.ui.home
|
||||
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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
|
||||
@@ -27,7 +27,10 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.OnlineDot
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
|
||||
/**
|
||||
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
|
||||
@@ -83,40 +86,37 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
||||
@Composable
|
||||
private fun StatusCard(status: StatusDto) {
|
||||
val online = !status.isMaintenance
|
||||
val containerColor =
|
||||
if (online) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.errorContainer
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
FeatureCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OnlineDot(if (online) PillTone.Success else PillTone.Danger)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (online) R.string.home_status_live else R.string.home_status_maintenance,
|
||||
),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = status.statusMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
if (status.version.server.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.home_server_version,
|
||||
status.version.server,
|
||||
status.version.api,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = status.statusMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
if (status.version.server.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.home_server_version,
|
||||
status.version.server,
|
||||
status.version.api,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
82
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
82
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
|
||||
/**
|
||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||
* single list where each entry declares the minimum access it needs, filtered by
|
||||
* the current session — not a pile of `if role ==` checks. The server stays the
|
||||
* source of truth; a hidden item is a UX convenience and every gated call still
|
||||
* enforces on the backend.
|
||||
*/
|
||||
enum class MenuAccess {
|
||||
/** Visible to everyone, signed in or not. */
|
||||
PUBLIC,
|
||||
|
||||
/** Visible to any signed-in account (§5, "My Account"). */
|
||||
SIGNED_IN,
|
||||
|
||||
/**
|
||||
* The linked game-data groups (§6.3). Visible to any player **or** staff:
|
||||
* staff are a superset of players (all player abilities plus their staff
|
||||
* tools), and the backend's player self-service surface is role-agnostic, so
|
||||
* a signed-in admin/editor/moderator sees + uses their own characters too.
|
||||
*/
|
||||
PLAYER,
|
||||
|
||||
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
|
||||
STAFF,
|
||||
|
||||
/** Visible to admin/moderator — moderation actions + the support queue (§1, M10). */
|
||||
MODERATOR,
|
||||
}
|
||||
|
||||
data class MenuEntry(
|
||||
val route: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||
)
|
||||
|
||||
/**
|
||||
* The full menu, in display order. Public content first, then the signed-in
|
||||
* surfaces, then the player-only game-data groups (revealed once the session's
|
||||
* role is `player`, §6.3).
|
||||
*/
|
||||
val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
||||
// 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),
|
||||
)
|
||||
|
||||
/**
|
||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
||||
* gating is unit-tested without Compose.
|
||||
*/
|
||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,41 @@ object Routes {
|
||||
const val WIKI = "wiki"
|
||||
const val CONTACT = "contact"
|
||||
|
||||
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Opt-in push notification settings (§11, signed-in). */
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
/** Shard boards, reachable from the hub. */
|
||||
const val SHARD_CHAMPS = "shard/champs"
|
||||
const val SHARD_GUILDS = "shard/guilds"
|
||||
const val SHARD_GOVERNORS = "shard/governors"
|
||||
const val SHARD_HOUSES = "shard/houses"
|
||||
|
||||
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
|
||||
const val PLAYER_CHARACTERS = "player/characters"
|
||||
const val PLAYER_VENDORS = "player/vendors"
|
||||
const val PLAYER_HOUSES = "player/houses"
|
||||
|
||||
/** A single character sheet by in-game (hex) serial. */
|
||||
const val PLAYER_CHAR = "player/char/{serial}"
|
||||
|
||||
/** Staff operations (§1, §6.4, M10). Gated to staff roles by the menu access level;
|
||||
* the backend re-checks role on every `/admin/…` call. */
|
||||
const val ADMIN_DASHBOARD = "admin/dashboard"
|
||||
const val ADMIN_MODERATION = "admin/moderation"
|
||||
const val ADMIN_SUPPORT = "admin/support"
|
||||
const val ADMIN_CONTENT = "admin/content"
|
||||
|
||||
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
||||
const val PAGE = "page/{slug}"
|
||||
|
||||
@@ -27,9 +62,32 @@ object Routes {
|
||||
const val SLUG = "slug"
|
||||
const val CATEGORY = "category"
|
||||
const val ID_OR_SLUG = "idOrSlug"
|
||||
const val SERIAL = "serial"
|
||||
}
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
||||
fun wikiPage(slug: String) = "wiki/$slug"
|
||||
|
||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||
fun playerChar(serial: String) = "player/char/$serial"
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.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.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||
import com.runicgateway.app.data.api.dto.ResistDto
|
||||
import com.runicgateway.app.data.api.dto.SkillDto
|
||||
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||
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.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatBar
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* A text-only character sheet (PLAN.md §6.3): identity + standing, attributes and
|
||||
* vitals, resistances, skills, and equipment. No item icons / paperdoll art — a
|
||||
* richer view is a future enhancement pending the platform art work.
|
||||
*/
|
||||
@Composable
|
||||
fun CharacterSheetScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CharacterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> CharacterSheet(s.data, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
IdentityBlock(char)
|
||||
StandingChips(char)
|
||||
char.stats?.let { AttributesBlock(it) }
|
||||
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||
SkillsBlock(char.skills)
|
||||
EquipmentBlock(char.equipment)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityBlock(char: CharProfileDto) {
|
||||
Column {
|
||||
Text(
|
||||
char.name ?: stringResource(R.string.player_char_unknown),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
char.title?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Row(Modifier.padding(top = 4.dp)) {
|
||||
Text(
|
||||
stringResource(if (char.online) R.string.player_char_online else R.string.player_char_offline),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (char.online) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
char.serial?.let {
|
||||
Text(
|
||||
" · $it",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun StandingChips(char: CharProfileDto) {
|
||||
val chips = buildList {
|
||||
char.governorOf.forEach { add(stringResource(R.string.player_char_governor, it)) }
|
||||
char.guild?.let { g ->
|
||||
val abbr = g.abbr?.let { " [$it]" } ?: ""
|
||||
add(stringResource(R.string.player_char_guildmaster, "${g.name.orEmpty()}$abbr"))
|
||||
}
|
||||
addAll(displayTitles(char.titles))
|
||||
}
|
||||
if (chips.isEmpty()) return
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
chips.forEach { Chip(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttributesBlock(stats: CharStatsDto) {
|
||||
SheetCard(R.string.player_char_attributes) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Stat(stringResource(R.string.player_char_str), stats.str)
|
||||
Stat(stringResource(R.string.player_char_dex), stats.dex)
|
||||
Stat(stringResource(R.string.player_char_int), stats.int)
|
||||
}
|
||||
Column(Modifier.padding(top = 16.dp)) {
|
||||
SectionLabel(stringResource(R.string.player_char_vitals))
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax)
|
||||
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax)
|
||||
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Stat(label: String, value: Int?) {
|
||||
Column {
|
||||
Text("${value ?: "—"}", style = MaterialTheme.typography.titleLarge)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Vital(label: String, cur: Int?, max: Int?) {
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
val fraction = if (max != null && max > 0 && cur != null) cur.toFloat() / max else 0f
|
||||
StatBar(fraction)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResistancesBlock(resist: ResistDto) {
|
||||
val rows = listOf(
|
||||
stringResource(R.string.player_char_phys) to resist.phys,
|
||||
stringResource(R.string.player_char_fire) to resist.fire,
|
||||
stringResource(R.string.player_char_cold) to resist.cold,
|
||||
stringResource(R.string.player_char_pois) to resist.pois,
|
||||
stringResource(R.string.player_char_energy) to resist.energy,
|
||||
)
|
||||
if (rows.all { it.second == null }) return
|
||||
SheetCard(R.string.player_char_resistances) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
rows.forEach { (label, value) ->
|
||||
Column {
|
||||
Text("${value ?: 0}", style = MaterialTheme.typography.titleMedium)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsBlock(skills: List<SkillDto>) {
|
||||
val shown = skills
|
||||
.filter { (it.value ?: it.base ?: 0.0) > 0.0 }
|
||||
.sortedByDescending { it.value ?: 0.0 }
|
||||
if (shown.isEmpty()) return
|
||||
SheetCard(R.string.player_char_skills) {
|
||||
shown.forEach { skill ->
|
||||
val value = skill.value ?: 0.0
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(skill.n ?: "—", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(formatSkill(value), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
StatBar((value / SKILL_CAP).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
if (equipment.isEmpty()) return
|
||||
SheetCard(R.string.player_char_equipment) {
|
||||
equipment.forEach { item ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
item.layer ?: stringResource(R.string.player_char_item),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
item.itemId?.let { stringResource(R.string.player_char_item_id, it) },
|
||||
item.hue?.takeIf { it != 0 }?.let { stringResource(R.string.player_char_item_hue, it) },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
val mods = item.mods.orEmpty()
|
||||
if (mods.isNotEmpty()) {
|
||||
FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
mods.forEach { (k, v) -> Chip("$k ${jsonText(v)}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
/** ServUO's default skill cap; used to scale the skill meter fill (0..1). */
|
||||
private const val SKILL_CAP = 120.0
|
||||
|
||||
/** Format a skill value: drop the ".0" on whole numbers, else one decimal. */
|
||||
internal fun formatSkill(value: Double): String =
|
||||
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
|
||||
|
||||
/**
|
||||
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||
* reward title — but only if it is a literal string, not a bare cliloc number
|
||||
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
||||
*/
|
||||
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||
if (titles == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
titles.fameKarma?.let { out.add(it) }
|
||||
titles.skill?.let { out.add(it) }
|
||||
val reward = titles.reward
|
||||
val sel = titles.selected ?: -1
|
||||
val candidate = when {
|
||||
sel in reward.indices -> reward[sel]
|
||||
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
||||
}
|
||||
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
||||
return out.filter { it.isNotBlank() }.distinct()
|
||||
}
|
||||
|
||||
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A single character sheet (PLAN.md §6.3), read from `/player/shard/char/:serial`
|
||||
* for a character on one of the caller's linked accounts (ownership-checked
|
||||
* server-side). A `503` renders as offline/retry, a `403` as not-found (§7).
|
||||
* Text-only presentation for v1.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CharacterViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serial: String = savedStateHandle.get<String>(Routes.Args.SERIAL).orEmpty()
|
||||
|
||||
private val _state = MutableStateFlow<UiState<CharProfileDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<CharProfileDto>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.char(serial).toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
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.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.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
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 player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a
|
||||
* character opens its text-only sheet. Not-yet-linked players get the `[link` code
|
||||
* prompt (and, when the shard offers it, a hybrid signup form).
|
||||
*/
|
||||
@Composable
|
||||
fun CharactersScreen(
|
||||
onOpenChar: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CharactersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val accounts = state.accounts) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
LinkCard(state, viewModel)
|
||||
if (state.signupEnabled) CreateAccountCard(state, viewModel)
|
||||
|
||||
if (accounts.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_characters_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
accounts.data.forEach { account ->
|
||||
AccountRoster(
|
||||
account = account,
|
||||
roster = state.rosters[account] ?: UiState.Loading,
|
||||
onRetry = { viewModel.loadRoster(account) },
|
||||
onOpenChar = onOpenChar,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.player_link_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.uppercase() },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.player_link_code)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.link(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.player_link_action)) }
|
||||
FormFeedback(state.feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = account,
|
||||
onValueChange = { account = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.player_create_account)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.player_create_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.createAccount(account, password); password = "" },
|
||||
enabled = !state.busy && account.isNotBlank() && password.length >= 8,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.player_create_action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountRoster(
|
||||
account: String,
|
||||
roster: UiState<List<RosterCharDto>>,
|
||||
onRetry: () -> Unit,
|
||||
onOpenChar: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
when (roster) {
|
||||
is UiState.Loading -> Text(
|
||||
stringResource(R.string.player_roster_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is UiState.Error -> RosterError(roster.kind, onRetry)
|
||||
is UiState.Success -> {
|
||||
if (roster.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_roster_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
roster.data.forEach { CharRow(it, onOpenChar) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||
Card(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(enabled = char.serial.isNotBlank()) { onOpenChar(char.serial) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
char.name ?: stringResource(R.string.player_char_unknown),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
if (char.online) R.string.player_char_online else R.string.player_char_offline,
|
||||
),
|
||||
tone = if (char.online) PillTone.Success else PillTone.Neutral,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormFeedback(feedback: CharactersViewModel.Feedback?) {
|
||||
if (feedback == null) return
|
||||
Text(
|
||||
text = stringResource(feedback.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
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.core.result.map
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's linked game accounts + per-account character rosters (PLAN.md §6.3):
|
||||
* link a game account with a `[link` one-time code (or, when the shard offers it,
|
||||
* a hybrid signup), then browse characters and open a text-only sheet. A per-account
|
||||
* roster carries its own load state so a down shard degrades that account to a
|
||||
* retry without blocking the rest (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CharactersViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot banner for the link / create-account forms. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val accounts: UiState<List<String>> = UiState.Loading,
|
||||
val rosters: Map<String, UiState<List<RosterCharDto>>> = emptyMap(),
|
||||
/** Whether the shard currently offers hybrid game-account signup. */
|
||||
val signupEnabled: Boolean = false,
|
||||
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(accounts = UiState.Loading, rosters = emptyMap()) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.accounts()) {
|
||||
is ApiResult.Ok -> {
|
||||
val names = result.data.map { it.account }
|
||||
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||
names.forEach { loadRoster(it) }
|
||||
}
|
||||
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||
}
|
||||
}
|
||||
// Non-critical: whether hybrid signup is offered right now.
|
||||
viewModelScope.launch {
|
||||
when (val settings = settingsRepository.getSettings()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(signupEnabled = settings.data.gameAccountSignup) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRoster(account: String) {
|
||||
_state.update { it.copy(rosters = it.rosters + (account to UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
val roster = repository.roster(account).map { it.chars }.toUiState()
|
||||
_state.update { it.copy(rosters = it.rosters + (account to roster)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun link(code: String) {
|
||||
if (_state.value.busy || code.isBlank()) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.link(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_link_ok)) }
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
|
||||
}
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAccount(account: String, password: String) {
|
||||
if (_state.value.busy || account.isBlank() || password.length < 8) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.createAccount(account.trim(), password)) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_create_ok)) }
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
|
||||
}
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun linkErrorRes(status: Int): Int = when (status) {
|
||||
400 -> R.string.player_link_bad_code
|
||||
503 -> R.string.error_shard_offline
|
||||
else -> R.string.player_link_error
|
||||
}
|
||||
|
||||
private fun createErrorRes(status: Int): Int = when (status) {
|
||||
409 -> R.string.player_create_taken
|
||||
429 -> R.string.player_create_capped
|
||||
403 -> R.string.player_create_unavailable
|
||||
400 -> R.string.player_create_rejected
|
||||
503 -> R.string.error_shard_offline
|
||||
else -> R.string.player_create_error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.PlayerHouseDto
|
||||
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 player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||
* IDOC house is flagged prominently. Only the caller's own property is ever shown.
|
||||
*/
|
||||
@Composable
|
||||
fun MyHousesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MyHousesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.player_houses_empty), modifier)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
items(s.data, key = { it.serial }) { house -> HouseCard(house) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: PlayerHouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = house.name?.takeIf { it.isNotBlank() }
|
||||
?: house.region
|
||||
?: stringResource(R.string.player_house_fallback),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (house.isIdoc) {
|
||||
Text(
|
||||
stringResource(R.string.houses_idoc_badge),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
house.stage?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
stringResource(R.string.player_house_stage, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (house.isIdoc) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(
|
||||
house.region,
|
||||
house.map,
|
||||
house.x?.let { "(${house.x}, ${house.y})" },
|
||||
).joinToString(" · ")
|
||||
if (where.isNotBlank()) {
|
||||
Text(
|
||||
where,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
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.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's OWN houses with decay/IDOC status (PLAN.md §6.3), read from
|
||||
* `/player/shard/houses` — never anyone else's. A `503` renders as offline/retry (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MyHousesViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<PlayerHouseDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<PlayerHouseDto>>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.houses().toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.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.VendorDto
|
||||
import com.runicgateway.app.data.api.dto.VendorListingDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* The player's own player-vendors + recent sales (PLAN.md §6.3), text-only. Vendor
|
||||
* shops group under their game account; recent sales list across all linked
|
||||
* accounts. A down shard degrades to a per-account retry (§7).
|
||||
*/
|
||||
@Composable
|
||||
fun VendorsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: VendorsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val accounts = state.accounts) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SalesCard(state.sales)
|
||||
|
||||
if (accounts.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_vendors_no_accounts),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
accounts.data.forEach { account ->
|
||||
AccountVendors(
|
||||
account = account,
|
||||
vendors = state.vendors[account] ?: UiState.Loading,
|
||||
onRetry = { viewModel.loadVendors(account) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||
when (sales) {
|
||||
is UiState.Loading -> Text(
|
||||
stringResource(R.string.player_roster_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
is UiState.Error -> Text(
|
||||
stringResource(R.string.error_server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
is UiState.Success -> {
|
||||
if (sales.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_sales_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
sales.data.forEach { SaleRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaleRow(sale: VendorSaleDto) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(Modifier.padding(end = 12.dp)) {
|
||||
Text(
|
||||
sale.itemType ?: stringResource(R.string.player_char_item),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
sale.t?.let {
|
||||
Text(
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.player_gold_amount, "%,d".format(sale.price ?: 0L)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountVendors(
|
||||
account: String,
|
||||
vendors: UiState<List<VendorDto>>,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
when (vendors) {
|
||||
is UiState.Loading -> Text(
|
||||
stringResource(R.string.player_roster_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is UiState.Error -> VendorError(vendors.kind, onRetry)
|
||||
is UiState.Success -> {
|
||||
if (vendors.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_vendors_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
vendors.data.forEach { VendorCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VendorCard(vendor: VendorDto) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
vendor.holdGold?.let { stringResource(R.string.player_vendor_hold, "%,d".format(it)) },
|
||||
vendor.map,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
if (vendor.listings.isNotEmpty()) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
vendor.listings.forEach { ListingRow(it) }
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.player_vendor_no_listings),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListingRow(listing: VendorListingDto) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(R.string.player_listing_item, listing.itemId ?: 0, listing.amount ?: 1),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.player_gold_amount, "%,d".format(listing.price ?: 0L)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.data.api.dto.VendorDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's own player-vendors and recent vendor sales (PLAN.md §6.3): a
|
||||
* per-account vendor snapshot (shops + listings) over `/player/shard/vendors/:account`
|
||||
* plus the recent-sales log over `/player/shard/sales`. Each account's snapshot
|
||||
* carries its own load state so a down shard degrades one account to a retry (§7).
|
||||
* Text-only listings — item names are clilocs the app has no table for.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VendorsViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
val accounts: UiState<List<String>> = UiState.Loading,
|
||||
val vendors: Map<String, UiState<List<VendorDto>>> = emptyMap(),
|
||||
val sales: UiState<List<VendorSaleDto>> = UiState.Loading,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(accounts = UiState.Loading, vendors = emptyMap(), sales = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.accounts()) {
|
||||
is ApiResult.Ok -> {
|
||||
val names = result.data.map { it.account }
|
||||
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||
names.forEach { loadVendors(it) }
|
||||
}
|
||||
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(sales = repository.sales().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadVendors(account: String) {
|
||||
_state.update { it.copy(vendors = it.vendors + (account to UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
val vendors = repository.vendors(account).map { it.vendors }.toUiState()
|
||||
_state.update { it.copy(vendors = it.vendors + (account to vendors)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Activity-scoped view of the current session for the app shell (PLAN.md §4.3,
|
||||
* §5): the shared menu observes [session] to reveal signed-in groups + the
|
||||
* sign-in/out toggle, and the shell drives resume re-validation and sign-out.
|
||||
* Login itself lives in [com.runicgateway.app.ui.auth.LoginViewModel].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SessionViewModel @Inject constructor(
|
||||
sessionManager: SessionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val session: StateFlow<Session> = sessionManager.state
|
||||
|
||||
/** Re-validate the cached role against the backend on app resume. */
|
||||
fun revalidate() {
|
||||
viewModelScope.launch { authRepository.revalidate() }
|
||||
}
|
||||
|
||||
/** Sign out of this session, or every session with [allDevices]. */
|
||||
fun signOut(allDevices: Boolean = false) {
|
||||
viewModelScope.launch { authRepository.logout(allDevices) }
|
||||
}
|
||||
}
|
||||
108
app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt
Normal file
108
app/src/main/java/com/runicgateway/app/ui/shard/ChampsScreen.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.ChampDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
fun ChampsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ChampsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
|
||||
LiveBoardScreen(
|
||||
emptyMessage = stringResource(R.string.champs_empty),
|
||||
state = state,
|
||||
connected = connected,
|
||||
onRetry = viewModel::load,
|
||||
key = { it.serial },
|
||||
modifier = modifier,
|
||||
) { champ -> ChampCard(champ) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChampCard(champ: ChampDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = champ.name ?: champ.type ?: stringResource(R.string.champs_fallback_name),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
champ.status?.let {
|
||||
StatusPill(text = it, tone = champStatusTone(it))
|
||||
}
|
||||
}
|
||||
val detail = champDetail(champ)
|
||||
if (detail.isNotBlank()) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(champ.map, champ.x?.let { "(${champ.x}, ${champ.y})" }).joinToString(" ")
|
||||
if (where.isNotBlank()) {
|
||||
Text(
|
||||
text = where,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a champ status to a pill tone: live spawns read green, cooldowns amber. */
|
||||
private fun champStatusTone(status: String): PillTone = when (status.lowercase()) {
|
||||
"active", "up", "spawned" -> PillTone.Success
|
||||
"cooldown", "restarting", "advancing" -> PillTone.Warning
|
||||
else -> PillTone.Neutral
|
||||
}
|
||||
|
||||
/** A short category-specific status line, mirroring the website's champ detail. */
|
||||
@Composable
|
||||
private fun champDetail(champ: ChampDto): String = when (champ.category) {
|
||||
"sea" -> if (champ.hitsMax != null) {
|
||||
"%,d / %,d hp".format(champ.hits ?: 0, champ.hitsMax)
|
||||
} else {
|
||||
champ.boss ?: champ.type ?: ""
|
||||
}
|
||||
"mini" -> stringResource(R.string.champ_level, champ.level ?: 0)
|
||||
else -> when (champ.status) {
|
||||
"active" -> if (champ.maxKills != null) {
|
||||
"%,d / %,d kills".format(champ.kills ?: 0, champ.maxKills)
|
||||
} else {
|
||||
stringResource(R.string.champ_level, champ.level ?: 0)
|
||||
}
|
||||
"cooldown" -> stringResource(R.string.champ_cooldown)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user