Compare commits
93 Commits
7c13476f29
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e61ee2bbb | |||
| ac2d75c3f9 | |||
| aa055469a8 | |||
| f3d90b189d | |||
| e10e1f1617 | |||
| 80441c3367 | |||
| d3bf4853de | |||
| 21b6ddc29b | |||
| d393cf022e | |||
| 21e235a07f | |||
| c55ee7f47e | |||
| 6cbfdb1e65 | |||
| b84a973559 | |||
| c14342aa51 | |||
| aeda919376 | |||
| 15a4d44c3f | |||
| fbe8b0bab6 | |||
| 94a5c26d6c | |||
| b95fc45548 | |||
| 3edd45d5f4 | |||
| 0051e97bc7 | |||
| a19fdd3582 | |||
| 7acbe54f46 | |||
| c7c49a9d6b | |||
| 1530c83fbc | |||
| c65913c62a | |||
| 17e9451494 | |||
| b0117acac1 | |||
| 5eaf5d22c6 | |||
| 12b2172731 | |||
| 4f85021be2 | |||
| 06b6b015c2 | |||
| aacef35def | |||
| 833e51de69 | |||
| 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 | |||
| 81f10fbca4 | |||
| 01481ef2d5 | |||
| 9019ded556 | |||
| 91cd4585c0 | |||
| 9ea66c500e | |||
| 6f243b0af9 | |||
| e714980600 | |||
| 3647d25be2 |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -17,3 +17,6 @@ gradlew text eol=lf
|
|||||||
*.png binary
|
*.png binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.ico binary
|
*.ico binary
|
||||||
|
# Bundled type families (res/font). `text=auto` already detects these as binary,
|
||||||
|
# but a font is too easy to corrupt silently to leave to a heuristic.
|
||||||
|
*.ttf binary
|
||||||
|
|||||||
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()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Gate every pull request into `main` on lint + unit tests + a debug build, so a
|
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
|
||||||
# broken build can't reach the deployable branch. Debug builds are auto-signed,
|
# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed,
|
||||||
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
||||||
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
||||||
#
|
#
|
||||||
@@ -8,16 +8,24 @@
|
|||||||
# * Enable Status Check
|
# * Enable Status Check
|
||||||
# * Status check patterns: PR Checks / *
|
# * Status check patterns: PR Checks / *
|
||||||
#
|
#
|
||||||
# Runner: the org's self-hosted `ubuntu-latest`, on a bare `ubuntu:latest`
|
# Runner: the org's self-hosted `ubuntu-latest`. The container lacks
|
||||||
# container that lacks git/curl/unzip (needed by checkout + sdkmanager) -- so the
|
# git/curl/unzip (needed by checkout + sdkmanager), so the first step installs
|
||||||
# first step installs them. (Faster later: switch to a prebuilt Android-SDK
|
# them. It also installs JDK 17 from the Ubuntu archive rather than using
|
||||||
# container image so nothing installs per-run.)
|
# actions/setup-java, because this runner can't resolve api.adoptium.net (that
|
||||||
|
# download fails with EAI_AGAIN) while the Ubuntu mirrors are reachable.
|
||||||
|
# (Faster later: switch to a prebuilt Android-SDK+JDK container image so nothing
|
||||||
|
# installs per-run.)
|
||||||
|
|
||||||
name: PR Checks
|
name: PR Checks
|
||||||
|
|
||||||
|
# `edge` is here because a workstream that lands ten phase PRs onto it before one
|
||||||
|
# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is
|
||||||
|
# exactly what happened to all nine M12 phase PRs, and would have happened again
|
||||||
|
# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own
|
||||||
|
# PR, not inside the cutover window with a whole workstream's diff to bisect.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, edge]
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pr-checks-${{ github.ref }}
|
group: pr-checks-${{ github.ref }}
|
||||||
@@ -27,23 +35,29 @@ jobs:
|
|||||||
android-build:
|
android-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Bare ubuntu:latest is missing the tools checkout + the SDK installer need.
|
# Install the tools checkout + the SDK installer need, plus JDK 17 (see the
|
||||||
- name: Install base tools
|
# header note on why we avoid actions/setup-java on this runner).
|
||||||
|
- name: Install base tools + JDK 17
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y git curl unzip
|
apt-get install -y git curl unzip openjdk-17-jdk-headless
|
||||||
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up JDK 17
|
|
||||||
uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
# Install exactly what the build targets so it never depends on AGP's
|
||||||
|
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||||
|
# +o pipefail` so `yes` dying with SIGPIPE (exit 141) once sdkmanager
|
||||||
|
# closes the pipe doesn't fail the step -- sdkmanager's own exit, last in
|
||||||
|
# the pipeline, still gates success.
|
||||||
|
- name: Install Android SDK packages
|
||||||
|
run: |
|
||||||
|
set +o pipefail
|
||||||
|
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||||
|
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -54,5 +68,13 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
gradle-${{ runner.os }}-
|
gradle-${{ runner.os }}-
|
||||||
|
|
||||||
|
# chmod defensively: this runner's checkout doesn't preserve the git
|
||||||
|
# executable bit, so `./gradlew` alone fails with "Permission denied".
|
||||||
|
# Debug-variant-only gate: unit tests, lint, and the debug APK. Scoping to
|
||||||
|
# the debug variant (vs. the aggregate `test`/`lint`) avoids compiling and
|
||||||
|
# linting the release variant in parallel, which halves peak memory on the
|
||||||
|
# runner and keeps lint's report phase from GC-thrashing (see gradle.properties).
|
||||||
- name: Lint, test, assemble debug
|
- name: Lint, test, assemble debug
|
||||||
run: ./gradlew --no-daemon lint test assembleDebug
|
run: |
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew --no-daemon testDebugUnitTest lintDebug assembleDebug
|
||||||
|
|||||||
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
|
||||||
22
README.md
22
README.md
@@ -48,9 +48,25 @@ any shard's website — there is no compiled-in API host.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
|
||||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
|
||||||
secrets. A signed **release** APK attached to a Gitea release comes at M6.
|
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
|
||||||
|
the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6.
|
||||||
|
|
||||||
|
**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch
|
||||||
|
before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what
|
||||||
|
happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is
|
||||||
|
unaffected: it is a push-on-`main` analysis, not a PR gate.
|
||||||
|
|
||||||
|
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
||||||
|
because this self-hosted runner differs from a stock GitHub runner:
|
||||||
|
|
||||||
|
- **JDK 17 is installed via `apt`** (not `actions/setup-java`) — the runner can't resolve
|
||||||
|
`api.adoptium.net`, while the Ubuntu mirrors are reachable.
|
||||||
|
- **SDK packages are installed explicitly** via `sdkmanager`, with `set +o pipefail` so `yes` dying of
|
||||||
|
`SIGPIPE` doesn't fail the step.
|
||||||
|
- **`gradlew` is `chmod +x`'d in the run step** — the runner's checkout does not preserve the git
|
||||||
|
executable bit, so `./gradlew` alone fails with "Permission denied".
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
@@ -7,8 +10,32 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
|
jacoco
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jacoco {
|
||||||
|
toolVersion = "0.8.12"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||||
|
// 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 {
|
android {
|
||||||
namespace = "com.runicgateway.app"
|
namespace = "com.runicgateway.app"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -18,20 +45,64 @@ android {
|
|||||||
applicationId = "com.runicgateway.app"
|
applicationId = "com.runicgateway.app"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||||
versionName = "0.1.0"
|
// 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"
|
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 {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
|
||||||
|
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
|
||||||
|
enableUnitTestCoverage = true
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
|
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||||
isMinifyEnabled = false
|
// release APK). Keep rules live in proguard-rules.pro.
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +141,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons.core)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
@@ -92,6 +164,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|
||||||
|
// Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
@@ -105,3 +180,35 @@ dependencies {
|
|||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
|
||||||
|
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
|
||||||
|
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
|
||||||
|
// JVM unit tests can't execute composable bodies without Robolectric.
|
||||||
|
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||||
|
dependsOn("testDebugUnitTest")
|
||||||
|
group = "verification"
|
||||||
|
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
|
||||||
|
|
||||||
|
reports {
|
||||||
|
xml.required.set(true)
|
||||||
|
html.required.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val coverageExcludes = listOf(
|
||||||
|
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
|
||||||
|
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
|
||||||
|
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
|
||||||
|
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
|
||||||
|
)
|
||||||
|
val buildDirFile = layout.buildDirectory.get().asFile
|
||||||
|
classDirectories.setFrom(
|
||||||
|
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
|
||||||
|
)
|
||||||
|
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
|
||||||
|
executionData.setFrom(
|
||||||
|
fileTree(buildDirFile) {
|
||||||
|
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
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.
|
||||||
93
app/licenses/EBGaramond-OFL.txt
Normal file
93
app/licenses/EBGaramond-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/IMFellEnglish-OFL.txt
Normal file
93
app/licenses/IMFellEnglish-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/Inter-OFL.txt
Normal file
93
app/licenses/Inter-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/Merriweather-OFL.txt
Normal file
93
app/licenses/Merriweather-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/PlayfairDisplay-OFL.txt
Normal file
93
app/licenses/PlayfairDisplay-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/SourceSans3-OFL.txt
Normal file
93
app/licenses/SourceSans3-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
93
app/licenses/WorkSans-OFL.txt
Normal file
93
app/licenses/WorkSans-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
62
app/proguard-rules.pro
vendored
62
app/proguard-rules.pro
vendored
@@ -1,3 +1,59 @@
|
|||||||
# Runic Gateway Android app — ProGuard/R8 rules.
|
# Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
|
||||||
# Minification is disabled until M6 (release hardening); real keep rules for
|
#
|
||||||
# kotlinx.serialization DTOs and Retrofit models are added there.
|
# 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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
||||||
|
and a foreground service that holds the persistent ntfy connection open — the
|
||||||
|
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RunicGatewayApp"
|
android:name=".RunicGatewayApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -13,20 +20,61 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.RunicGateway">
|
android:theme="@style/Theme.RunicGateway">
|
||||||
|
|
||||||
|
<!-- singleTop so the SSO Custom Tab returning via the deep link reuses the
|
||||||
|
running task (onNewIntent) instead of stacking a second activity. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.RunicGateway">
|
android:theme="@style/Theme.RunicGateway">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
|
||||||
|
one-time authorization code back to this fixed, app-owned custom
|
||||||
|
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
|
||||||
|
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. This is the permanent
|
||||||
|
fallback on every build (docs/android/APP_LINKS.md). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="runicgateway"
|
||||||
|
android:host="auth"
|
||||||
|
android:path="/callback" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- App Links hardening (docs/android/APP_LINKS.md): a verified https
|
||||||
|
callback that only the domain's real owner can claim. autoVerify
|
||||||
|
needs a literal host, so ${appLinkHost} is baked at build time
|
||||||
|
(build.gradle.kts). The generic build leaves it as the reserved
|
||||||
|
runic-gateway.invalid sentinel — the filter then matches no real
|
||||||
|
link and never verifies. A white-label build sets -PappLinkHost. -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="https"
|
||||||
|
android:host="${appLinkHost}"
|
||||||
|
android:path="/mobile/callback" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
||||||
|
dataSync foreground type; not exported — started only by PushManager. -->
|
||||||
|
<service
|
||||||
|
android:name=".core.push.PushService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
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,62 +3,156 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app
|
package com.runicgateway.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import com.runicgateway.app.core.push.PushNotifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.ui.AppViewModel
|
||||||
|
import com.runicgateway.app.ui.AppViewModel.AppState
|
||||||
|
import com.runicgateway.app.ui.LocalAssetResolver
|
||||||
|
import com.runicgateway.app.ui.RunicApp
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.connect.ConnectScreen
|
||||||
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-activity host. Navigation-Compose and the first-run base-URL flow (§3)
|
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||||
* land in M1; this M0 skeleton only proves the Compose + Hilt + theme wiring.
|
* connect screen until a shard site is configured (§3), then the main app.
|
||||||
|
* The Material theme is resolved from the shard's published appearance (M12),
|
||||||
|
* and asset-path resolution is provided to the whole tree.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
// Native SSO bridge — handles the runicgateway://auth/callback deep link (M9,
|
||||||
|
// §4.2). Field-injected because the callback can arrive independent of any
|
||||||
|
// ViewModel; a successful exchange flips the SessionManager the whole app
|
||||||
|
// observes, and the login screen consumes SsoAuthManager.outcome.
|
||||||
|
@Inject
|
||||||
|
lateinit var ssoAuthManager: SsoAuthManager
|
||||||
|
|
||||||
|
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
|
||||||
|
// Set from the launching intent and from onNewIntent (the activity is singleTop),
|
||||||
|
// consumed once by RunicApp which navigates to the stream's screen.
|
||||||
|
private var pendingStream by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
// The tickle's other half: an opaque ref, carried since M7 and read since
|
||||||
|
// ENGAGEMENT.md phase 8, where a `notification:<id>` ref means the engine wrote
|
||||||
|
// an inbox row and the tap should land there. Never rendered — it is a hint that
|
||||||
|
// something exists, and the app pulls the real item over the authenticated API.
|
||||||
|
private var pendingRef by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||||
|
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
|
||||||
|
handleSsoCallback(intent)
|
||||||
|
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||||
|
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||||
|
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||||
|
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
|
||||||
setContent {
|
setContent {
|
||||||
RunicGatewayTheme {
|
val appViewModel: AppViewModel = hiltViewModel()
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||||
Placeholder(modifier = Modifier.padding(innerPadding))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
|
||||||
private fun Placeholder(modifier: Modifier = Modifier) {
|
// resolved token map is applied field by field over the shipped palette,
|
||||||
Column(
|
// so NONE — before the site is connected, or when settings can't be
|
||||||
modifier = modifier.fillMaxSize(),
|
// read — is the app exactly as it shipped.
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
|
// The admin's theme and nav can change while the app is backgrounded
|
||||||
|
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
|
||||||
|
// re-validation RunicApp already does. Best-effort and silent.
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
appViewModel.refreshAppearance()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
RunicGatewayTheme(appearance = appearance) {
|
||||||
|
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
Text(
|
when (val s = state) {
|
||||||
text = stringResource(id = R.string.app_scaffold_ready),
|
AppState.Loading -> LoadingView()
|
||||||
style = MaterialTheme.typography.titleLarge,
|
AppState.NeedsConnection ->
|
||||||
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||||
|
is AppState.Ready ->
|
||||||
|
RunicApp(
|
||||||
|
appearance = s.appearance,
|
||||||
|
onChangeServer = appViewModel::changeServer,
|
||||||
|
deepLinkStream = pendingStream,
|
||||||
|
deepLinkRef = pendingRef,
|
||||||
|
onDeepLinkConsumed = {
|
||||||
|
pendingStream = null
|
||||||
|
pendingRef = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
/**
|
||||||
@Composable
|
* A notification tap or an SSO callback arriving while the activity is already
|
||||||
private fun PlaceholderPreview() {
|
* running (singleTop) — the common case, since the Custom Tab overlays the live
|
||||||
RunicGatewayTheme {
|
* app during sign-in.
|
||||||
Placeholder()
|
*/
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let {
|
||||||
|
pendingStream = it
|
||||||
|
// Cleared alongside, not conditionally: a tickle with no ref arriving
|
||||||
|
// after one with a ref must not inherit the earlier ref and land on the
|
||||||
|
// inbox instead of its own screen.
|
||||||
|
pendingRef = intent.getStringExtra(PushNotifier.EXTRA_REF)
|
||||||
|
}
|
||||||
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
app/src/main/java/com/runicgateway/app/core/AppConfig.kt
Normal file
14
app/src/main/java/com/runicgateway/app/core/AppConfig.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build-derived flags, injected rather than read from `BuildConfig` directly so
|
||||||
|
* the logic that consumes them (URL validation, etc.) stays plain and unit-testable.
|
||||||
|
*/
|
||||||
|
data class AppConfig(
|
||||||
|
/** Allow plain HTTP base URLs. Debug-only (local dev); release requires HTTPS (§3). */
|
||||||
|
val allowInsecureHttp: Boolean,
|
||||||
|
val versionName: String,
|
||||||
|
)
|
||||||
@@ -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,74 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.inbox
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.inboxDataStore: DataStore<Preferences> by preferencesDataStore(name = "inbox")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [InboxCache] over the same plain DataStore the push state uses. Not secret —
|
||||||
|
* tokens stay in the encrypted store — but an inbox body is a person's own
|
||||||
|
* notifications, which is why the snapshot is owner-scoped and cleared on
|
||||||
|
* sign-out rather than left lying about.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class DataStoreInboxCache @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val json: Json,
|
||||||
|
) : InboxCache {
|
||||||
|
|
||||||
|
private val store = context.inboxDataStore
|
||||||
|
|
||||||
|
override suspend fun read(owner: String): InboxCache.Snapshot? {
|
||||||
|
val raw = store.data.first()[KEY_SNAPSHOT] ?: return null
|
||||||
|
val stored = try {
|
||||||
|
json.decodeFromString(Stored.serializer(), raw)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// A snapshot this build can't parse is a snapshot from an older one;
|
||||||
|
// dropping it silently is right — it will be rewritten on the next pull.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (stored.owner != owner) return null
|
||||||
|
return InboxCache.Snapshot(items = stored.items, unread = stored.unread, savedAt = stored.savedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||||
|
val payload = Stored(
|
||||||
|
owner = owner,
|
||||||
|
items = items.take(InboxCache.MAX_ITEMS),
|
||||||
|
unread = unread,
|
||||||
|
savedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
store.edit { it[KEY_SNAPSHOT] = json.encodeToString(Stored.serializer(), payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
store.edit { it.remove(KEY_SNAPSHOT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class Stored(
|
||||||
|
val owner: String,
|
||||||
|
val items: List<NotificationItemDto>,
|
||||||
|
val unread: Int,
|
||||||
|
val savedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_SNAPSHOT = stringPreferencesKey("snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.inbox
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inbox's offline snapshot (ENGAGEMENT.md phase 8).
|
||||||
|
*
|
||||||
|
* **PLAN.md §7 decided the app ships no Room cache, and that decision stands** —
|
||||||
|
* this is its one named exception, settled with the org lead 2026-08-31. The
|
||||||
|
* inbox is a short, read-only, newest-first list with a server-side cursor and no
|
||||||
|
* joins, so what "works offline" needs is the newest page and the badge, not a
|
||||||
|
* database: one JSON blob in the DataStore the push code already uses. Nothing
|
||||||
|
* here is a source of truth — a successful pull always replaces it, and the
|
||||||
|
* screen says out loud when it is showing this instead.
|
||||||
|
*
|
||||||
|
* **The [owner] key is the security property, not a convenience.** A snapshot is
|
||||||
|
* written under the base URL *and* the account id that produced it and is only
|
||||||
|
* ever handed back to that exact pair, so a cache cannot survive into another
|
||||||
|
* account or another shard — including the sign-out paths that never reach
|
||||||
|
* [clear] at all (a dead refresh token, a server switch). Clearing on logout is
|
||||||
|
* the tidy-up; this is what makes it safe.
|
||||||
|
*
|
||||||
|
* An interface for the same reason [com.runicgateway.app.core.auth.TokenStore] is
|
||||||
|
* one: the storage needs a `Context` and the view models that use it should be
|
||||||
|
* testable without one.
|
||||||
|
*/
|
||||||
|
interface InboxCache {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What was cached for [owner], or null when nothing was — including when the
|
||||||
|
* stored snapshot belongs to a different account or shard, which is the same
|
||||||
|
* answer on purpose.
|
||||||
|
*/
|
||||||
|
suspend fun read(owner: String): Snapshot?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the snapshot with the newest page.
|
||||||
|
*
|
||||||
|
* Only the FIRST page is ever cached, capped at [MAX_ITEMS]: an offline inbox
|
||||||
|
* is there so the last things you were told are still readable on a train, not
|
||||||
|
* so the whole history is. Later pages come from the server or not at all.
|
||||||
|
*/
|
||||||
|
suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int)
|
||||||
|
|
||||||
|
/** Forget everything. Called on sign-out, alongside the push deregistration. */
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
/** What the screen renders from while offline, with the time it was captured. */
|
||||||
|
data class Snapshot(
|
||||||
|
val items: List<NotificationItemDto>,
|
||||||
|
val unread: Int,
|
||||||
|
val savedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The server's own default page size — caching more than it sends is pointless. */
|
||||||
|
const val MAX_ITEMS = 30
|
||||||
|
|
||||||
|
/** The (shard, account) a snapshot belongs to. */
|
||||||
|
fun ownerKey(baseUrl: String?, userId: Long): String = "${baseUrl.orEmpty()}|$userId"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the currently-selected shard website base URL (PLAN.md §3). The API host
|
||||||
|
* is not compiled in: it is chosen on first run, may be changed later under
|
||||||
|
* Settings → Server, and every outbound API request is retargeted onto it by
|
||||||
|
* [HostSelectionInterceptor].
|
||||||
|
*
|
||||||
|
* Threading: the value is read on every network call and written from the
|
||||||
|
* connect/settings flows, so it lives in an [AtomicReference].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class BaseUrlHolder @Inject constructor() {
|
||||||
|
private val ref = AtomicReference<HttpUrl?>(null)
|
||||||
|
|
||||||
|
/** The configured base, or null before first-run connect completes. */
|
||||||
|
val current: HttpUrl? get() = ref.get()
|
||||||
|
|
||||||
|
fun set(url: HttpUrl?) = ref.set(url)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Sentinel host used as Retrofit's compile-time `baseUrl`. Relative
|
||||||
|
* endpoint paths resolve against it; [HostSelectionInterceptor] rewrites
|
||||||
|
* exactly these requests onto [current]. Absolute-URL probe requests use
|
||||||
|
* a real host and are left untouched. `.invalid` is reserved (RFC 6761)
|
||||||
|
* so it can never accidentally resolve on a network.
|
||||||
|
*/
|
||||||
|
const val PLACEHOLDER_HOST = "runic-gateway.invalid"
|
||||||
|
const val PLACEHOLDER_BASE_URL = "https://$PLACEHOLDER_HOST/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retargets each relative API request onto the runtime-selected base URL
|
||||||
|
* (PLAN.md §3). Retrofit is built with a sentinel base host
|
||||||
|
* ([BaseUrlHolder.PLACEHOLDER_HOST]); this interceptor swaps the scheme/host/port
|
||||||
|
* for the configured shard site and prefixes any base path the user included
|
||||||
|
* (`https://host/base`). Absolute-URL requests (the connect probe via `@Url`)
|
||||||
|
* carry a real host and pass through untouched.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class HostSelectionInterceptor @Inject constructor(
|
||||||
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
if (request.url.host != BaseUrlHolder.PLACEHOLDER_HOST) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
val base = baseUrlHolder.current
|
||||||
|
?: throw IOException("No shard website is configured yet.")
|
||||||
|
|
||||||
|
val rewritten = rewriteOntoBase(base, request.url)
|
||||||
|
return chain.proceed(request.newBuilder().url(rewritten).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the sentinel-hosted [requestUrl] against [base] the way a browser
|
||||||
|
* resolves a relative link. [base] is guaranteed to end in "/"
|
||||||
|
* (ServerUrl.normalize), so `https://host/base/` + `api/v1/x` yields
|
||||||
|
* `https://host/base/api/v1/x` — the base path prefix is preserved and no double
|
||||||
|
* slash appears. Extracted as a pure function for unit testing.
|
||||||
|
*/
|
||||||
|
fun rewriteOntoBase(base: HttpUrl, requestUrl: HttpUrl): HttpUrl {
|
||||||
|
val relative = buildString {
|
||||||
|
append(requestUrl.encodedPath.removePrefix("/"))
|
||||||
|
requestUrl.encodedQuery?.let { append('?').append(it) }
|
||||||
|
}
|
||||||
|
return base.resolve(relative) ?: base
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
78
app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt
Normal file
78
app/src/main/java/com/runicgateway/app/core/net/ServerUrl.kt
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and normalizes the base URL a user types on the first-run "Connect to
|
||||||
|
* your shard's website" screen (PLAN.md §3). Pure, dependency-light logic so it
|
||||||
|
* is exercised directly in JVM unit tests.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - accept `https://host[/base]`; a bare `host[/base]` gets an implicit scheme
|
||||||
|
* (`https` normally, so users needn't type it);
|
||||||
|
* - trim surrounding whitespace;
|
||||||
|
* - require HTTPS unless [allowInsecureHttp] (release builds forbid HTTP; debug
|
||||||
|
* allows it for local dev against `127.0.0.1:3000`);
|
||||||
|
* - drop any query/fragment and guarantee a trailing slash on the path so the
|
||||||
|
* stored value composes cleanly with relative endpoint paths.
|
||||||
|
*/
|
||||||
|
object ServerUrl {
|
||||||
|
|
||||||
|
sealed interface Result {
|
||||||
|
data class Valid(val url: HttpUrl) : Result
|
||||||
|
data class Invalid(val reason: Reason) : Result
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Reason {
|
||||||
|
/** Empty or whitespace-only input. */
|
||||||
|
BLANK,
|
||||||
|
|
||||||
|
/** Not a parseable http(s) URL (bad host, illegal characters, …). */
|
||||||
|
MALFORMED,
|
||||||
|
|
||||||
|
/** A scheme other than http/https (e.g. ftp://, ws://). */
|
||||||
|
UNSUPPORTED_SCHEME,
|
||||||
|
|
||||||
|
/** Plain HTTP where the build requires HTTPS. */
|
||||||
|
INSECURE,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalize(raw: String, allowInsecureHttp: Boolean): Result {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isEmpty()) return Result.Invalid(Reason.BLANK)
|
||||||
|
|
||||||
|
// Give a scheme-less entry an implicit, secure default so users can type
|
||||||
|
// just "shard.example.com". An explicit but unsupported scheme is rejected.
|
||||||
|
val hasScheme = SCHEME_RE.containsMatchIn(trimmed)
|
||||||
|
val candidate = if (hasScheme) trimmed else "https://$trimmed"
|
||||||
|
|
||||||
|
val lowerScheme = candidate.substringBefore("://", "").lowercase()
|
||||||
|
if (hasScheme && lowerScheme != "http" && lowerScheme != "https") {
|
||||||
|
return Result.Invalid(Reason.UNSUPPORTED_SCHEME)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = candidate.toHttpUrlOrNull() ?: return Result.Invalid(Reason.MALFORMED)
|
||||||
|
if (parsed.host.isBlank()) return Result.Invalid(Reason.MALFORMED)
|
||||||
|
|
||||||
|
if (parsed.scheme == "http" && !allowInsecureHttp) {
|
||||||
|
return Result.Invalid(Reason.INSECURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild without query/fragment and force a trailing slash so
|
||||||
|
// HttpUrl.resolve()/addPathSegments compose predictably later.
|
||||||
|
val path = parsed.encodedPath.trimEnd('/')
|
||||||
|
val normalized = parsed.newBuilder()
|
||||||
|
.encodedPath(if (path.isEmpty()) "/" else "$path/")
|
||||||
|
.query(null)
|
||||||
|
.fragment(null)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return Result.Valid(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SCHEME_RE = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://")
|
||||||
|
}
|
||||||
@@ -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,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.net
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a stable, identifiable User-Agent on every request. The website mounts a
|
||||||
|
* bot/scanner guard ahead of routing (PLAN.md §8); a native client must present
|
||||||
|
* a sane UA so it is not caught by the scanner heuristics that reject blank or
|
||||||
|
* default agents. Constructed with the app's UA string in the network module.
|
||||||
|
*/
|
||||||
|
class UserAgentInterceptor(
|
||||||
|
private val userAgent: String,
|
||||||
|
) : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.header("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.prefs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import 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.serverDataStore: DataStore<Preferences> by preferencesDataStore(name = "server")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the selected shard website base URL (PLAN.md §3). The base URL is
|
||||||
|
* non-sensitive, so it lives in plain DataStore; tokens (M3) will use
|
||||||
|
* EncryptedSharedPreferences instead, never this store.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ServerPreferences @Inject constructor(
|
||||||
|
@param:dagger.hilt.android.qualifiers.ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val store = context.serverDataStore
|
||||||
|
|
||||||
|
/** Emits the saved base URL, or null before first-run connect completes. */
|
||||||
|
val baseUrl: Flow<String?> = store.data.map { it[KEY_BASE_URL] }
|
||||||
|
|
||||||
|
suspend fun currentBaseUrl(): String? = baseUrl.first()
|
||||||
|
|
||||||
|
suspend fun setBaseUrl(url: String) {
|
||||||
|
store.edit { it[KEY_BASE_URL] = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the base URL — used by a Settings → Server switch (hard reset, §3). */
|
||||||
|
suspend fun clear() {
|
||||||
|
store.edit { it.remove(KEY_BASE_URL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.result
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single result type every repository call returns (PLAN.md §7). The UI
|
||||||
|
* degrades gracefully on a down backend or shard: a repository never throws for
|
||||||
|
* an expected failure, it returns a typed variant the screen can render.
|
||||||
|
*
|
||||||
|
* - [Ok] — a 2xx response with a decoded body.
|
||||||
|
* - [HttpError] — the server answered with a non-2xx status.
|
||||||
|
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
|
||||||
|
*/
|
||||||
|
sealed interface ApiResult<out T> {
|
||||||
|
data class Ok<T>(val data: T) : ApiResult<T>
|
||||||
|
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
|
||||||
|
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the "shard/sidecar down" signal the player screens treat as offline (§6.3, §7). */
|
||||||
|
fun ApiResult<*>.isShardUnavailable(): Boolean =
|
||||||
|
this is ApiResult.HttpError && status == 503
|
||||||
|
|
||||||
|
/** Map an [ApiResult.Ok] body while preserving the failure variants unchanged. */
|
||||||
|
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
|
||||||
|
is ApiResult.Ok -> ApiResult.Ok(transform(data))
|
||||||
|
is ApiResult.HttpError -> this
|
||||||
|
is ApiResult.NetworkError -> this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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())
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
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
|
||||||
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.time
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a timestamp off the wire, in either shape the backend sends.
|
||||||
|
*
|
||||||
|
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
|
||||||
|
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
|
||||||
|
* columns, and one read back as a string reaches the wire as
|
||||||
|
* `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**,
|
||||||
|
* because that is what the server stores — reading it as local time would
|
||||||
|
* silently shift every timestamp by the device's offset, which is a bug that
|
||||||
|
* looks right on the machine it was written on.
|
||||||
|
*
|
||||||
|
* Anything unparseable answers null, and every caller is expected to render
|
||||||
|
* *something* without it: a notification with an odd date is still worth reading,
|
||||||
|
* and an event with one is still worth listing.
|
||||||
|
*
|
||||||
|
* Lives here rather than beside either caller because the trap is the wire's, not
|
||||||
|
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
|
||||||
|
* inherit it (EVENTS.md §I).
|
||||||
|
*/
|
||||||
|
fun parseWireInstant(raw: String?): Instant? {
|
||||||
|
val text = raw?.trim().orEmpty()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
return try {
|
||||||
|
Instant.parse(text)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
try {
|
||||||
|
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
|
||||||
|
*
|
||||||
|
* **These are CORE routes, not a module's**, which is why they live here rather
|
||||||
|
* than beside the shard reads in [PublicApi]: they exist on a backend
|
||||||
|
* with no game module installed at all, and they are gated by core's own `events`
|
||||||
|
* capability rather than by a module's. Nothing here is under `/shard`.
|
||||||
|
*
|
||||||
|
* The three public reads and the one player read share an interface for the same
|
||||||
|
* reason the website mounts them in one feature: the history row's whole purpose
|
||||||
|
* is to link back to the public page. The player call carries a bearer through
|
||||||
|
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
|
||||||
|
* call; there is one Retrofit.
|
||||||
|
*/
|
||||||
|
interface EventsApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public calendar. Defaults to now through 31 days out when neither end
|
||||||
|
* is named; the window may span at most 92 days and the server 400s past it.
|
||||||
|
*
|
||||||
|
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
|
||||||
|
* in the answer, so there is nothing here to re-check.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events")
|
||||||
|
suspend fun getCalendar(
|
||||||
|
@Query("from") from: String? = null,
|
||||||
|
@Query("to") to: String? = null,
|
||||||
|
@Query("seriesId") seriesId: Long? = null,
|
||||||
|
): EventCalendarDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event.
|
||||||
|
*
|
||||||
|
* **[run] selects which occurrence the results table is about**, and is what
|
||||||
|
* an announcement's link carries: the page lives at the definition's slug, so
|
||||||
|
* a weekly event has one address that survives a retitle, while every
|
||||||
|
* `event.` trigger is about one occurrence. A run belonging to some other
|
||||||
|
* event is ignored rather than refused, so a stale link in a months-old mail
|
||||||
|
* still opens the page it was about.
|
||||||
|
*
|
||||||
|
* A draft, an archived definition and an unlisted one all answer 404,
|
||||||
|
* indistinguishable from a slug that never existed.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events/{slug}")
|
||||||
|
suspend fun getEvent(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
@Query("run") run: String? = null,
|
||||||
|
): PublicEventResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc. A series with no listed events answers 404 rather than an empty
|
||||||
|
* page — an arc is a label on its definitions, so a page for an empty one
|
||||||
|
* would publish that an operator has named something they have not announced.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events/series/{slug}")
|
||||||
|
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own participation history. Self-scoped on the session's user
|
||||||
|
* id server-side; there is deliberately no id parameter here, because there
|
||||||
|
* is none on the route.
|
||||||
|
*
|
||||||
|
* [before] is a participation row id, not an offset — the list gains rows at
|
||||||
|
* the top as the reader attends things.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/player/events/history")
|
||||||
|
suspend fun getHistory(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("before") before: Long? = null,
|
||||||
|
): EventHistoryDto
|
||||||
|
}
|
||||||
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,88 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
|
||||||
|
* registration, per-user stream subscriptions, the per-channel preferences that
|
||||||
|
* supersede them (ENGAGEMENT.md phase 3), and the in-app **inbox** — the first
|
||||||
|
* of these that carries content rather than a preference (phase 7/8). Every call rides the
|
||||||
|
* 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
|
||||||
|
|
||||||
|
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────
|
||||||
|
//
|
||||||
|
// The superset of the two calls above: `notification_subscriptions` is now
|
||||||
|
// the push projection of this table and the server fans every write to
|
||||||
|
// either one into the other, so the two cannot disagree.
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/notifications/channels")
|
||||||
|
suspend fun channelPrefs(): NotificationChannelPrefsDto
|
||||||
|
|
||||||
|
/** SPARSE — send only the pairs that changed; everything unnamed is untouched. */
|
||||||
|
@PUT("api/v1/auth/me/notifications/channels")
|
||||||
|
suspend fun putChannelPrefs(
|
||||||
|
@Body body: NotificationChannelPrefsUpdateDto,
|
||||||
|
): NotificationChannelPrefsDto
|
||||||
|
|
||||||
|
// ── The inbox (ENGAGEMENT.md phase 7/8) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Keyset-paged on `before`, never an offset. There is no way to name another
|
||||||
|
// user on any of these: the caller is the only account they can read or write.
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/notifications")
|
||||||
|
suspend fun inbox(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("before") before: Long? = null,
|
||||||
|
@Query("unread") unread: Boolean? = null,
|
||||||
|
): NotificationInboxDto
|
||||||
|
|
||||||
|
@GET("api/v1/auth/me/notifications/unread-count")
|
||||||
|
suspend fun unreadCount(): NotificationUnreadDto
|
||||||
|
|
||||||
|
/** Idempotent; 404 both for a missing item and for another account's. */
|
||||||
|
@POST("api/v1/auth/me/notifications/{id}/read")
|
||||||
|
suspend fun markRead(@Path("id") id: Long): NotificationReadResultDto
|
||||||
|
|
||||||
|
@POST("api/v1/auth/me/notifications/read-all")
|
||||||
|
suspend fun markAllRead(): NotificationReadResultDto
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
222
app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt
Normal file
222
app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AtlasMetaDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||||
|
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.MarketMetaDto
|
||||||
|
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
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. 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 {
|
||||||
|
|
||||||
|
// ── First-run probe (absolute URL; bypasses host rewriting) ──────────
|
||||||
|
/**
|
||||||
|
* Validates a candidate site during the first-run connect flow (§3). Takes a
|
||||||
|
* fully-qualified URL so the request carries a real host and is left
|
||||||
|
* untouched by the host interceptor — the probe targets the URL the user
|
||||||
|
* just typed, not the (not-yet-configured) base.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
suspend fun probeStatus(@Url absoluteStatusUrl: String): StatusDto
|
||||||
|
|
||||||
|
// ── Site status / settings ───────────────────────────────────────────
|
||||||
|
@GET("api/v1/public/status")
|
||||||
|
suspend fun getStatus(): StatusDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/settings")
|
||||||
|
suspend fun getSettings(): SettingsDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which modules this backend is serving, and the capabilities each declares
|
||||||
|
* (§5, M13). Read together with the `version` block's own `capabilities` —
|
||||||
|
* core's list and a module's are separate lists on purpose.
|
||||||
|
*
|
||||||
|
* This is what lets the app tell a module that is **not installed** from a
|
||||||
|
* lookup that failed: `/public/shard/features` 404s in both cases, and only
|
||||||
|
* this call distinguishes them.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/modules")
|
||||||
|
suspend fun getModules(): ModulesDto
|
||||||
|
|
||||||
|
// ── News & content ───────────────────────────────────────────────────
|
||||||
|
@GET("api/v1/public/posts/{category}")
|
||||||
|
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/posts/{category}/{idOrSlug}")
|
||||||
|
suspend fun getPost(
|
||||||
|
@Path("category") category: String,
|
||||||
|
@Path("idOrSlug") idOrSlug: String,
|
||||||
|
): PostDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/pages/{slug}")
|
||||||
|
suspend fun getPage(@Path("slug") slug: String): PageDto
|
||||||
|
|
||||||
|
// ── Wiki ─────────────────────────────────────────────────────────────
|
||||||
|
@GET("api/v1/public/wiki")
|
||||||
|
suspend fun getWikiPages(
|
||||||
|
@Query("q") query: String? = null,
|
||||||
|
@Query("category") category: String? = null,
|
||||||
|
@Query("tag") tag: String? = null,
|
||||||
|
): List<WikiSummaryDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/wiki/categories")
|
||||||
|
suspend fun getWikiCategories(): List<WikiCategoryDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/wiki/tags")
|
||||||
|
suspend fun getWikiTags(): List<WikiTagDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/wiki/{slug}")
|
||||||
|
suspend fun getWikiPage(@Path("slug") slug: String): WikiPageDto
|
||||||
|
|
||||||
|
// ── Contact ──────────────────────────────────────────────────────────
|
||||||
|
@POST("api/v1/public/contact")
|
||||||
|
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||||
|
|
||||||
|
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Which shard features this caller may reach, so the menu hides entries instead
|
||||||
|
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
|
||||||
|
* call and a signed-in one can differ.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/shard/features")
|
||||||
|
suspend fun getShardFeatures(): ShardFeaturesDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/status")
|
||||||
|
suspend fun getShardStatus(): ShardStatusDto
|
||||||
|
|
||||||
|
@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>
|
||||||
|
|
||||||
|
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
|
||||||
|
//
|
||||||
|
// Each of these sits behind the website's `requireFeature` gate: a 404 means the
|
||||||
|
// shard doesn't publish it and a 403 means this viewer is below its audience rung,
|
||||||
|
// which `toShardUiState()` folds into one "not available here" state.
|
||||||
|
|
||||||
|
/** The shard's configured ruleset. A `null` body means "not published yet". */
|
||||||
|
@GET("api/v1/public/shard/ruleset")
|
||||||
|
suspend fun getShardRuleset(): RulesetDto?
|
||||||
|
|
||||||
|
/** Every points/loyalty leaderboard the shard publishes. */
|
||||||
|
@GET("api/v1/public/shard/points")
|
||||||
|
suspend fun getShardPoints(): List<PointsBoardDto>
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/points/{system}")
|
||||||
|
suspend fun getShardPointsBoard(@Path("system") system: String): PointsBoardDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the player-vendor index. **Rate-limited** — the first genuinely expensive
|
||||||
|
* public endpoint on the site, so handle `429` (`ErrorKind.RATE_LIMITED`).
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/shard/market")
|
||||||
|
suspend fun getShardMarket(
|
||||||
|
@Query("q") query: String? = null,
|
||||||
|
@Query("minPrice") minPrice: Long? = null,
|
||||||
|
@Query("maxPrice") maxPrice: Long? = null,
|
||||||
|
@Query("map") map: String? = null,
|
||||||
|
@Query("region") region: String? = null,
|
||||||
|
@Query("sort") sort: String? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("offset") offset: Int? = null,
|
||||||
|
): MarketPageDto
|
||||||
|
|
||||||
|
/** Index size, staleness, and which facets/regions actually hold vendors. */
|
||||||
|
@GET("api/v1/public/shard/market/meta")
|
||||||
|
suspend fun getShardMarketMeta(): MarketMetaDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/shard/market/vendors/{serial}")
|
||||||
|
suspend fun getShardMarketVendor(
|
||||||
|
@Path("serial") serial: String,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("offset") offset: Int? = null,
|
||||||
|
): MarketVendorDto
|
||||||
|
|
||||||
|
// The atlas lives under /public/atlas, NOT /public/shard: it is static shard
|
||||||
|
// content parsed from the server's data files, so it stays readable while the
|
||||||
|
// shard is down — but it IS site-mode gated, unlike the shard routes.
|
||||||
|
@GET("api/v1/public/atlas/creatures")
|
||||||
|
suspend fun getAtlasCreatures(
|
||||||
|
@Query("q") query: String? = null,
|
||||||
|
@Query("facet") facet: String? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("offset") offset: Int? = null,
|
||||||
|
): AtlasCreaturePageDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/atlas/creatures/{slug}")
|
||||||
|
suspend fun getAtlasCreature(@Path("slug") slug: String): AtlasCreatureDto
|
||||||
|
|
||||||
|
@GET("api/v1/public/atlas/meta")
|
||||||
|
suspend fun getAtlasMeta(): AtlasMetaDto
|
||||||
|
}
|
||||||
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,26 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `POST /public/contact` request body. */
|
||||||
|
@Serializable
|
||||||
|
data class ContactRequest(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /public/contact` response (website `mailer.sendContactMessage`). Either
|
||||||
|
* `{ sent: true }`, or `{ sent: false, fallback: "mailto", email }` when the
|
||||||
|
* site has no mailer configured and the user should email directly instead.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ContactResponse(
|
||||||
|
val sent: Boolean = false,
|
||||||
|
val fallback: String? = null,
|
||||||
|
val email: String? = null,
|
||||||
|
)
|
||||||
212
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
212
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
|
||||||
|
* Phase 14a; the app's half is M13). Field names match
|
||||||
|
* `server/src/model/events/eventPublic.model.js` exactly.
|
||||||
|
*
|
||||||
|
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
|
||||||
|
* the server side is spread into a public entry — a field reaches one because a
|
||||||
|
* line put it there — so three things are absent from every shape below and each
|
||||||
|
* absence is a decision core made: the **spec** (phases, steps, actions and their
|
||||||
|
* params are the operator's plan for changing a live world; a visitor gets the
|
||||||
|
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
|
||||||
|
* and errors** (facts about the deployment's plumbing, not about the event), and
|
||||||
|
* **`member_key`** (module-opaque, so core cannot say what publishing one would
|
||||||
|
* disclose). Adding a field here that the server does not send would decode to a
|
||||||
|
* default and render as a fact.
|
||||||
|
*
|
||||||
|
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
|
||||||
|
* backend field is safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
|
||||||
|
* differently on purpose.
|
||||||
|
*
|
||||||
|
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
|
||||||
|
* what it says is committed to. A **projected** entry is arithmetic past the
|
||||||
|
* materialisation horizon — a forecast with nothing behind it — so the screen
|
||||||
|
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
|
||||||
|
* only ever arrive on a projection, and say a DST shift moved it.
|
||||||
|
*
|
||||||
|
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||||
|
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventCalendarEntryDto(
|
||||||
|
val kind: String = "run",
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesSlug: String? = null,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val timezone: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val live: Boolean = false,
|
||||||
|
val adjusted: Boolean = false,
|
||||||
|
val shiftMinutes: Int = 0,
|
||||||
|
) {
|
||||||
|
/** True for a forecast the server has committed nothing to. */
|
||||||
|
val isProjected: Boolean get() = kind == "projected"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /public/events` — the calendar for a window, ascending by instant. */
|
||||||
|
@Serializable
|
||||||
|
data class EventCalendarDto(
|
||||||
|
val entries: List<EventCalendarEntryDto> = emptyList(),
|
||||||
|
/** True when the server capped the answer; the screen says so rather than lying by omission. */
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence on an event's page.
|
||||||
|
*
|
||||||
|
* [phase] is the label of the phase a live run is in, resolved from the version
|
||||||
|
* that run PINNED — so an edit since does not relabel a run in flight. It is null
|
||||||
|
* on anything that is not live, which is why the screen only ever shows it there.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventOccurrenceDto(
|
||||||
|
val runId: Long = 0,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val timezone: String? = null,
|
||||||
|
val startedAt: String? = null,
|
||||||
|
val endedAt: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val live: Boolean = false,
|
||||||
|
val scope: String? = null,
|
||||||
|
val phase: String? = null,
|
||||||
|
val resultsPublishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a published results table.
|
||||||
|
*
|
||||||
|
* [name] is whatever the module put in its participation `meta`, and there is
|
||||||
|
* genuinely nothing else to render when it is absent: core has no name for a
|
||||||
|
* character and the member key is not published, so the screen says "Unnamed"
|
||||||
|
* rather than inventing one.
|
||||||
|
*
|
||||||
|
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
|
||||||
|
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
|
||||||
|
* writes a fraction — the live walk found `318.5` in the first row it read.
|
||||||
|
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
|
||||||
|
* response fails to decode, and the screen reports a server error for a `200`.
|
||||||
|
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventParticipantDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val score: Double = 0.0,
|
||||||
|
val rank: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The results table for ONE occurrence, present only once it has been published. */
|
||||||
|
@Serializable
|
||||||
|
data class EventResultsDto(
|
||||||
|
val runId: Long = 0,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val publishedAt: String? = null,
|
||||||
|
val participants: List<EventParticipantDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The arc an event belongs to, as its own page names it. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesRefDto(
|
||||||
|
val name: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/events/:slug` — the event. */
|
||||||
|
@Serializable
|
||||||
|
data class PublicEventDto(
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val summary: String? = null,
|
||||||
|
/** Sanitized HTML, written the way a wiki page and a forum post are. */
|
||||||
|
val body: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
val series: EventSeriesRefDto? = null,
|
||||||
|
val live: Boolean = false,
|
||||||
|
val current: EventOccurrenceDto? = null,
|
||||||
|
/**
|
||||||
|
* The next occurrence — **narrower than the first of [upcoming]**, and the
|
||||||
|
* server decides which. A cancelled occurrence still appears under what is
|
||||||
|
* coming, because "next Friday is off" is what somebody checking a calendar
|
||||||
|
* came to find out; it is not what "next" means.
|
||||||
|
*/
|
||||||
|
val next: EventOccurrenceDto? = null,
|
||||||
|
val upcoming: List<EventOccurrenceDto> = emptyList(),
|
||||||
|
val past: List<EventOccurrenceDto> = emptyList(),
|
||||||
|
val results: EventResultsDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope `GET /public/events/:slug` answers with. */
|
||||||
|
@Serializable
|
||||||
|
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
|
||||||
|
|
||||||
|
/** One event as an arc lists it — the editor's order, so no dates. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesEntryDto(
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val summary: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesDto(
|
||||||
|
val name: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
val events: List<EventSeriesEntryDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope `GET /public/events/series/:slug` answers with. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the caller's own participation history.
|
||||||
|
*
|
||||||
|
* [rank] is null until `core.results.publish` ran for that occurrence, and that
|
||||||
|
* is a real state rather than an error — the screen says "not published" rather
|
||||||
|
* than rendering a dash that reads as a bug.
|
||||||
|
*
|
||||||
|
* [id] is the participation row's own id and is what the keyset page walks back
|
||||||
|
* on: the list gains a row every time the reader attends something, so an offset
|
||||||
|
* would skip and repeat.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventHistoryEntryDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val runId: Long = 0,
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesSlug: String? = null,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val startedAt: String? = null,
|
||||||
|
val endedAt: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val joinedAt: String? = null,
|
||||||
|
// Fractional, for the reason [EventParticipantDto.score] gives.
|
||||||
|
val score: Double = 0.0,
|
||||||
|
val rank: Int? = null,
|
||||||
|
val resultsPublishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /player/events/history` — self-scoped, one page. */
|
||||||
|
@Serializable
|
||||||
|
data class EventHistoryDto(
|
||||||
|
val entries: List<EventHistoryEntryDto> = emptyList(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/*
|
||||||
|
* 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>,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── The in-app channel (ENGAGEMENT.md phase 7/8) ───────────────────────────
|
||||||
|
//
|
||||||
|
// The inbox is the first notification surface that carries CONTENT. Everything
|
||||||
|
// above is a preference or a content-free tickle; these four shapes are the
|
||||||
|
// items themselves, pulled over the authenticated API after a tickle wakes the
|
||||||
|
// app. The wire names come from `userNotifications.db.js`'s `toItem`.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inbox item. [read] is the flag and [readAt] the stamp, sent side by side so
|
||||||
|
* a client renders one without parsing the other.
|
||||||
|
*
|
||||||
|
* [url] is where the item points on the site (rendered from the template's
|
||||||
|
* `email.button` block) and is **null on most items** — an inbox row is complete
|
||||||
|
* on its own. [triggerId] is the event that produced it, in §7.2's ONE namespace,
|
||||||
|
* so it is the same vocabulary a push tickle's `stream` speaks.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationItemDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val triggerId: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val body: String? = null,
|
||||||
|
val url: String? = null,
|
||||||
|
val read: Boolean = false,
|
||||||
|
val readAt: String? = null,
|
||||||
|
val createdAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /auth/me/notifications` — one page, newest first.
|
||||||
|
*
|
||||||
|
* Keyset-paged: the next page is `?before=<the last item's id>`, not an offset,
|
||||||
|
* because the list gains rows at the top while it is being read. [hasMore] comes
|
||||||
|
* from the server's take+1, so "is there another page" costs no second query.
|
||||||
|
* [unread] counts the WHOLE inbox, not the page — it rides along so a screen
|
||||||
|
* rendering both a badge and a list from one response cannot show the two
|
||||||
|
* disagreeing.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationInboxDto(
|
||||||
|
val items: List<NotificationItemDto> = emptyList(),
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val unread: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /auth/me/notifications/unread-count` — the badge, on its own. */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationUnreadDto(
|
||||||
|
val unread: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What both mark-read routes answer with. [unread] is the count AFTER the write,
|
||||||
|
* so the badge follows from the response rather than from a second call.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationReadResultDto(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
val changed: Int = 0,
|
||||||
|
val unread: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One delivery channel from the registry. [modes] is what this channel accepts —
|
||||||
|
* `["off","instant"]` for push and in-app, `["off","instant","digest"]` for email
|
||||||
|
* — and the UI renders its control from THIS, never from a hardcoded set, so a
|
||||||
|
* channel added server-side arrives without an app release.
|
||||||
|
*
|
||||||
|
* [carriesContent] is the tickle invariant stated on the wire: push is `false`,
|
||||||
|
* which is why a push item's title never leaves the server.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelDto(
|
||||||
|
val id: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
val carriesContent: Boolean = false,
|
||||||
|
val defaultMode: String = "off",
|
||||||
|
val supportsDigest: Boolean = false,
|
||||||
|
val modes: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One subscribable id, from `GET /auth/me/notifications/channels`. The list is the
|
||||||
|
* UNION of push streams and event triggers in one namespace (§7.2), so an id may
|
||||||
|
* be a stream, a trigger, or both.
|
||||||
|
*
|
||||||
|
* [channels] is which channels apply to THIS id — a trigger-only id carries no
|
||||||
|
* `push` because nothing is registered to push it — and [modes] is the EFFECTIVE
|
||||||
|
* mode per channel: where the user has expressed nothing the server has already
|
||||||
|
* substituted that channel's default, and the client must not re-implement the
|
||||||
|
* defaulting.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelItemDto(
|
||||||
|
val id: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val personal: Boolean = false,
|
||||||
|
val requiresLinkedAccount: Boolean = false,
|
||||||
|
val ceiling: String? = null,
|
||||||
|
val channels: List<String> = emptyList(),
|
||||||
|
val modes: Map<String, String> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelPrefsDto(
|
||||||
|
val channels: List<NotificationChannelDto> = emptyList(),
|
||||||
|
val items: List<NotificationChannelItemDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One (id, channel) → mode pair of a sparse update. */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelPrefDto(
|
||||||
|
val id: String,
|
||||||
|
val channel: String,
|
||||||
|
val mode: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /auth/me/notifications/channels` body — a SPARSE update: only the pairs
|
||||||
|
* named are written and every other pair is left alone, so one toggle saves
|
||||||
|
* without the screen holding the whole table.
|
||||||
|
*
|
||||||
|
* [prefs] has no default for the same reason [NotificationSubscriptionsDto.streams]
|
||||||
|
* has none — kotlinx omits a property equal to its default, and the validator
|
||||||
|
* requires the field. Unlike that DTO there is no empty-set case to get wrong
|
||||||
|
* here: `off` is a mode, never an omission.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationChannelPrefsUpdateDto(
|
||||||
|
val prefs: List<NotificationChannelPrefDto>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* 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.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/pages/:slug` — a block-based CMS page (website `pages.model.js`
|
||||||
|
* `serialize`). Everything block-specific lives inside `props`; the renderer
|
||||||
|
* dispatches on `type`. `props` is left as a raw JSON object so new block types
|
||||||
|
* or props never break decoding — the renderer reads the keys it knows and
|
||||||
|
* ignores the rest.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PageDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
val blocks: List<BlockDto> = emptyList(),
|
||||||
|
@SerialName("publishedAt") val publishedAt: String? = null,
|
||||||
|
@SerialName("updatedAt") val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One CMS block. Known types (website `src/blocks/types`): `heading`,
|
||||||
|
* `rich_text`, `image`, `quote`, `cta`, `divider`, `two_column`. Container
|
||||||
|
* blocks (`two_column`) hold sub-block arrays inside their props.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class BlockDto(
|
||||||
|
val type: String = "",
|
||||||
|
val props: JsonObject = JsonObject(emptyMap()),
|
||||||
|
val visible: Boolean = true,
|
||||||
|
)
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
/*
|
||||||
|
* 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(),
|
||||||
|
/**
|
||||||
|
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
|
||||||
|
* earned nothing anywhere — the shard omits systems the character has no entry in
|
||||||
|
* — and empty on a shard whose plugin predates 3.0.
|
||||||
|
*
|
||||||
|
* Served **ungated**: a character's own standings are self-service data on
|
||||||
|
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
|
||||||
|
* feature being visible. Don't re-gate them app-side.
|
||||||
|
*/
|
||||||
|
val points: List<CharPointsDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One point system a character holds a score in (Protocol 3.0 §7.3).
|
||||||
|
*
|
||||||
|
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
|
||||||
|
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
|
||||||
|
*
|
||||||
|
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
|
||||||
|
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
|
||||||
|
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
|
||||||
|
* Nothing may divide by it, and a full-width progress bar for an uncapped score
|
||||||
|
* would imply a completion that doesn't exist.
|
||||||
|
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
|
||||||
|
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
|
||||||
|
* Loyalty") is the PRIMARY display path, not a defensive fallback.
|
||||||
|
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
|
||||||
|
* Absent and "unranked" are different answers, so it renders only when sent.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CharPointsDto(
|
||||||
|
val system: String? = null,
|
||||||
|
val nameString: String? = null,
|
||||||
|
val points: Long? = null,
|
||||||
|
val maxPoints: Long? = null,
|
||||||
|
val rank: Int? = null,
|
||||||
|
) {
|
||||||
|
/** The cap, or null when the system is uncapped (see [maxPoints]). */
|
||||||
|
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CharStatsDto(
|
||||||
|
val str: Int? = null,
|
||||||
|
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,
|
||||||
|
/**
|
||||||
|
* A player-given name — set for the minority of items someone has renamed, null
|
||||||
|
* for almost everything else. The shard sends the plain `Item.Name` field; it
|
||||||
|
* never builds a display name (that call is a packet builder, not a field read).
|
||||||
|
*/
|
||||||
|
val name: String? = null,
|
||||||
|
/**
|
||||||
|
* The item's type name, resolved from its cliloc id **by the website** against
|
||||||
|
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
|
||||||
|
* table configured, which is fully supported — the sheet then falls back to the
|
||||||
|
* layer, exactly as it did before the table existed.
|
||||||
|
*/
|
||||||
|
val clilocName: String? = null,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* What to call this item.
|
||||||
|
*
|
||||||
|
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
|
||||||
|
* not be relabelled "hatchet" — and the server applies the same precedence, so
|
||||||
|
* this only re-states it for an item that arrived with both.
|
||||||
|
*/
|
||||||
|
val label: String? get() = name ?: clilocName ?: layer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
|
||||||
|
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
|
||||||
|
*
|
||||||
|
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
|
||||||
|
* into words against its cliloc table — same length and order as [reward], with a null
|
||||||
|
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
|
||||||
|
* the shard has no cliloc table, so read it positionally and tolerate it being short.
|
||||||
|
* See `displayTitles` in the character sheet.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TitlesDto(
|
||||||
|
val selected: Int? = null,
|
||||||
|
val reward: List<String> = emptyList(),
|
||||||
|
val rewardResolved: List<String?> = emptyList(),
|
||||||
|
val fameKarma: String? = null,
|
||||||
|
val skill: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/posts/:category` (list) and `/public/posts/:category/:idOrSlug`
|
||||||
|
* (detail). One shape serves both; the list omits nothing the app renders. The
|
||||||
|
* `body` (HTML) is present on the detail response and rendered there.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PostDto(
|
||||||
|
val id: Long,
|
||||||
|
/** Stored DB category (`news | five_on_friday | newsletter | screenshot`). */
|
||||||
|
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,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String? = null,
|
||||||
|
)
|
||||||
149
app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt
Normal file
149
app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
||||||
|
* responses (website `public.controller.js` + `settings.model.js`); unknown
|
||||||
|
* keys are ignored by the JSON parser so additive backend fields never break
|
||||||
|
* decoding (PLAN.md §8 "additive, v1").
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /public/version` and the `version` block embedded in `/public/status`. */
|
||||||
|
@Serializable
|
||||||
|
data class VersionDto(
|
||||||
|
val service: String = "",
|
||||||
|
val api: String = "",
|
||||||
|
val server: String = "",
|
||||||
|
/**
|
||||||
|
* What CORE serves beyond the baseline every backend has (events Phase 14a;
|
||||||
|
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
|
||||||
|
* `GET /public/modules` so a client feature-detects one way, and a **separate
|
||||||
|
* list** because core is not a module.
|
||||||
|
*
|
||||||
|
* **The value is in what is absent**, which is why the default is empty
|
||||||
|
* rather than something meaningful: a backend released before a capability
|
||||||
|
* existed omits the key entirely, and that is how the app tells an older site
|
||||||
|
* from one that simply has nothing to show. An unknown string is absent, and
|
||||||
|
* no route may be inferred from one.
|
||||||
|
*/
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed, **started** module on `GET /public/modules`.
|
||||||
|
*
|
||||||
|
* A module that is disabled or failed to load is absent rather than listed with a
|
||||||
|
* state — its routes and its nav are absent too, so a client renders a site
|
||||||
|
* without that capability rather than one advertising a capability that 503s.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class InstalledModuleDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val version: String = "",
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/modules` — what this backend is serving beyond core.
|
||||||
|
*
|
||||||
|
* Database-free and never gated by site mode, so the app can feature-detect
|
||||||
|
* during maintenance. A **500** is the one answer that is not an answer: core
|
||||||
|
* refuses to return `[]` for a list read before its loader ran, because a caller
|
||||||
|
* cannot tell an empty list from a mis-ordered boot.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModulesDto(
|
||||||
|
val modules: List<InstalledModuleDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
||||||
|
@Serializable
|
||||||
|
data class StatusDto(
|
||||||
|
val mode: String = "live",
|
||||||
|
@SerialName("status_message") val statusMessage: String = "",
|
||||||
|
val version: VersionDto = VersionDto(),
|
||||||
|
) {
|
||||||
|
val isMaintenance: Boolean get() = mode.equals("maintenance", ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-shard branding block the app themes itself from (§3, §6.1). */
|
||||||
|
@Serializable
|
||||||
|
data class BrandDto(
|
||||||
|
val name: String = "",
|
||||||
|
val shortName: String = "",
|
||||||
|
val tagline: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
val contactEmail: String = "",
|
||||||
|
val url: String = "",
|
||||||
|
/** Seed/accent color as a hex string, e.g. "#7f99bd". */
|
||||||
|
val accent: String = "",
|
||||||
|
/** Asset URL or site-relative path; empty = none. Resolve against the base URL. */
|
||||||
|
val logo: String = "",
|
||||||
|
val hero: String = "",
|
||||||
|
val favicon: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Derived, public-safe registration availability flags. */
|
||||||
|
@Serializable
|
||||||
|
data class RegistrationFlagsDto(
|
||||||
|
val password: Boolean = false,
|
||||||
|
val sso: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push-notification relay config (M7). [ntfyUrl] is the client-facing ntfy base
|
||||||
|
* URL the app's embedded distributor registers its device topic against; null (or
|
||||||
|
* absent, on an older backend) means push isn't configured for this shard and the
|
||||||
|
* Notifications screen shows it as unavailable.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PushConfigDto(
|
||||||
|
val ntfyUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
|
||||||
|
* app consumes are modeled; other whitelisted keys are ignored.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SettingsDto(
|
||||||
|
@SerialName("site_title") val siteTitle: String? = null,
|
||||||
|
@SerialName("status_message") val statusMessage: String? = null,
|
||||||
|
@SerialName("maintenance_message") val maintenanceMessage: String? = null,
|
||||||
|
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(),
|
||||||
|
/**
|
||||||
|
* The admin's **resolved** theme tokens — the CSS custom properties the site
|
||||||
|
* paints, already layered `:root ← preset ← custom` by the server
|
||||||
|
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
|
||||||
|
* means "the shipped defaults" and is the untouched-instance path.
|
||||||
|
*
|
||||||
|
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
|
||||||
|
* purpose: a single unexpected value must not fail the decode of the whole
|
||||||
|
* settings payload and take `brand` and `push` down with it. It is coerced
|
||||||
|
* field-by-field by `SiteAppearance.from`.
|
||||||
|
*
|
||||||
|
* The raw `theme_visual` / `brand_assets` rows ride along in this same
|
||||||
|
* response and are deliberately **not** modeled — they are inputs, and
|
||||||
|
* re-deriving a palette from them would be a second `resolveThemeTokens` in
|
||||||
|
* Kotlin, guaranteed to drift (§3).
|
||||||
|
*/
|
||||||
|
val theme: JsonElement? = null,
|
||||||
|
/**
|
||||||
|
* The public nav overrides, as the raw JSON **string** stored in
|
||||||
|
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
|
||||||
|
* client's `parseJsonSetting` does. Absent when the admin never edited the
|
||||||
|
* nav.
|
||||||
|
*/
|
||||||
|
@SerialName("nav_public") val navPublic: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11):
|
||||||
|
* the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn
|
||||||
|
* atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas.
|
||||||
|
* controller.js` responses; see `docs/link/v3.md` §5–§8.
|
||||||
|
*
|
||||||
|
* Every field is nullable-with-a-default, which is load-bearing rather than merely
|
||||||
|
* defensive here: an admin can gate individual fields away per audience rung
|
||||||
|
* (`ownerName`, `location`, a board's `name`), so a response legitimately arrives
|
||||||
|
* with them missing and must still decode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Ruleset (§5) ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/shard/ruleset` — what this shard's world is configured to do.
|
||||||
|
*
|
||||||
|
* Every block is optional and omitted when its system is off, so a null block means
|
||||||
|
* "not applicable here", not "unknown". A `null` BODY (rather than an empty object)
|
||||||
|
* means the shard has never published a ruleset — distinct from the feature being
|
||||||
|
* switched off, which is a 404.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RulesetDto(
|
||||||
|
val shard: String? = null,
|
||||||
|
val expansion: String? = null,
|
||||||
|
/**
|
||||||
|
* The public connect address, published only when the operator set one. It is
|
||||||
|
* also the ruleset's one admin-configurable field, so it can be present for a
|
||||||
|
* signed-in viewer and absent for an anonymous one.
|
||||||
|
*/
|
||||||
|
val connect: String? = null,
|
||||||
|
/** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */
|
||||||
|
val systems: Map<String, Boolean> = emptyMap(),
|
||||||
|
val caps: RulesetCapsDto? = null,
|
||||||
|
val accounts: RulesetAccountsDto? = null,
|
||||||
|
val housing: RulesetHousingDto? = null,
|
||||||
|
val vetRewards: RulesetVetRewardsDto? = null,
|
||||||
|
val vendors: RulesetVendorsDto? = null,
|
||||||
|
val vvv: RulesetVvvDto? = null,
|
||||||
|
val store: RulesetStoreDto? = null,
|
||||||
|
val schedule: RulesetScheduleDto? = null,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill and stat caps.
|
||||||
|
*
|
||||||
|
* **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores
|
||||||
|
* them, and the raw number is actively misleading rather than merely unhelpful (a
|
||||||
|
* "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap]
|
||||||
|
* and [totalSkillCap]. The stat caps below them are plain values.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RulesetCapsDto(
|
||||||
|
val skill: Int? = null,
|
||||||
|
val totalSkill: Int? = null,
|
||||||
|
val stat: Int? = null,
|
||||||
|
val str: Int? = null,
|
||||||
|
val dex: Int? = null,
|
||||||
|
val int: Int? = null,
|
||||||
|
val strMax: Int? = null,
|
||||||
|
val dexMax: Int? = null,
|
||||||
|
val intMax: Int? = null,
|
||||||
|
) {
|
||||||
|
val skillCap: Double? get() = skill?.let { it / 10.0 }
|
||||||
|
val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetAccountsDto(
|
||||||
|
val perIp: Int? = null,
|
||||||
|
val charSlots: Int? = null,
|
||||||
|
val autoCreate: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetHousingDto(val accountHouseLimit: Int? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetVetRewardsDto(
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val rewardIntervalDays: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetVendorsDto(
|
||||||
|
val restockDelayMinutes: Int? = null,
|
||||||
|
val maxSell: Int? = null,
|
||||||
|
val economyStockAmount: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetVvvDto(
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val startSilver: Int? = null,
|
||||||
|
val enhancedRules: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetStoreDto(
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val currencyName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RulesetScheduleDto(
|
||||||
|
val autoSaveFrequencyMinutes: Int? = null,
|
||||||
|
val autoRestartEnabled: Boolean? = null,
|
||||||
|
val autoRestartHour: Int? = null,
|
||||||
|
val autoRestartMinute: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Leaderboards (§7) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One point system's board (`GET /public/shard/points`, `/points/:system`).
|
||||||
|
*
|
||||||
|
* [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is
|
||||||
|
* usually null because most systems name themselves with a cliloc — the same two
|
||||||
|
* traps as [CharPointsDto], documented in full there.
|
||||||
|
*
|
||||||
|
* [players] counts players actually *holding* points, not the entry count: ten of the
|
||||||
|
* shard's systems auto-add a zero-point row for every character ever created, so the
|
||||||
|
* raw count would report the whole census as one system's participants.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PointsBoardDto(
|
||||||
|
val system: String? = null,
|
||||||
|
val nameString: String? = null,
|
||||||
|
val nameNumber: Int? = null,
|
||||||
|
val maxPoints: Long? = null,
|
||||||
|
val players: Int? = null,
|
||||||
|
val showOnGump: Boolean = true,
|
||||||
|
val top: List<PointsEntryDto> = emptyList(),
|
||||||
|
val t: Long? = null,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
) {
|
||||||
|
/** The cap, or null when the system is uncapped. */
|
||||||
|
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ranked character on a board. [name] is admin-configurable (the `leaderboards`
|
||||||
|
* feature's one field rule), so a shard can publish standings without naming who
|
||||||
|
* holds them — a rank with no name is a valid row, not a broken one.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PointsEntryDto(
|
||||||
|
val rank: Int? = null,
|
||||||
|
val serial: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val points: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Marketplace (§8) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a shop stands. **Nested, not flattened**, on the wire and in the read model
|
||||||
|
* alike, so that ONE admin rule hides the facet, the coordinates, the region and the
|
||||||
|
* house together — five flat keys would be five rules that drift apart (`v3.md` §8.8).
|
||||||
|
* A null location means an admin gated it away; render that as an answer, not a blank.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MarketLocationDto(
|
||||||
|
val map: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val z: Int? = null,
|
||||||
|
val region: String? = null,
|
||||||
|
val house: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The shop a listing belongs to, as embedded in a search result. */
|
||||||
|
@Serializable
|
||||||
|
data class MarketVendorRefDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val shopName: String? = null,
|
||||||
|
val ownerName: String? = null,
|
||||||
|
val location: MarketLocationDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One item for sale. [displayName] is resolved server-side against the site's cliloc
|
||||||
|
* table, preferring a player-set [name]; a shard with no cliloc table configured sends
|
||||||
|
* neither and the item renders by id.
|
||||||
|
*
|
||||||
|
* [child] marks an item priced by an enclosing container rather than itself, exactly
|
||||||
|
* as the in-game Vendor Search reports it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MarketListingDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val itemId: Int? = null,
|
||||||
|
val hue: Int? = null,
|
||||||
|
val amount: Int? = null,
|
||||||
|
val price: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val cliloc: Int? = null,
|
||||||
|
val displayName: String? = null,
|
||||||
|
val child: Boolean = false,
|
||||||
|
val vendor: MarketVendorRefDto? = null,
|
||||||
|
) {
|
||||||
|
/** What to call this item; null when the shard publishes no name for it. */
|
||||||
|
val label: String? get() = name ?: displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of search results (`GET /public/shard/market`).
|
||||||
|
*
|
||||||
|
* Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much"
|
||||||
|
* is the question, and a vendor-shaped result would make every caller flatten the
|
||||||
|
* shops back out.
|
||||||
|
*
|
||||||
|
* [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The
|
||||||
|
* shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old;
|
||||||
|
* a page implying live prices sends someone to an item that sold twenty minutes ago.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MarketPageDto(
|
||||||
|
val listings: List<MarketListingDto> = emptyList(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val limit: Int? = null,
|
||||||
|
val offset: Int? = null,
|
||||||
|
val vendors: Int? = null,
|
||||||
|
val staleAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One shop and its stock (`GET /public/shard/market/vendors/:serial`).
|
||||||
|
*
|
||||||
|
* [truncated] means the shard publishes only the first `MarketMaxListings` of a larger
|
||||||
|
* inventory — [count] is what is published, [total] what the shop holds. Saying so is
|
||||||
|
* the point of this screen: a search result list cannot express it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MarketVendorDto(
|
||||||
|
val serial: String? = null,
|
||||||
|
val shopName: String? = null,
|
||||||
|
val ownerSerial: String? = null,
|
||||||
|
val ownerName: String? = null,
|
||||||
|
val location: MarketLocationDto? = null,
|
||||||
|
val count: Int? = null,
|
||||||
|
val total: Int? = null,
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
val updatedAt: String? = null,
|
||||||
|
val items: List<MarketListingDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Index size, staleness and the filter options that actually hold vendors. */
|
||||||
|
@Serializable
|
||||||
|
data class MarketMetaDto(
|
||||||
|
val vendors: Int = 0,
|
||||||
|
val items: Int = 0,
|
||||||
|
val staleAt: String? = null,
|
||||||
|
val freshAt: String? = null,
|
||||||
|
val maps: List<String> = emptyList(),
|
||||||
|
val regions: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Spawn atlas (§6) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`:
|
||||||
|
* the atlas is static shard *content* parsed from the server's own data files, not
|
||||||
|
* live shard *state*, so it does not go offline with the sidecar — but unlike the
|
||||||
|
* shard routes it IS site-mode gated, like posts and the wiki.
|
||||||
|
*
|
||||||
|
* [points] is a **count** of spawners; [spawners] is the list, and only the
|
||||||
|
* single-creature route sends it. The two names are one letter apart in meaning and
|
||||||
|
* were deliberately separated (`v3.md` §6.3) — do not reuse one for the other.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AtlasCreatureDto(
|
||||||
|
val slug: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
/** How many can be alive at once, summed across every spawner. */
|
||||||
|
val total: Int? = null,
|
||||||
|
/** How many spawners mention this creature. */
|
||||||
|
val points: Int? = null,
|
||||||
|
/** Spawner count per facet. */
|
||||||
|
val facets: Map<String, Int> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* Where it appears, aggregated per named place — the detail route only, and the
|
||||||
|
* answer the whole screen exists to give. **Objects, not strings:** the server
|
||||||
|
* sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
|
||||||
|
* made the detail route fail to decode entirely.
|
||||||
|
*/
|
||||||
|
val places: List<AtlasPlaceDto> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
|
||||||
|
* the normal state, since no artwork ships. Neither client renders it yet; the
|
||||||
|
* field is carried so a decode never depends on that staying true.
|
||||||
|
*/
|
||||||
|
val art: String? = null,
|
||||||
|
val spawners: List<AtlasSpawnerDto> = emptyList(),
|
||||||
|
val spawnersTruncated: Boolean = false,
|
||||||
|
/** Creatures sharing its spawners — the detail route only. */
|
||||||
|
val alsoHere: List<AtlasCreatureDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One named place a creature spawns in, already aggregated across its spawners.
|
||||||
|
*
|
||||||
|
* [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
|
||||||
|
* "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
|
||||||
|
* "Wilderness" — turning a list of coordinates into an answer.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AtlasPlaceDto(
|
||||||
|
val facet: String? = null,
|
||||||
|
val label: String? = null,
|
||||||
|
/** Spawners in this place. */
|
||||||
|
val spawners: Int? = null,
|
||||||
|
/** How many can be alive at once here, summed across those spawners. */
|
||||||
|
val maxAlive: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One spawn point.
|
||||||
|
*
|
||||||
|
* **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser.
|
||||||
|
* XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole
|
||||||
|
* minutes, flagging that per record — so the raw file has `5` meaning five minutes on
|
||||||
|
* one spawner and five seconds on the next, both plausible. The API and this client
|
||||||
|
* carry seconds throughout.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AtlasSpawnerDto(
|
||||||
|
val id: Long? = null,
|
||||||
|
val facet: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val x: Int? = null,
|
||||||
|
val y: Int? = null,
|
||||||
|
val maxCount: Int? = null,
|
||||||
|
val minDelay: Int? = null,
|
||||||
|
val maxDelay: Int? = null,
|
||||||
|
val region: String? = null,
|
||||||
|
val landmark: String? = null,
|
||||||
|
/** The server's own "Despise, Felucca" style placement label. */
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A page of creature search results (`GET /public/atlas/creatures`). */
|
||||||
|
@Serializable
|
||||||
|
data class AtlasCreaturePageDto(
|
||||||
|
val creatures: List<AtlasCreatureDto> = emptyList(),
|
||||||
|
val total: Int = 0,
|
||||||
|
val limit: Int? = null,
|
||||||
|
val offset: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** When the atlas was last derived from the shard's data files, and what it holds. */
|
||||||
|
@Serializable
|
||||||
|
data class AtlasMetaDto(
|
||||||
|
val importedAt: String? = null,
|
||||||
|
val generatedAt: String? = null,
|
||||||
|
val counts: Map<String, Int> = emptyMap(),
|
||||||
|
val facets: List<String> = emptyList(),
|
||||||
|
)
|
||||||
199
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
199
app/src/main/java/com/runicgateway/app/data/api/dto/ShardDto.kt
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
|
||||||
|
* the audience rung they resolved to.
|
||||||
|
*
|
||||||
|
* Every shard-derived feature is admin-configurable — it can be switched off or
|
||||||
|
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
|
||||||
|
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
|
||||||
|
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
|
||||||
|
* since `player` means *a linked game account* and staff always satisfy it.
|
||||||
|
*
|
||||||
|
* The response reports only what the caller can see, so the list itself never
|
||||||
|
* discloses a feature they're gated out of.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ShardFeaturesDto(
|
||||||
|
val level: String? = null,
|
||||||
|
val features: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
||||||
|
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
||||||
|
* (e.g. `"0x1A2B"`), never numbers.
|
||||||
|
*
|
||||||
|
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
|
||||||
|
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
|
||||||
|
* not in-game-visible the way a character name is, so they are stripped from every
|
||||||
|
* response below `admin` and no admin setting can loosen that. The fields stay
|
||||||
|
* declared because an admin session does receive them; nothing below one should
|
||||||
|
* expect a value.
|
||||||
|
*/
|
||||||
|
@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,76 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiki DTOs (website `wiki.model.js` / `wiki.db.js`). The list route returns
|
||||||
|
* lighter summary rows (no body); the detail route returns the full page plus
|
||||||
|
* its tags, backlinks, and unresolved ("red") link targets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /public/wiki` — a summary row (body omitted). */
|
||||||
|
@Serializable
|
||||||
|
data class WikiSummaryDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val excerpt: String? = null,
|
||||||
|
@SerialName("category_slug") val categorySlug: String? = null,
|
||||||
|
@SerialName("category_title") val categoryTitle: String? = null,
|
||||||
|
@SerialName("updated_at") val updatedAt: String? = null,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/wiki/:slug` — full page + tags/backlinks. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiPageDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val body: String? = null,
|
||||||
|
val excerpt: String? = null,
|
||||||
|
@SerialName("category_slug") val categorySlug: String? = null,
|
||||||
|
@SerialName("category_title") val categoryTitle: String? = null,
|
||||||
|
@SerialName("updated_at") val updatedAt: String? = null,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
val tags: List<WikiTagRefDto> = emptyList(),
|
||||||
|
val backlinks: List<WikiBacklinkDto> = emptyList(),
|
||||||
|
@SerialName("missing_links") val missingLinks: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A tag as attached to a page (slug + label only). */
|
||||||
|
@Serializable
|
||||||
|
data class WikiTagRefDto(
|
||||||
|
val slug: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A page that links to the current page. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiBacklinkDto(
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/wiki/categories`. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiCategoryDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("published_count") val publishedCount: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/wiki/tags`. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiTagDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
@SerialName("published_count") val publishedCount: Long = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.appearance
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JSON-valued settings row, client side — the second stage of decoding
|
||||||
|
* `nav_public` (THEMING_AND_NAV.md §3).
|
||||||
|
*
|
||||||
|
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
|
||||||
|
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
|
||||||
|
* the row arrives as a **string inside** the already-decoded settings object,
|
||||||
|
* and a malformed or wrong-shaped one must read as **absent** — the surface
|
||||||
|
* falls back to the coded default — never as an error and never as a
|
||||||
|
* half-applied object.
|
||||||
|
*/
|
||||||
|
private val settingsJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param raw the raw stored value, as it arrived in the settings payload
|
||||||
|
* @return the parsed object, or null when absent/malformed
|
||||||
|
*/
|
||||||
|
fun parseJsonSetting(raw: String?): JsonObject? {
|
||||||
|
if (raw.isNullOrEmpty()) return null
|
||||||
|
val parsed = try {
|
||||||
|
settingsJson.parseToJsonElement(raw)
|
||||||
|
} catch (_: SerializationException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||||
|
// every consumer of these keys as a syntax error is.
|
||||||
|
return parsed as? JsonObject
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.appearance
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app renders itself with that the shard's admin controls
|
||||||
|
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
|
||||||
|
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
|
||||||
|
* so the theme and the drawer can never disagree about which shard they are showing.
|
||||||
|
*
|
||||||
|
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
|
||||||
|
* that predates the feature, and a settings call that failed outright are all the
|
||||||
|
* same state here, and all three must render exactly as the app did before this
|
||||||
|
* milestone existed (§2). That is why nothing on this class is nullable except
|
||||||
|
* [brand], which was already nullable and whose absence already meant "use the
|
||||||
|
* bundled strings".
|
||||||
|
*/
|
||||||
|
data class SiteAppearance(
|
||||||
|
/** The per-shard branding block; null when settings couldn't be loaded. */
|
||||||
|
val brand: BrandDto? = null,
|
||||||
|
/**
|
||||||
|
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
|
||||||
|
* Empty means "the shipped defaults" — the server never emits an empty map,
|
||||||
|
* but absent and empty are the same thing to the app and it must not depend
|
||||||
|
* on that.
|
||||||
|
*/
|
||||||
|
val theme: Map<String, String> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* The parsed `nav_public` row, or null when the admin never edited the nav.
|
||||||
|
* Kept as the raw object here; reading `items` / `sections` / `links` out of
|
||||||
|
* it is the job of the phases that render them.
|
||||||
|
*/
|
||||||
|
val navPublic: JsonObject? = null,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** The shipped app: no brand, no overrides. Also what a failed load means. */
|
||||||
|
val NONE = SiteAppearance()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the appearance from a `GET /public/settings` body. Forgiving
|
||||||
|
* field by field (§2): a bad `--accent` must not discard a good `--bg`
|
||||||
|
* beside it, and a malformed `nav_public` must not cost the theme.
|
||||||
|
*/
|
||||||
|
fun from(settings: SettingsDto?): SiteAppearance {
|
||||||
|
if (settings == null) return NONE
|
||||||
|
return SiteAppearance(
|
||||||
|
brand = settings.brand,
|
||||||
|
theme = themeTokens(settings.theme as? JsonObject),
|
||||||
|
navPublic = parseJsonSetting(settings.navPublic),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every themable token is a string server-side (validated on write, and
|
||||||
|
// resolveThemeTokens only ever copies a validated value). Anything else
|
||||||
|
// is dropped rather than coerced, so an unexpected value costs exactly
|
||||||
|
// its own token and the rest of the palette still applies.
|
||||||
|
private fun themeTokens(raw: JsonObject?): Map<String, String> {
|
||||||
|
if (raw.isNullOrEmpty()) return emptyMap()
|
||||||
|
return buildMap {
|
||||||
|
for ((token, value) in raw) {
|
||||||
|
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
if (!text.isNullOrBlank()) put(token, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,241 @@
|
|||||||
|
/*
|
||||||
|
* 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.inbox.InboxCache
|
||||||
|
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 inboxCache: InboxCache,
|
||||||
|
private val trustTokenStore: TrustTokenStore,
|
||||||
|
private val deviceNameProvider: DeviceNameProvider,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** The three outcomes of SSO provider discovery, so the login screen can tell a
|
||||||
|
* shard that offers no SSO ([None]) apart from a discovery that failed
|
||||||
|
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
|
||||||
|
* a broken call behind a dead website hand-off (§4.2). */
|
||||||
|
sealed interface SsoDiscovery {
|
||||||
|
/** At least one enabled provider — render a native button per entry. */
|
||||||
|
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
|
||||||
|
|
||||||
|
/** Discovery succeeded but the shard has no SSO providers configured. */
|
||||||
|
data object None : SsoDiscovery
|
||||||
|
|
||||||
|
/** The discovery call failed (offline / server error) — surface a retry. */
|
||||||
|
data object Unavailable : SsoDiscovery
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover the shard's enabled SSO providers for the native login buttons (§4.2).
|
||||||
|
* Public discovery, never secrets. Retries once before reporting [Unavailable],
|
||||||
|
* so a single transient blip doesn't strand the user.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
// Drop the cached inbox with it: those are one person's notifications, and
|
||||||
|
// they have finished with this device. This is the tidy-up, not the
|
||||||
|
// safeguard — InboxCache scopes every snapshot to (base URL, user id), so
|
||||||
|
// the paths that never reach here (a dead refresh, a server switch) cannot
|
||||||
|
// surface one account's items under another's session either.
|
||||||
|
try {
|
||||||
|
inboxCache.clear()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore — same reason.
|
||||||
|
}
|
||||||
|
val refreshToken = sessionManager.currentRefreshToken()
|
||||||
|
try {
|
||||||
|
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
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.StatusDto
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the shard website base-URL lifecycle (PLAN.md §3): restoring a saved URL
|
||||||
|
* on launch, validating + persisting a candidate on the first-run connect
|
||||||
|
* screen, and the hard reset performed by a Settings → Server switch.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
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 shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
|
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
|
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||||
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Outcome of validating a candidate base URL against a live site. */
|
||||||
|
sealed interface ProbeResult {
|
||||||
|
data class Success(val status: StatusDto) : ProbeResult
|
||||||
|
data class InvalidUrl(val reason: ServerUrl.Reason) : ProbeResult
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a saved base URL has been loaded into the holder. */
|
||||||
|
val isConnected: Boolean get() = baseUrlHolder.current != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore any saved base URL into the holder on launch. Returns true if the
|
||||||
|
* app already has a configured shard site (skip the connect screen).
|
||||||
|
*/
|
||||||
|
suspend fun restore(): Boolean {
|
||||||
|
val saved = prefs.currentBaseUrl()?.toHttpUrlOrNull()
|
||||||
|
baseUrlHolder.set(saved)
|
||||||
|
return saved != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate [rawUrl], and on success persist it and activate it for all
|
||||||
|
* subsequent API calls. Insecure HTTP is allowed only in debug builds
|
||||||
|
* (local dev against 127.0.0.1); release builds require HTTPS.
|
||||||
|
*/
|
||||||
|
suspend fun probeAndConnect(rawUrl: String): ProbeResult {
|
||||||
|
val normalized = when (val r = ServerUrl.normalize(rawUrl, allowInsecureHttp = config.allowInsecureHttp)) {
|
||||||
|
is ServerUrl.Result.Invalid -> return ProbeResult.InvalidUrl(r.reason)
|
||||||
|
is ServerUrl.Result.Valid -> r.url
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusUrl = normalized.resolve("api/v1/public/status")?.toString()
|
||||||
|
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
||||||
|
|
||||||
|
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
|
||||||
|
// a switch between two signed-out hosts changes no session, so nothing else
|
||||||
|
// invalidates the cache and the new shard would inherit the old one's menu.
|
||||||
|
shardFeaturesRepository.invalidate()
|
||||||
|
// Same argument, one layer up: what the OLD host served says nothing about
|
||||||
|
// the new one, and a stale "this backend has no game module" would hide the
|
||||||
|
// new host's shard rows until its first successful read.
|
||||||
|
siteCapabilitiesRepository.invalidate()
|
||||||
|
prefs.clear()
|
||||||
|
baseUrlHolder.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form submission (PLAN.md §6.1). The endpoint is rate-limited; the
|
||||||
|
* caller handles `429` (too many) and `502` (mailer down) via [ApiResult.HttpError].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ContactRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
suspend fun send(name: String, email: String, message: String): ApiResult<ContactResponse> =
|
||||||
|
safeApiCall { api.postContact(ContactRequest(name = name, email = email, message = message)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* News posts and CMS pages (PLAN.md §6.1). URL post categories map 1:1 to the
|
||||||
|
* backend's route segments (`news | five-on-friday | newsletter | screenshots`).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ContentRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
/** Known URL categories, in display order. */
|
||||||
|
enum class PostCategory(val urlSlug: String) {
|
||||||
|
NEWS("news"),
|
||||||
|
FIVE_ON_FRIDAY("five-on-friday"),
|
||||||
|
NEWSLETTER("newsletter"),
|
||||||
|
SCREENSHOTS("screenshots"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromUrlSlug(slug: String?): PostCategory? = entries.firstOrNull { it.urlSlug == slug }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPosts(category: PostCategory): ApiResult<List<PostDto>> =
|
||||||
|
safeApiCall { api.getPosts(category.urlSlug) }
|
||||||
|
|
||||||
|
suspend fun getPost(category: PostCategory, idOrSlug: String): ApiResult<PostDto> =
|
||||||
|
safeApiCall { api.getPost(category.urlSlug, idOrSlug) }
|
||||||
|
|
||||||
|
suspend fun getPage(slug: String): ApiResult<PageDto> =
|
||||||
|
safeApiCall { api.getPage(slug) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.EventsApi
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event calendar, event pages, arcs and the caller's own participation
|
||||||
|
* history (PLAN.md §6.1, §9 M13).
|
||||||
|
*
|
||||||
|
* The two single-object reads unwrap their envelope here rather than in a view
|
||||||
|
* model, so a screen never holds a `…Response` whose only job was to carry one
|
||||||
|
* field. The calendar and the history keep theirs: `truncated` is a fact about
|
||||||
|
* the answer that the screen renders, and the history's page is a list the pager
|
||||||
|
* appends to.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class EventsRepository @Inject constructor(
|
||||||
|
private val api: EventsApi,
|
||||||
|
) {
|
||||||
|
/** The public calendar. Both ends optional; the server's default window is 31 days. */
|
||||||
|
suspend fun calendar(
|
||||||
|
from: String? = null,
|
||||||
|
to: String? = null,
|
||||||
|
seriesId: Long? = null,
|
||||||
|
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event, optionally about one occurrence.
|
||||||
|
*
|
||||||
|
* [run] is passed through untouched — including a run that belongs to some
|
||||||
|
* other event, which the server ignores rather than refusing. Filtering it
|
||||||
|
* here would turn a stale link into a dead end instead of a page about the
|
||||||
|
* thing the link was about.
|
||||||
|
*/
|
||||||
|
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
|
||||||
|
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
|
||||||
|
|
||||||
|
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
|
||||||
|
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
|
||||||
|
safeApiCall { api.getSeries(slug) }.map { it.series }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the caller's own participation history, newest first.
|
||||||
|
*
|
||||||
|
* [before] is the id of the last row already shown — a keyset page, not an
|
||||||
|
* offset, because the list gains rows at the top as the reader attends things.
|
||||||
|
*/
|
||||||
|
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
|
||||||
|
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* 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.NotificationChannelPrefDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device registration, stream subscriptions, per-channel preferences and the
|
||||||
|
* in-app inbox — the whole `/auth/me` notification surface (PLAN.md §11,
|
||||||
|
* ENGAGEMENT.md phases 3 and 7/8). Every call returns a typed [ApiResult] so the screen
|
||||||
|
* 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)) }
|
||||||
|
|
||||||
|
// ── Per-channel preferences (phase 3) ──────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun channelPrefs(): ApiResult<NotificationChannelPrefsDto> =
|
||||||
|
safeApiCall { api.channelPrefs() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write ONE (id, channel) → mode pair. The endpoint is sparse, so a screen
|
||||||
|
* saving a single toggle sends a single row and cannot disturb the others —
|
||||||
|
* including the ones it does not render.
|
||||||
|
*/
|
||||||
|
suspend fun setChannelMode(id: String, channel: String, mode: String): ApiResult<NotificationChannelPrefsDto> =
|
||||||
|
safeApiCall {
|
||||||
|
api.putChannelPrefs(
|
||||||
|
NotificationChannelPrefsUpdateDto(listOf(NotificationChannelPrefDto(id, channel, mode))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The inbox (phase 7/8) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One page, newest first. [before] is the previous page's last id, never an offset. */
|
||||||
|
suspend fun inbox(before: Long? = null, unreadOnly: Boolean = false): ApiResult<NotificationInboxDto> =
|
||||||
|
safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) }
|
||||||
|
|
||||||
|
suspend fun unreadCount(): ApiResult<NotificationUnreadDto> = safeApiCall { api.unreadCount() }
|
||||||
|
|
||||||
|
suspend fun markRead(id: Long): ApiResult<NotificationReadResultDto> = safeApiCall { api.markRead(id) }
|
||||||
|
|
||||||
|
suspend fun markAllRead(): ApiResult<NotificationReadResultDto> = safeApiCall { api.markAllRead() }
|
||||||
|
}
|
||||||
@@ -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,22 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Site status (mode/maintenance) and settings/branding (PLAN.md §6.1). */
|
||||||
|
@Singleton
|
||||||
|
class SettingsRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
suspend fun getStatus(): ApiResult<StatusDto> = safeApiCall { api.getStatus() }
|
||||||
|
|
||||||
|
suspend fun getSettings(): ApiResult<SettingsDto> = safeApiCall { api.getSettings() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which shard surfaces the current viewer may reach, from
|
||||||
|
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
|
||||||
|
*
|
||||||
|
* Every shard-derived feature is admin-configurable — it can be switched off, or its
|
||||||
|
* audience raised above the caller's rung — so shard navigation can no longer be a
|
||||||
|
* static list gated on the session role alone. [level] is the server's own answer on
|
||||||
|
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
|
||||||
|
* re-derive it.
|
||||||
|
*
|
||||||
|
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
|
||||||
|
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
|
||||||
|
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ShardFeaturesRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
private val _features = MutableStateFlow<ShardFeatures?>(null)
|
||||||
|
|
||||||
|
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
|
||||||
|
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
|
||||||
|
|
||||||
|
// Serializes concurrent refreshes: the shell refreshes on every session change,
|
||||||
|
// and two overlapping loads would race to publish.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
|
||||||
|
* a role revalidation that actually changed the user), because the answer is
|
||||||
|
* per-viewer.
|
||||||
|
*
|
||||||
|
* A failed lookup clears the cache rather than keeping a stale one: falling back
|
||||||
|
* to "show everything" is the safe direction here, since the server still gates
|
||||||
|
* every call.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() = mutex.withLock {
|
||||||
|
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
|
||||||
|
is ApiResult.Ok -> ShardFeatures(
|
||||||
|
level = result.data.level,
|
||||||
|
visible = result.data.features.toSet(),
|
||||||
|
)
|
||||||
|
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
|
||||||
|
// route — that site has no visibility framework, so "unknown" is exactly
|
||||||
|
// the right answer and the menu behaves as it did before M11.
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the cached answer. Called on a Settings → Server switch: the features
|
||||||
|
* belong to the host that reported them, and a switch between two signed-out
|
||||||
|
* hosts changes no session, so nothing else would invalidate them.
|
||||||
|
*/
|
||||||
|
fun invalidate() {
|
||||||
|
_features.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved visibility answer for one viewer: the rung the server placed them on
|
||||||
|
* and the shard features they may reach.
|
||||||
|
*/
|
||||||
|
data class ShardFeatures(
|
||||||
|
val level: String?,
|
||||||
|
val visible: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [feature] may be shown — **or when the answer isn't known yet**.
|
||||||
|
*
|
||||||
|
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
|
||||||
|
* the server gates every call regardless, so the cost of guessing wrong is a link that
|
||||||
|
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
|
||||||
|
* flickers its entries in on every cold start.
|
||||||
|
*/
|
||||||
|
fun canSee(features: ShardFeatures?, feature: String): Boolean =
|
||||||
|
features == null || feature in features.visible
|
||||||
|
|
||||||
|
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
|
||||||
|
object ShardFeature {
|
||||||
|
const val STATUS = "status"
|
||||||
|
const val ACTIVITY = "activity"
|
||||||
|
const val CHAMPS = "champs"
|
||||||
|
const val GUILDS = "guilds"
|
||||||
|
const val GOVERNORS = "governors"
|
||||||
|
const val HOUSES = "houses"
|
||||||
|
const val PRESENCE = "presence"
|
||||||
|
|
||||||
|
// Added by Protocol 3.0.
|
||||||
|
const val RULESET = "ruleset"
|
||||||
|
const val ATLAS = "atlas"
|
||||||
|
const val LEADERBOARDS = "leaderboards"
|
||||||
|
const val MARKET = "market"
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/*
|
||||||
|
* 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.AtlasCreatureDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ChampDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||||
|
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
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() }
|
||||||
|
|
||||||
|
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
|
||||||
|
//
|
||||||
|
// All four sit behind `requireFeature`, so a 404/403 here is "this shard doesn't
|
||||||
|
// publish it" rather than a fault — see `toShardUiState()`.
|
||||||
|
|
||||||
|
/** The shard ruleset, or `Ok(null)` when the shard has never published one. */
|
||||||
|
suspend fun ruleset(): ApiResult<RulesetDto?> = safeApiCall { api.getShardRuleset() }
|
||||||
|
|
||||||
|
suspend fun pointsBoards(): ApiResult<List<PointsBoardDto>> = safeApiCall { api.getShardPoints() }
|
||||||
|
|
||||||
|
suspend fun pointsBoard(system: String): ApiResult<PointsBoardDto> =
|
||||||
|
safeApiCall { api.getShardPointsBoard(system) }
|
||||||
|
|
||||||
|
suspend fun market(
|
||||||
|
query: String? = null,
|
||||||
|
map: String? = null,
|
||||||
|
region: String? = null,
|
||||||
|
sort: String = SORT_PRICE_ASC,
|
||||||
|
limit: Int = MARKET_PAGE,
|
||||||
|
offset: Int = 0,
|
||||||
|
): ApiResult<MarketPageDto> = safeApiCall {
|
||||||
|
api.getShardMarket(
|
||||||
|
query = query?.takeIf { it.isNotBlank() },
|
||||||
|
map = map?.takeIf { it.isNotBlank() },
|
||||||
|
region = region?.takeIf { it.isNotBlank() },
|
||||||
|
sort = sort,
|
||||||
|
limit = limit,
|
||||||
|
offset = offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun marketMeta(): ApiResult<MarketMetaDto> = safeApiCall { api.getShardMarketMeta() }
|
||||||
|
|
||||||
|
suspend fun marketVendor(serial: String): ApiResult<MarketVendorDto> =
|
||||||
|
safeApiCall { api.getShardMarketVendor(serial) }
|
||||||
|
|
||||||
|
suspend fun atlasCreatures(
|
||||||
|
query: String? = null,
|
||||||
|
facet: String? = null,
|
||||||
|
limit: Int = ATLAS_PAGE,
|
||||||
|
offset: Int = 0,
|
||||||
|
): ApiResult<AtlasCreaturePageDto> = safeApiCall {
|
||||||
|
api.getAtlasCreatures(
|
||||||
|
query = query?.takeIf { it.isNotBlank() },
|
||||||
|
facet = facet?.takeIf { it.isNotBlank() },
|
||||||
|
limit = limit,
|
||||||
|
offset = offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun atlasCreature(slug: String): ApiResult<AtlasCreatureDto> =
|
||||||
|
safeApiCall { api.getAtlasCreature(slug) }
|
||||||
|
|
||||||
|
// ── Live stream ──────────────────────────────────────────────────────
|
||||||
|
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
|
||||||
|
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
|
||||||
|
// Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by
|
||||||
|
// default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out
|
||||||
|
// disabled (a live firehose of vendor inventories would be the site's biggest
|
||||||
|
// bandwidth consumer), so the market screen is a plain paginated read and must never
|
||||||
|
// wait on a frame.
|
||||||
|
fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer())
|
||||||
|
fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer())
|
||||||
|
|
||||||
|
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
|
||||||
|
json.decodeFromJsonElement(serializer, obj)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SORT_PRICE_ASC = "price_asc"
|
||||||
|
const val SORT_PRICE_DESC = "price_desc"
|
||||||
|
const val SORT_RECENT = "recent"
|
||||||
|
|
||||||
|
/** The server caps `limit` at 100; stay well under it on a phone. */
|
||||||
|
const val MARKET_PAGE = 50
|
||||||
|
const val ATLAS_PAGE = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this BACKEND serves — core's own capabilities and every started module's
|
||||||
|
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
|
||||||
|
*
|
||||||
|
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
|
||||||
|
*
|
||||||
|
* The two answer different questions and neither can answer the other's:
|
||||||
|
*
|
||||||
|
* - **Capability — is this module installed at all?** Per HOST. It changes when
|
||||||
|
* an operator installs or removes a module, so it is resolved beside the
|
||||||
|
* appearance and invalidated on a server switch.
|
||||||
|
* - **Feature — does this shard publish this surface to this viewer?** Per
|
||||||
|
* VIEWER. It changes on sign-in, which is why it is resolved on every session
|
||||||
|
* change.
|
||||||
|
*
|
||||||
|
* Without the first, the app cannot tell a module that is **not installed** from
|
||||||
|
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
|
||||||
|
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
|
||||||
|
* treats as visible. On a site running a different game that renders every shard
|
||||||
|
* row in the drawer and every one of them 404s when tapped.
|
||||||
|
*
|
||||||
|
* ## Absence of an answer is not an answer of absence
|
||||||
|
*
|
||||||
|
* The distinction this class exists to make, and the reason [SiteCapabilities]
|
||||||
|
* carries no "unknown" member of its own — the *absence of the whole value* is
|
||||||
|
* the unknown state:
|
||||||
|
*
|
||||||
|
* - a **successful** read that does not name a capability is an answer, and
|
||||||
|
* [canUse] hides what needs it;
|
||||||
|
* - a **failed** read keeps the last answer this host gave, because a moment
|
||||||
|
* with no connectivity is not an uninstall;
|
||||||
|
* - a host that has **never** answered leaves the value null, and [canUse]
|
||||||
|
* passes — the drawer renders as it did before this existed rather than
|
||||||
|
* flickering its rows in on every cold start.
|
||||||
|
*
|
||||||
|
* The last one is deliberately the same fail-open direction [canSee] takes, for
|
||||||
|
* the same reason: the server gates every call regardless, so the cost of
|
||||||
|
* guessing wrong is a link that briefly 404s.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SiteCapabilitiesRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
|
||||||
|
|
||||||
|
/** The current answer, or `null` while this host has never given one. */
|
||||||
|
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
|
||||||
|
|
||||||
|
// Serializes concurrent refreshes: the shell refreshes on resume and the
|
||||||
|
// connect flow refreshes on first load, and two overlapping reads would race
|
||||||
|
// to publish.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve what this backend serves.
|
||||||
|
*
|
||||||
|
* **Two calls, and one failing is not the same as both failing.** Core's list
|
||||||
|
* and a module's are separate lists (§2.9), so they are merged from separate
|
||||||
|
* reads and each is kept only if it answered. A backend released before
|
||||||
|
* events omits `capabilities` from its `version` block entirely, which is an
|
||||||
|
* answer — the empty list — and not a failure.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() = mutex.withLock {
|
||||||
|
val status = safeApiCall { api.getStatus() }
|
||||||
|
val modules = safeApiCall { api.getModules() }
|
||||||
|
|
||||||
|
// Neither call answered: keep whatever this host said last, which for a
|
||||||
|
// host that has never answered is still null.
|
||||||
|
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
|
||||||
|
|
||||||
|
val previous = _capabilities.value
|
||||||
|
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
|
||||||
|
?: previous?.core
|
||||||
|
?: emptySet()
|
||||||
|
val installed = (modules as? ApiResult.Ok)?.data?.modules
|
||||||
|
?.flatMap { it.capabilities }
|
||||||
|
?.toSet()
|
||||||
|
?: previous?.modules
|
||||||
|
?: emptySet()
|
||||||
|
|
||||||
|
_capabilities.value = SiteCapabilities(core = core, modules = installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the answer. Called on a Settings → Server switch: capabilities belong
|
||||||
|
* to the host that reported them, and the new host must not inherit them —
|
||||||
|
* a switch between two signed-out hosts changes no session, so nothing else
|
||||||
|
* would invalidate this.
|
||||||
|
*/
|
||||||
|
fun invalidate() {
|
||||||
|
_capabilities.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one backend serves, as two lists rather than one.
|
||||||
|
*
|
||||||
|
* They are kept apart because core is not a module: merging them would leave the
|
||||||
|
* app unable to tell *"this backend has events"* from *"a module called core
|
||||||
|
* happens to be installed"*, which is exactly the distinction
|
||||||
|
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
|
||||||
|
* entry does not care which half serves it — but the halves stay separable, so a
|
||||||
|
* future caller that does care still can.
|
||||||
|
*/
|
||||||
|
data class SiteCapabilities(
|
||||||
|
/** Core's own, from the `version` block. Empty on a backend that predates them. */
|
||||||
|
val core: Set<String>,
|
||||||
|
/** Every started module's, flattened. Two modules may declare the same string. */
|
||||||
|
val modules: Set<String>,
|
||||||
|
) {
|
||||||
|
/** True when either half names [capability]. */
|
||||||
|
operator fun contains(capability: String): Boolean =
|
||||||
|
capability in core || capability in modules
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [capability] may be relied on — **or when this host has not answered
|
||||||
|
* yet**.
|
||||||
|
*
|
||||||
|
* The null case is the fail-open one and it is not the same as the empty one: a
|
||||||
|
* [SiteCapabilities] that names nothing is a backend that told us it serves
|
||||||
|
* nothing extra, and that hides. See the class doc above.
|
||||||
|
*
|
||||||
|
* `null` [capability] means the caller declared none, which always passes.
|
||||||
|
*/
|
||||||
|
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
|
||||||
|
capability == null || capabilities == null || capability in capabilities
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capability strings the app gates on.
|
||||||
|
*
|
||||||
|
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
|
||||||
|
* its own would be a second, worse copy of what the per-viewer feature flags
|
||||||
|
* already decide — and one that drifts, because a capability is opaque to core
|
||||||
|
* and nothing checks the two agree. One string answers the only question a
|
||||||
|
* capability can: is the module there.
|
||||||
|
*/
|
||||||
|
object Capability {
|
||||||
|
/**
|
||||||
|
* A game module serving a live shard. Declared by `module-uo`; a different
|
||||||
|
* game's module that serves the same surfaces would declare it too, which is
|
||||||
|
* the point of an opaque string.
|
||||||
|
*/
|
||||||
|
const val SHARD = "shard"
|
||||||
|
|
||||||
|
/** Core's event system (events Phase 14a). Never a module's. */
|
||||||
|
const val EVENTS = "events"
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Wiki listing/filtering, categories, tags, and detail (PLAN.md §6.1). */
|
||||||
|
@Singleton
|
||||||
|
class WikiRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
/** Full-text [query] takes precedence over [category]/[tag] on the backend. */
|
||||||
|
suspend fun getPages(
|
||||||
|
query: String? = null,
|
||||||
|
category: String? = null,
|
||||||
|
tag: String? = null,
|
||||||
|
): ApiResult<List<WikiSummaryDto>> =
|
||||||
|
safeApiCall { api.getWikiPages(query?.takeIf { it.isNotBlank() }, category, tag) }
|
||||||
|
|
||||||
|
suspend fun getCategories(): ApiResult<List<WikiCategoryDto>> =
|
||||||
|
safeApiCall { api.getWikiCategories() }
|
||||||
|
|
||||||
|
suspend fun getTags(): ApiResult<List<WikiTagDto>> =
|
||||||
|
safeApiCall { api.getWikiTags() }
|
||||||
|
|
||||||
|
suspend fun getPage(slug: String): ApiResult<WikiPageDto> =
|
||||||
|
safeApiCall { api.getWikiPage(slug) }
|
||||||
|
}
|
||||||
24
app/src/main/java/com/runicgateway/app/di/AppModule.kt
Normal file
24
app/src/main/java/com/runicgateway/app/di/AppModule.kt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.di
|
||||||
|
|
||||||
|
import com.runicgateway.app.BuildConfig
|
||||||
|
import com.runicgateway.app.core.AppConfig
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAppConfig(): AppConfig = AppConfig(
|
||||||
|
allowInsecureHttp = BuildConfig.DEBUG,
|
||||||
|
versionName = BuildConfig.VERSION_NAME,
|
||||||
|
)
|
||||||
|
}
|
||||||
170
app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
Normal file
170
app/src/main/java/com/runicgateway/app/di/NetworkModule.kt
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
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.EventsApi
|
||||||
|
import com.runicgateway.app.data.api.MeApi
|
||||||
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
|
import com.runicgateway.app.data.api.NotificationsApi
|
||||||
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideJson(): Json = Json {
|
||||||
|
// Additive backend fields (§8) must never break decoding; be lenient.
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
coerceInputValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUserAgentInterceptor(): UserAgentInterceptor {
|
||||||
|
// Identifiable UA so the site's scanner guard (§8) doesn't reject the app.
|
||||||
|
val ua = "RunicGatewayApp/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.RELEASE})"
|
||||||
|
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()
|
||||||
|
.addInterceptor(userAgentInterceptor)
|
||||||
|
.addInterceptor(hostSelectionInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.authenticator(tokenAuthenticator)
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
builder.addInterceptor(
|
||||||
|
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit =
|
||||||
|
Retrofit.Builder()
|
||||||
|
// Compile-time sentinel; every relative call is rewritten onto the
|
||||||
|
// runtime-selected shard site by HostSelectionInterceptor (§3).
|
||||||
|
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event surface (§9 M13). Three public reads and one bearer-authed player
|
||||||
|
* read on one interface — they are all CORE routes, so none of them is a
|
||||||
|
* module path and none is under `/shard`.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
|
||||||
|
|
||||||
|
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
||||||
|
retrofit.create(NotificationsApi::class.java)
|
||||||
|
|
||||||
|
/** Staff operations (§1, §6.4, M10) — bearer-authed; the server re-checks role every call. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAdminApi(retrofit: Retrofit): AdminApi = retrofit.create(AdminApi::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||||
|
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
49
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* 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 com.runicgateway.app.core.inbox.DataStoreInboxCache
|
||||||
|
import com.runicgateway.app.core.inbox.InboxCache
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Binds the at-rest stores to their implementations — EncryptedSharedPreferences
|
||||||
|
* for anything secret (§4.3), plain DataStore for the inbox snapshot. */
|
||||||
|
@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
|
||||||
|
|
||||||
|
/** The inbox's offline snapshot — plain DataStore, not encrypted (ENGAGEMENT.md phase 8). */
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindInboxCache(impl: DataStoreInboxCache): InboxCache
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user