Compare commits
8 Commits
199df6cd64
...
feat/m6-re
| Author | SHA1 | Date | |
|---|---|---|---|
| 68da9da805 | |||
| 0df862a6af | |||
| e497e6c8a7 | |||
| 52e06da8fc | |||
| 2e1bea4220 | |||
| d4f7fcb241 | |||
| ca704caaaf | |||
| 1c56eda64b |
269
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,269 @@
|
||||
# Automated build + release for the Runic Gateway Android app.
|
||||
#
|
||||
# Trigger: every push to `main` (i.e. every merged PR).
|
||||
#
|
||||
# Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine:
|
||||
#
|
||||
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
|
||||
# │ reads: latest v* git tag + conventional-commit subjects │
|
||||
# │ produces: next version, changelog, and (at the end) the release │
|
||||
# └───────────────────────────────────────────────────────────────────┘
|
||||
# ┌── ANDROID ADAPTER (the only Android-specific part) ───────────────┐
|
||||
# │ consumes: the version │
|
||||
# │ produces: the artifact (a signed release APK + SHA256SUMS) │
|
||||
# └───────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# Version bump (conventional commits since the last v* tag):
|
||||
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||
# nothing releasable -> no release is cut
|
||||
# (first ever run, no tag) -> releases the current build.gradle.kts version as-is
|
||||
# versionName is the semver; versionCode is derived major*10000+minor*100+patch so
|
||||
# it is deterministic + monotonic (PLAN.md §10). The bump is committed back to
|
||||
# app/build.gradle.kts, then tagged.
|
||||
#
|
||||
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
||||
# REGISTRY_USER — Gitea username the token below belongs to
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository` (push the bump
|
||||
# commit + tag and create the release)
|
||||
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
||||
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
||||
# Also: `main` must accept a direct push from the REGISTRY_USER account (disable
|
||||
# branch protection for it, or add it as an exception) — the bump commit lands on
|
||||
# main. The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
|
||||
#
|
||||
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
|
||||
# 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:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: release-apk
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/Android-app
|
||||
GRADLE_MODULE: app
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
|
||||
# Quoted because the expression contains a colon (`chore(release):`), which an
|
||||
# unquoted YAML scalar would misparse as a mapping value.
|
||||
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
||||
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 full history (need tags + commit log for the bump)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
|
||||
- name: Plan the release (version + changelog)
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
git fetch --tags --force >/dev/null 2>&1 || true
|
||||
|
||||
# Current committed version (the `?: "x.y.z"` default in build.gradle.kts).
|
||||
MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)"
|
||||
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
|
||||
|
||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||
|
||||
BUMP=none
|
||||
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
|
||||
|
||||
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||
IFS=. read -r MA MI PA <<< "$1"
|
||||
case "$2" in
|
||||
major) echo "$((MA+1)).0.0" ;;
|
||||
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
RELEASE=true
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
VERSION="$MANIFEST_VERSION" # first release: ship what's committed
|
||||
elif [ "$BUMP" = none ]; then
|
||||
RELEASE=false # no feat/fix/breaking since last tag
|
||||
VERSION="${LAST_TAG#v}"
|
||||
else
|
||||
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||
echo "Tag v${VERSION} already exists — nothing to release."
|
||||
RELEASE=false
|
||||
fi
|
||||
|
||||
# Derive a deterministic, monotonic versionCode from the semver.
|
||||
IFS=. read -r MA MI PA <<< "$VERSION"
|
||||
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
|
||||
|
||||
{
|
||||
echo "## Runic Gateway Android v${VERSION}"
|
||||
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 "$LAST_TAG" ]; then echo "Since ${LAST_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=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> release=${RELEASE} version=${VERSION} code=${VERSION_CODE} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
|
||||
|
||||
# ── ANDROID ADAPTER: SDK + signing keystore ──────────────────────────
|
||||
- name: Set up Android SDK
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Decode signing keystore
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
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"
|
||||
|
||||
# ── ANDROID ADAPTER: set the version, gate, build the signed APK ─────
|
||||
- name: Set the app version to match the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
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
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
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
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
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
|
||||
|
||||
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
||||
- name: Commit version bump and push tag
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
# Secrets can arrive with a trailing newline; a stray CR/LF corrupts the
|
||||
# remote URL / auth header. Strip line breaks before use.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
git config user.name "android-app-ci"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
git remote set-url origin \
|
||||
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||
|
||||
git add "${GRADLE_MODULE}/build.gradle.kts"
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
|
||||
git push origin "HEAD:main"
|
||||
else
|
||||
echo "Version unchanged (first release) — no bump commit needed."
|
||||
fi
|
||||
git tag "${TAG}"
|
||||
git push origin "${TAG}"
|
||||
|
||||
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
|
||||
- name: Create Gitea release and upload assets
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
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
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
@@ -9,6 +12,25 @@ plugins {
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||
// order of precedence: a local gitignored `keystore.properties` at the repo root,
|
||||
// then environment variables (how CI injects the decoded keystore + secrets). When
|
||||
// none is present, the release build is simply left unsigned — `assembleDebug` and
|
||||
// the PR gate are unaffected, so contributors without the keystore can still build.
|
||||
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||
val keystoreProps = Properties().apply {
|
||||
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
|
||||
}
|
||||
fun signingValue(propKey: String, envKey: String): String? =
|
||||
keystoreProps.getProperty(propKey) ?: System.getenv(envKey)
|
||||
|
||||
val ksStoreFilePath = signingValue("storeFile", "ANDROID_KEYSTORE_FILE")
|
||||
val ksStorePassword = signingValue("storePassword", "ANDROID_KEYSTORE_PASSWORD")
|
||||
val ksKeyAlias = signingValue("keyAlias", "ANDROID_KEY_ALIAS")
|
||||
val ksKeyPassword = signingValue("keyPassword", "ANDROID_KEY_PASSWORD")
|
||||
val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null &&
|
||||
ksKeyAlias != null && ksKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.runicgateway.app"
|
||||
compileSdk = 35
|
||||
@@ -18,20 +40,46 @@ android {
|
||||
applicationId = "com.runicgateway.app"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// These committed defaults are the version source of truth (PLAN.md §10).
|
||||
// release.yml's conventional-commit engine bumps versionName here and commits
|
||||
// it on release; versionCode is derived from it (major*10000+minor*100+patch)
|
||||
// so it stays monotonic. Both remain overridable via -P for local/manual builds.
|
||||
versionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
|
||||
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
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 {
|
||||
release {
|
||||
// Signing/minification are wired at M6 (release hardening). Debug is auto-signed.
|
||||
isMinifyEnabled = false
|
||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||
// release APK). Keep rules live in proguard-rules.pro.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Signed only when the keystore material is present (local or CI); an
|
||||
// unsigned APK is produced otherwise. The direct-APK release (§10) runs
|
||||
// through release.yml, which supplies the keystore from a Gitea secret.
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +141,9 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
// Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// Images
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
|
||||
93
app/licenses/Cinzel-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
62
app/proguard-rules.pro
vendored
@@ -1,3 +1,59 @@
|
||||
# Runic Gateway Android app — ProGuard/R8 rules.
|
||||
# Minification is disabled until M6 (release hardening); real keep rules for
|
||||
# kotlinx.serialization DTOs and Retrofit models are added there.
|
||||
# Runic Gateway Android app — ProGuard/R8 rules (release minify + resource shrink, M6).
|
||||
#
|
||||
# The dependency stack ships its own consumer rules that R8 applies automatically:
|
||||
# Retrofit 2.11, OkHttp 4.12, kotlinx.serialization 1.7 (core), Hilt/Dagger, Coil 2.7.
|
||||
# The rules below are defensive belt-and-suspenders for the areas full-mode R8 is
|
||||
# most likely to over-strip in this app: the kotlinx.serialization generated
|
||||
# serializers and our own @Serializable wire DTOs.
|
||||
|
||||
# ── kotlinx.serialization (canonical keep rules) ────────────────────────────
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
|
||||
# Keep the Companion of @Serializable classes so `.serializer()` resolves.
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$Companion Companion;
|
||||
}
|
||||
-keepclassmembers class <2>$Companion {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
# Keep `INSTANCE.serializer()` of @Serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
# Keep the synthesized $$serializer classes and their descriptor field.
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
|
||||
# ── Our wire DTOs ───────────────────────────────────────────────────────────
|
||||
# All request/response models decoded by kotlinx.serialization. Keeping them
|
||||
# (and their generated serializers) guarantees additive backend fields and
|
||||
# @SerialName mappings survive minification. DTOs are small, so keeping them
|
||||
# whole is cheap insurance against a full-mode strip.
|
||||
-keep @kotlinx.serialization.Serializable class com.runicgateway.app.** { *; }
|
||||
-keepclassmembers class com.runicgateway.app.data.api.dto.** { *; }
|
||||
|
||||
# ── Retrofit service interfaces ─────────────────────────────────────────────
|
||||
# Retrofit reads method + parameter annotations reflectively; keep our API
|
||||
# interfaces' generic signatures so return types (suspend .../Call<T>) resolve.
|
||||
-keep,allowobfuscation interface com.runicgateway.app.data.api.*Api
|
||||
-keepattributes Signature, Exceptions
|
||||
|
||||
# Kotlin metadata is needed for reflection over Kotlin types (serialization/Retrofit).
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# ── Tink / EncryptedSharedPreferences (androidx.security-crypto) ─────────────
|
||||
# Tink references Error Prone compile-only annotations that are absent at runtime;
|
||||
# they are safe to ignore (they carry no runtime behaviour). Suppresses the R8
|
||||
# "Missing class com.google.errorprone.annotations.*" errors.
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
@@ -3,8 +3,10 @@
|
||||
*/
|
||||
package com.runicgateway.app
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -35,7 +37,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
|
||||
setContent {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Attaches the current bearer access token to outbound calls (PLAN.md §4.1).
|
||||
* Public endpoints simply carry a token the backend ignores; the credential
|
||||
* endpoints (login/refresh) tag themselves [Http.NO_SESSION_HEADER] and are left
|
||||
* bare so a credential `401` is never mistaken for an expired session. A request
|
||||
* that already set its own Authorization (the authenticator's retry) is untouched.
|
||||
*/
|
||||
@Singleton
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val sessionManager: SessionManager,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.header(Http.NO_SESSION_HEADER) != null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
if (request.header(Http.AUTHORIZATION) != null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val token = sessionManager.currentAccessToken()
|
||||
?: return chain.proceed(request)
|
||||
val authed = request.newBuilder()
|
||||
.header(Http.AUTHORIZATION, Http.bearer(token))
|
||||
.build()
|
||||
return chain.proceed(authed)
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
/** Shared HTTP constants for the auth layer (PLAN.md §4). */
|
||||
object Http {
|
||||
/**
|
||||
* Marks the credential endpoints (login, refresh) that must run *without* a
|
||||
* bearer and must never trigger the refresh-on-401 [TokenAuthenticator].
|
||||
* [AuthInterceptor] sees it and skips attaching a token; the authenticator
|
||||
* sees it on the failed request and declines to refresh. It is a harmless
|
||||
* unknown header to the backend.
|
||||
*/
|
||||
const val NO_SESSION_HEADER = "X-Runic-No-Session"
|
||||
const val AUTHORIZATION = "Authorization"
|
||||
|
||||
fun bearer(token: String): String = "Bearer $token"
|
||||
}
|
||||
@@ -0,0 +1,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,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,37 @@
|
||||
/*
|
||||
* 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)
|
||||
|
||||
/** The website login page — carries the SSO provider buttons (§4.2). */
|
||||
fun login(): String? = resolve(LOGIN)
|
||||
|
||||
private companion object {
|
||||
const val REGISTER = "account/register"
|
||||
const val FORGOT = "account/forgot"
|
||||
const val LOGIN = "account/login"
|
||||
}
|
||||
}
|
||||
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.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.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.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.
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/login")
|
||||
suspend fun login(@Body body: MobileLoginRequest): 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>
|
||||
}
|
||||
55
app/src/main/java/com/runicgateway/app/data/api/MeApi.kt
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import retrofit2.http.Body
|
||||
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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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. */
|
||||
@Serializable
|
||||
data class TotpStateDto(val totp_enabled: Boolean = false)
|
||||
|
||||
/** 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,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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. [code] is only sent on the 2FA retry. */
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: 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.
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileTokenResponse(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: String? = null,
|
||||
val user: SafeUserDto,
|
||||
)
|
||||
|
||||
/** 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,224 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* DTOs for a player's OWN game data (PLAN.md §6.3), read over the bearer-gated
|
||||
* `/player/shard/…` surface. The roster / char / vendor reads return the sidecar
|
||||
* payload verbatim (a permissive object), so only the fields the app renders are
|
||||
* modeled — unknown keys are ignored by the JSON parser (matching the website's
|
||||
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||
*
|
||||
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
|
||||
* the public boards — these are separate endpoints with separate shapes.
|
||||
*/
|
||||
|
||||
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||
|
||||
/** `GET /player/shard/accounts` — a linked in-game account. */
|
||||
@Serializable
|
||||
data class ShardLinkDto(
|
||||
val account: String = "",
|
||||
val userId: Long? = null,
|
||||
val charName: String? = null,
|
||||
val linkedAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /player/shard/link` body — the one-time code shown by `[link` in game. */
|
||||
@Serializable
|
||||
data class ShardLinkRequest(val code: String)
|
||||
|
||||
/** `POST /player/shard/link` result — the confirmed link. */
|
||||
@Serializable
|
||||
data class ShardLinkResultDto(
|
||||
val linked: Boolean = false,
|
||||
val account: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /player/shard/account` (hybrid signup) body. */
|
||||
@Serializable
|
||||
data class CreateGameAccountRequest(
|
||||
val account: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
// ── Character roster + sheet ─────────────────────────────────────────────────
|
||||
|
||||
/** `GET /player/shard/roster/:account` — the account's characters (incl. offline). */
|
||||
@Serializable
|
||||
data class RosterDto(
|
||||
val acct: String? = null,
|
||||
val chars: List<RosterCharDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** One character in a roster; the picker fetches the full sheet on demand. */
|
||||
@Serializable
|
||||
data class RosterCharDto(
|
||||
val slot: Int? = null,
|
||||
val serial: String = "",
|
||||
val name: String? = null,
|
||||
val body: Int? = null,
|
||||
val online: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /player/shard/char/:serial` — a character sheet. `guild` / `governorOf` are
|
||||
* best-effort cross-links the backend decorates in (never fail the sheet).
|
||||
*/
|
||||
@Serializable
|
||||
data class CharProfileDto(
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val title: String? = null,
|
||||
val online: Boolean = false,
|
||||
val acct: String? = null,
|
||||
val stats: CharStatsDto? = null,
|
||||
val skills: List<SkillDto> = emptyList(),
|
||||
val equipment: List<EquipmentDto> = emptyList(),
|
||||
val titles: TitlesDto? = null,
|
||||
val guild: GuildRefDto? = null,
|
||||
val governorOf: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CharStatsDto(
|
||||
val str: Int? = null,
|
||||
val dex: Int? = null,
|
||||
val int: Int? = null,
|
||||
val hits: Int? = null,
|
||||
val hitsMax: Int? = null,
|
||||
val mana: Int? = null,
|
||||
val manaMax: Int? = null,
|
||||
val stam: Int? = null,
|
||||
val stamMax: Int? = null,
|
||||
val resist: ResistDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResistDto(
|
||||
val phys: Int? = null,
|
||||
val fire: Int? = null,
|
||||
val cold: Int? = null,
|
||||
val pois: Int? = null,
|
||||
val energy: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A skill line. `base` is the trained value, `value` includes item/temp bonuses,
|
||||
* `cap` is the cap — do NOT assume base ≤ cap (GM chars exceed it). Doubles, as the
|
||||
* shard reports tenths.
|
||||
*/
|
||||
@Serializable
|
||||
data class SkillDto(
|
||||
val n: String? = null,
|
||||
val base: Double? = null,
|
||||
val value: Double? = null,
|
||||
val cap: Double? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* An equipped item. Names are usually clilocs (numeric), not strings, and the app
|
||||
* ships no cliloc table, so the text-only sheet renders layer + id + hue + mods.
|
||||
* [mods] is a flattened map of non-zero AOS attributes (empty for plain items);
|
||||
* kept as a raw object since values may be numbers or strings.
|
||||
*/
|
||||
@Serializable
|
||||
data class EquipmentDto(
|
||||
val serial: String? = null,
|
||||
val layer: String? = null,
|
||||
val itemId: Int? = null,
|
||||
val hue: Int? = null,
|
||||
val mods: JsonObject? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
||||
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
||||
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
||||
*/
|
||||
@Serializable
|
||||
data class TitlesDto(
|
||||
val selected: Int? = null,
|
||||
val reward: List<String> = emptyList(),
|
||||
val fameKarma: String? = null,
|
||||
val skill: String? = null,
|
||||
)
|
||||
|
||||
/** The guild a character leads (cross-linked from board data). */
|
||||
@Serializable
|
||||
data class GuildRefDto(
|
||||
val name: String? = null,
|
||||
val abbr: String? = null,
|
||||
)
|
||||
|
||||
// ── Player vendors + sales ───────────────────────────────────────────────────
|
||||
|
||||
/** `GET /player/shard/vendors/:account` — every player vendor on the account. */
|
||||
@Serializable
|
||||
data class VendorSnapshotDto(
|
||||
val acct: String? = null,
|
||||
val vendors: List<VendorDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VendorDto(
|
||||
val serial: String? = null,
|
||||
val shopName: String? = null,
|
||||
val holdGold: Long? = null,
|
||||
val ownerSerial: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val listings: List<VendorListingDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** A single vendor listing. Item names are clilocs (see [EquipmentDto]); text-only. */
|
||||
@Serializable
|
||||
data class VendorListingDto(
|
||||
val serial: String? = null,
|
||||
val itemId: Int? = null,
|
||||
val amount: Int? = null,
|
||||
val price: Long? = null,
|
||||
val forSale: Boolean = false,
|
||||
)
|
||||
|
||||
/** `GET /player/shard/sales` — a player-vendor sale, visible only to the owner. */
|
||||
@Serializable
|
||||
data class VendorSaleDto(
|
||||
/** Sale time, epoch ms. */
|
||||
val t: Long? = null,
|
||||
val itemType: String? = null,
|
||||
val amount: Int? = null,
|
||||
val price: Long? = null,
|
||||
val commission: Int? = null,
|
||||
val ownerAcct: String? = null,
|
||||
)
|
||||
|
||||
// ── Player houses ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `GET /player/shard/houses` — the caller's OWN houses, with full decay/IDOC
|
||||
* detail (their own property). Serial is a hex string here.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayerHouseDto(
|
||||
val serial: String = "",
|
||||
val stage: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
val y: Int? = null,
|
||||
val z: Int? = null,
|
||||
val region: String? = null,
|
||||
val name: String? = null,
|
||||
val ownerSerial: String? = null,
|
||||
val ownerAcct: String? = null,
|
||||
val builtOn: String? = null,
|
||||
val lastRefreshed: String? = null,
|
||||
val isIdoc: Boolean = false,
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
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,
|
||||
) {
|
||||
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) }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.data.api.AuthApi
|
||||
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.TotpRequiredError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
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 sessionManager: SessionManager,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
/** Outcome of a login attempt (§4.1). */
|
||||
sealed interface LoginResult {
|
||||
data object Success : 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
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
|
||||
val response: Response<MobileTokenResponse> = try {
|
||||
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return LoginResult.NetworkError
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return LoginResult.ServerError
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
return LoginResult.Success
|
||||
}
|
||||
|
||||
return when (response.code()) {
|
||||
401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials
|
||||
429 -> LoginResult.RateLimited
|
||||
else -> LoginResult.ServerError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.ServerUrl
|
||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||
@@ -24,6 +25,7 @@ class ConnectionRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
|
||||
@@ -34,6 +36,13 @@ class ConnectionRepository @Inject constructor(
|
||||
|
||||
/** Reachable and 2xx, but not a Runic Gateway backend (wrong version identity). */
|
||||
data object NotRunicGateway : ProbeResult
|
||||
|
||||
/**
|
||||
* A Runic Gateway backend, but speaking an API version this app build does
|
||||
* not support (§3 version guard) — refuse rather than mis-render. [serverApi]
|
||||
* is what the site reported; [supportedApi] is what this app speaks.
|
||||
*/
|
||||
data class VersionMismatch(val serverApi: String, val supportedApi: String) : ProbeResult
|
||||
data class ServerError(val status: Int) : ProbeResult
|
||||
data class Unreachable(val cause: Throwable) : ProbeResult
|
||||
}
|
||||
@@ -66,14 +75,15 @@ class ConnectionRepository @Inject constructor(
|
||||
?: return ProbeResult.InvalidUrl(ServerUrl.Reason.MALFORMED)
|
||||
|
||||
return when (val result = safeApiCall { api.probeStatus(statusUrl) }) {
|
||||
is ApiResult.Ok -> {
|
||||
if (!result.data.version.service.equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
||||
ProbeResult.NotRunicGateway
|
||||
} else {
|
||||
is ApiResult.Ok -> when (val verdict = evaluateVersion(result.data.version)) {
|
||||
is VersionVerdict.Ok -> {
|
||||
prefs.setBaseUrl(normalized.toString())
|
||||
baseUrlHolder.set(normalized)
|
||||
ProbeResult.Success(result.data)
|
||||
}
|
||||
is VersionVerdict.NotRunicGateway -> ProbeResult.NotRunicGateway
|
||||
is VersionVerdict.Mismatch ->
|
||||
ProbeResult.VersionMismatch(serverApi = verdict.serverApi, supportedApi = SUPPORTED_API)
|
||||
}
|
||||
is ApiResult.HttpError -> ProbeResult.ServerError(result.status)
|
||||
is ApiResult.NetworkError -> ProbeResult.Unreachable(result.cause)
|
||||
@@ -81,15 +91,48 @@ class ConnectionRepository @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset for a Settings → Server switch (§3): clear the saved URL and
|
||||
* deactivate it. Token/cache clearing joins here in M3 once sessions exist.
|
||||
* Hard reset for a Settings → Server switch (§3): sign out (clear stored
|
||||
* tokens), clear the saved URL, and deactivate it — the app returns to a
|
||||
* signed-out state against the new host.
|
||||
*/
|
||||
suspend fun disconnect() {
|
||||
sessionManager.onSignedOut()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The pure outcome of inspecting a probed site's [VersionDto] (§3 version guard). */
|
||||
internal sealed interface VersionVerdict {
|
||||
data object Ok : VersionVerdict
|
||||
data object NotRunicGateway : VersionVerdict
|
||||
data class Mismatch(val serverApi: String) : VersionVerdict
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
const val RUNIC_SERVICE_ID = "runic-gateway"
|
||||
|
||||
/** The backend API major version this app build speaks (matches `/public/version` `api`). */
|
||||
const val SUPPORTED_API = "v1"
|
||||
|
||||
/**
|
||||
* Decide whether a probed site is a Runic Gateway backend this app can talk
|
||||
* to. Pure (no I/O) so it is unit-testable without a live site. Lenient on a
|
||||
* blank `api` (an older backend that predates version surfacing); refuses only
|
||||
* an API version we positively know we can't parse (e.g. a future `v2`).
|
||||
*/
|
||||
fun evaluateVersion(
|
||||
version: com.runicgateway.app.data.api.dto.VersionDto,
|
||||
supportedApi: String = SUPPORTED_API,
|
||||
): VersionVerdict {
|
||||
if (!version.service.trim().equals(RUNIC_SERVICE_ID, ignoreCase = true)) {
|
||||
return VersionVerdict.NotRunicGateway
|
||||
}
|
||||
val serverApi = version.api.trim()
|
||||
return when {
|
||||
serverApi.isEmpty() -> VersionVerdict.Ok
|
||||
serverApi.equals(supportedApi, ignoreCase = true) -> VersionVerdict.Ok
|
||||
else -> VersionVerdict.Mismatch(serverApi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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() }
|
||||
}
|
||||
@@ -6,9 +6,15 @@ 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.TokenAuthenticator
|
||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||
import com.runicgateway.app.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -42,16 +48,25 @@ object NetworkModule {
|
||||
return UserAgentInterceptor(ua)
|
||||
}
|
||||
|
||||
/**
|
||||
* The main client every API and the SSE stream ride on. Order: identify (UA),
|
||||
* retarget onto the configured shard host, then attach the bearer; the
|
||||
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
|
||||
* so it observes the final, authed request.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
hostSelectionInterceptor: HostSelectionInterceptor,
|
||||
userAgentInterceptor: UserAgentInterceptor,
|
||||
authInterceptor: AuthInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
// User-Agent first, then host retargeting, so both apply to every call.
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(hostSelectionInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
builder.addInterceptor(
|
||||
@@ -75,4 +90,44 @@ object NetworkModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::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)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.di
|
||||
|
||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -15,6 +14,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
@@ -22,13 +22,20 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -37,13 +44,24 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||
import com.runicgateway.app.ui.player.CharactersScreen
|
||||
import com.runicgateway.app.ui.player.MyHousesScreen
|
||||
import com.runicgateway.app.ui.player.VendorsScreen
|
||||
import com.runicgateway.app.ui.session.SessionViewModel
|
||||
import com.runicgateway.app.ui.shard.ChampsScreen
|
||||
import com.runicgateway.app.ui.shard.GovernorsScreen
|
||||
import com.runicgateway.app.ui.shard.GuildsScreen
|
||||
@@ -54,27 +72,18 @@ import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
import com.runicgateway.app.ui.wiki.WikiScreen
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** A navigation menu entry (PLAN.md §5). For M1 every entry is public. */
|
||||
private data class MenuEntry(val route: String, val labelRes: Int)
|
||||
|
||||
private val PUBLIC_MENU = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
)
|
||||
|
||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||
private val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE,
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
)
|
||||
|
||||
/**
|
||||
* The main app shell once a shard site is configured (PLAN.md §5): one shared,
|
||||
* declarative navigation drawer over the public content graph, plus the
|
||||
* Settings → Server switch. The signed-in menu groups and auth toggle join in M3.
|
||||
* declarative, access-level navigation drawer whose entries are filtered by the
|
||||
* current session, plus the Sign in / Sign out toggle and the Settings → Server
|
||||
* switch. The signed-in role is re-validated against the backend on every resume
|
||||
* (§4.3), so a server-side demotion drops menu access promptly.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -82,29 +91,45 @@ fun RunicApp(
|
||||
brand: BrandDto?,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||
LifecycleResumeEffect(Unit) {
|
||||
sessionViewModel.revalidate()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
val entries = visibleEntries(APP_MENU, session)
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = isTopLevel,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
val drawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = androidx.compose.material3.MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PUBLIC_MENU.forEach { entry ->
|
||||
entries.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(entry.labelRes)) },
|
||||
selected = currentRoute == entry.route,
|
||||
@@ -112,10 +137,34 @@ fun RunicApp(
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateTopLevel(entry.route)
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// Sign in / Sign out toggles on the session (§5).
|
||||
val signInLabel = if (session is Session.SignedIn) {
|
||||
R.string.menu_sign_out
|
||||
} else {
|
||||
R.string.menu_sign_in
|
||||
}
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(signInLabel)) },
|
||||
selected = false,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
if (session is Session.SignedIn) {
|
||||
sessionViewModel.signOut()
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
} else {
|
||||
navController.navigate(Routes.LOGIN)
|
||||
}
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(R.string.menu_change_server)) },
|
||||
selected = false,
|
||||
@@ -123,6 +172,7 @@ fun RunicApp(
|
||||
scope.launch { drawerState.close() }
|
||||
onChangeServer()
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
@@ -132,10 +182,19 @@ fun RunicApp(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name),
|
||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)).uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -158,6 +217,9 @@ fun RunicApp(
|
||||
RunicNavHost(
|
||||
navController = navController,
|
||||
brand = brand,
|
||||
session = session,
|
||||
onSignOut = { sessionViewModel.signOut() },
|
||||
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
@@ -168,6 +230,9 @@ fun RunicApp(
|
||||
private fun RunicNavHost(
|
||||
navController: NavHostController,
|
||||
brand: BrandDto?,
|
||||
session: Session,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
@@ -226,6 +291,63 @@ private fun RunicNavHost(
|
||||
composable(Routes.CONTACT) {
|
||||
ContactScreen()
|
||||
}
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(onSignedIn = { navController.popBackStack() })
|
||||
}
|
||||
composable(Routes.ACCOUNT) {
|
||||
// Only meaningful while signed in; a sign-out (here or from the drawer)
|
||||
// sends the user home rather than leaving a stale identity on screen.
|
||||
when (val s = session) {
|
||||
is Session.SignedIn -> AccountScreen(
|
||||
username = s.user.username,
|
||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||
onSignOut = onSignOut,
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Player game data (§6.3) — reached from the player-only menu groups.
|
||||
// The server enforces the player gate on every call; these screens simply
|
||||
// render 401/403/503 as clean states (§7).
|
||||
composable(Routes.PLAYER_CHARACTERS) {
|
||||
PlayerGate(session, navController) {
|
||||
CharactersScreen(onOpenChar = { serial -> navController.navigate(Routes.playerChar(serial)) })
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = Routes.PLAYER_CHAR,
|
||||
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
|
||||
) {
|
||||
CharacterSheetScreen()
|
||||
}
|
||||
composable(Routes.PLAYER_VENDORS) {
|
||||
PlayerGate(session, navController) { VendorsScreen() }
|
||||
}
|
||||
composable(Routes.PLAYER_HOUSES) {
|
||||
PlayerGate(session, navController) { MyHousesScreen() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A UX guard for the player-only groups: while signed in, render [content]; if the
|
||||
* session drops (sign-out, or a server-side demotion caught on resume, §4.3), send
|
||||
* the user home instead of leaving a stale player screen up. The backend remains
|
||||
* the authority — this only mirrors the menu's visibility rule.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlayerGate(
|
||||
session: Session,
|
||||
navController: NavHostController,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> content()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
362
app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt
Normal file
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.auth.AccountViewModel.Feedback
|
||||
import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The signed-in account surface (PLAN.md §5, §6.3): identity + role, self-service
|
||||
* over `/auth/me/account*` (change username/password, TOTP, linked identities),
|
||||
* and the sign-out controls. Credential-provisioning flows (register / reset / SSO
|
||||
* link) stay website hand-offs (§4.2) and are not rebuilt here.
|
||||
*/
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
username: String,
|
||||
roleLabel: String,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
IdentityCard(username = username, roleLabel = roleLabel)
|
||||
|
||||
when (val account = state.account) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.account_sign_out))
|
||||
}
|
||||
TextButton(
|
||||
onClick = onSignOutEverywhere,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.account_sign_out_all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||
StatusPill(
|
||||
text = roleLabel,
|
||||
tone = PillTone.Info,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountSections(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
UsernameSection(account, state, viewModel)
|
||||
PasswordSection(account, state, viewModel)
|
||||
TwoFactorSection(account, state, viewModel)
|
||||
IdentitiesSection(state, viewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsernameSection(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
var username by rememberSaveable(account.username) { mutableStateOf(account.username) }
|
||||
SectionCard(R.string.account_username_title) {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.changeUsername(username) },
|
||||
enabled = !state.busy && username.trim() != account.username && username.trim().length >= 3,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_username_action)) }
|
||||
SectionFeedback(state.feedback, Section.USERNAME)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordSection(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
val hasPassword = account.has_password
|
||||
var current by rememberSaveable { mutableStateOf("") }
|
||||
var next by rememberSaveable { mutableStateOf("") }
|
||||
SectionCard(if (hasPassword) R.string.account_password_title else R.string.account_password_set_title) {
|
||||
if (!hasPassword) {
|
||||
Text(
|
||||
stringResource(R.string.account_password_set_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (hasPassword) {
|
||||
OutlinedTextField(
|
||||
value = current,
|
||||
onValueChange = { current = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_current)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = next,
|
||||
onValueChange = { next = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_new)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.changePassword(next, if (hasPassword) current else null)
|
||||
current = ""
|
||||
next = ""
|
||||
},
|
||||
enabled = !state.busy && next.length >= 8 && (!hasPassword || current.isNotBlank()),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) {
|
||||
Text(stringResource(if (hasPassword) R.string.account_password_action else R.string.account_password_set_action))
|
||||
}
|
||||
SectionFeedback(state.feedback, Section.PASSWORD)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TwoFactorSection(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
SectionCard(R.string.account_totp_title) {
|
||||
StatusPill(
|
||||
text = stringResource(if (account.totp_enabled) R.string.account_totp_on else R.string.account_totp_off),
|
||||
tone = if (account.totp_enabled) PillTone.Success else PillTone.Neutral,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
when {
|
||||
// Enabled → offer disable via a current code.
|
||||
account.totp_enabled -> {
|
||||
CodeField(code, { code = it }, !state.busy)
|
||||
Button(
|
||||
onClick = { viewModel.disableTotp(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_totp_disable)) }
|
||||
}
|
||||
// Mid-enrollment → show QR + confirm.
|
||||
state.totpSetup != null -> {
|
||||
Text(
|
||||
stringResource(R.string.account_totp_scan),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
rememberQrBitmap(state.totpSetup.qr)?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = stringResource(R.string.account_totp_qr_desc),
|
||||
modifier = Modifier.padding(top = 12.dp).size(180.dp),
|
||||
)
|
||||
}
|
||||
CodeField(code, { code = it }, !state.busy)
|
||||
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.enableTotp(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
) { Text(stringResource(R.string.account_totp_confirm)) }
|
||||
OutlinedButton(onClick = { viewModel.cancelTotp(); code = "" }, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.account_totp_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not enabled, not enrolling → start.
|
||||
else -> {
|
||||
Button(
|
||||
onClick = viewModel::beginTotp,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_totp_setup)) }
|
||||
}
|
||||
}
|
||||
SectionFeedback(state.feedback, Section.TOTP)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentitiesSection(state: AccountViewModel.State, viewModel: AccountViewModel) {
|
||||
SectionCard(R.string.account_identities_title) {
|
||||
if (state.identities.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.account_identities_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
state.identities.forEach { identity -> IdentityRow(identity, state.busy, viewModel) }
|
||||
}
|
||||
SectionFeedback(state.feedback, Section.IDENTITY)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityRow(identity: LinkedIdentityDto, busy: Boolean, viewModel: AccountViewModel) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
identity.provider.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
identity.email?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { viewModel.unlinkIdentity(identity.provider) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.account_identity_unlink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeField(code: String, onChange: (String) -> Unit, enabled: Boolean) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { onChange(it.filter(Char::isDigit).take(8)) },
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionFeedback(feedback: Feedback?, section: Section) {
|
||||
if (feedback == null || feedback.section != section) return
|
||||
Text(
|
||||
text = stringResource(feedback.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Decode a `data:image/png;base64,…` URL (the TOTP QR) into an [ImageBitmap]. */
|
||||
@Composable
|
||||
private fun rememberQrBitmap(dataUrl: String?): ImageBitmap? = remember(dataUrl) {
|
||||
if (dataUrl.isNullOrBlank()) return@remember null
|
||||
val comma = dataUrl.indexOf(',')
|
||||
if (comma < 0) return@remember null
|
||||
runCatching {
|
||||
val bytes = Base64.decode(dataUrl.substring(comma + 1), Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Human label for a role (advisory display only — §4.3). */
|
||||
@StringRes
|
||||
fun roleLabelRes(role: Role): Int = when (role) {
|
||||
Role.PLAYER -> R.string.role_player
|
||||
Role.MODERATOR -> R.string.role_moderator
|
||||
Role.EDITOR -> R.string.role_editor
|
||||
Role.ADMIN -> R.string.role_admin
|
||||
Role.UNKNOWN -> R.string.role_unknown
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the signed-in self-service surface (PLAN.md §6.3, §6.4) over
|
||||
* `/auth/me/account*`: change username/password, enroll/disable TOTP, and manage
|
||||
* linked SSO identities. Each mutation folds its [ApiResult] into a section-scoped
|
||||
* [Feedback] so the screen shows friendly, localized copy inline (§7). A username
|
||||
* change also re-validates the session so the shell reflects the new name at once.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AccountViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Which section an action's [Feedback] belongs to, so it renders in place. */
|
||||
enum class Section { USERNAME, PASSWORD, TOTP, IDENTITY }
|
||||
|
||||
/** A one-shot result banner under a section. */
|
||||
data class Feedback(val section: Section, val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val account: UiState<PlayerAccountDto> = UiState.Loading,
|
||||
val identities: List<LinkedIdentityDto> = emptyList(),
|
||||
/** True while any mutation is in flight (disables that section's controls). */
|
||||
val busy: Boolean = false,
|
||||
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||
val totpSetup: TotpSetupDto? = null,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(account = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
val account = accountRepository.getAccount()
|
||||
_state.update { it.copy(account = account.toUiState()) }
|
||||
// Identities are non-critical — an empty list on failure is fine.
|
||||
when (val ids = accountRepository.identities()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
// ── Username ─────────────────────────────────────────────────────────
|
||||
fun changeUsername(username: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.changeUsername(username.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.USERNAME, true, R.string.account_username_changed)
|
||||
// Reflect the new name in the shell + refresh the loaded account.
|
||||
authRepository.revalidate()
|
||||
reloadAccount()
|
||||
}
|
||||
else -> finish(Section.USERNAME, false, usernameErrorRes(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Password ─────────────────────────────────────────────────────────
|
||||
fun changePassword(newPassword: String, currentPassword: String?) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.changePassword(newPassword, currentPassword?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Ok -> finish(Section.PASSWORD, true, R.string.account_password_changed)
|
||||
is ApiResult.HttpError -> finish(Section.PASSWORD, false, R.string.account_password_error)
|
||||
is ApiResult.NetworkError -> finish(Section.PASSWORD, false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TOTP ─────────────────────────────────────────────────────────────
|
||||
fun beginTotp() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.totpSetup()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(busy = false, totpSetup = result.data) }
|
||||
else -> finish(Section.TOTP, false, R.string.account_totp_setup_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTotp() = _state.update { it.copy(totpSetup = null, feedback = null) }
|
||||
|
||||
fun enableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.totpEnable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(totpSetup = null) }
|
||||
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||
reloadAccount()
|
||||
}
|
||||
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.totpDisable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.TOTP, true, R.string.account_totp_disabled)
|
||||
reloadAccount()
|
||||
}
|
||||
is ApiResult.HttpError -> finish(Section.TOTP, false, R.string.account_totp_code_error)
|
||||
is ApiResult.NetworkError -> finish(Section.TOTP, false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identities ───────────────────────────────────────────────────────
|
||||
fun unlinkIdentity(provider: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.unlinkIdentity(provider)) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(Section.IDENTITY, true, R.string.account_identity_unlinked)
|
||||
when (val ids = accountRepository.identities()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(identities = ids.data) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
else -> finish(Section.IDENTITY, false, R.string.account_identity_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadAccount() {
|
||||
when (val account = accountRepository.getAccount()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(account = UiState.Success(account.data)) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(section: Section, ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(section, ok, messageRes)) }
|
||||
|
||||
private fun usernameErrorRes(result: ApiResult<*>): Int = when {
|
||||
result is ApiResult.HttpError && result.status == 409 -> R.string.account_username_taken
|
||||
result is ApiResult.NetworkError -> R.string.error_network
|
||||
else -> R.string.account_username_error
|
||||
}
|
||||
}
|
||||
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.web.WebHandoff
|
||||
import com.runicgateway.app.ui.auth.LoginViewModel.LoginError
|
||||
|
||||
/**
|
||||
* Native username/password (+TOTP) login (PLAN.md §4.1) — the app's only native
|
||||
* credential screen. Registration, forgot-password, and SSO are website hand-offs
|
||||
* opened in a Custom Tab (§4.2); the user completes them in the browser and
|
||||
* returns here to sign in.
|
||||
*/
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onSignedIn: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.signedIn) {
|
||||
if (state.signedIn) onSignedIn()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.login_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = if (state.totpRequired) ImeAction.Next else ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
if (state.totpRequired) {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_totp_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
Text(
|
||||
text = stringResource(loginErrorRes(err)),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = viewModel::submit,
|
||||
enabled = !state.submitting,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp),
|
||||
) {
|
||||
if (state.submitting) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.login_button))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
||||
viewModel.registerUrl?.let { url ->
|
||||
TextButton(
|
||||
onClick = { WebHandoff.open(context, url) },
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
) { Text(stringResource(R.string.login_register)) }
|
||||
}
|
||||
viewModel.forgotPasswordUrl?.let { url ->
|
||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||
Text(stringResource(R.string.login_forgot))
|
||||
}
|
||||
}
|
||||
viewModel.ssoLoginUrl?.let { url ->
|
||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||
Text(stringResource(R.string.login_sso))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginErrorRes(error: LoginError): Int = when (error) {
|
||||
LoginError.INVALID_CREDENTIALS -> R.string.login_error_credentials
|
||||
LoginError.BAD_CODE -> R.string.login_error_code
|
||||
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
|
||||
LoginError.SERVER -> R.string.login_error_server
|
||||
LoginError.NETWORK -> R.string.login_error_network
|
||||
}
|
||||
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.web.WebsiteUrls
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the native login screen (PLAN.md §4.1): username/password, single-request
|
||||
* TOTP (the code field is revealed once the backend answers `totpRequired`), and
|
||||
* friendly 429 backoff handling. Registration / forgot-password / SSO are website
|
||||
* hand-offs whose URLs it exposes (§4.2).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val websiteUrls: WebsiteUrls,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The transient error surfaced under the form after a failed attempt. */
|
||||
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK }
|
||||
|
||||
data class UiState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val code: String = "",
|
||||
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||
val totpRequired: Boolean = false,
|
||||
val submitting: Boolean = false,
|
||||
val error: LoginError? = null,
|
||||
val signedIn: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
|
||||
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
||||
fun onCodeChange(value: String) =
|
||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||
|
||||
val registerUrl: String? get() = websiteUrls.register()
|
||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||
val ssoLoginUrl: String? get() = websiteUrls.login()
|
||||
|
||||
fun submit() {
|
||||
val s = _state.value
|
||||
if (s.submitting) return
|
||||
if (s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
||||
return
|
||||
}
|
||||
// If 2FA is being requested, a code must accompany the resubmit.
|
||||
if (s.totpRequired && s.code.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() }
|
||||
when (authRepository.login(s.username.trim(), s.password, code)) {
|
||||
LoginResult.Success ->
|
||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
LoginResult.InvalidCredentials ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
|
||||
|
||||
LoginResult.RateLimited ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
|
||||
|
||||
LoginResult.ServerError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
|
||||
|
||||
LoginResult.NetworkError ->
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.ui.theme.ShardCardBottom
|
||||
import com.runicgateway.app.ui.theme.ShardCardTop
|
||||
import com.runicgateway.app.ui.theme.ShardDanger
|
||||
import com.runicgateway.app.ui.theme.ShardDangerBg
|
||||
import com.runicgateway.app.ui.theme.ShardElevated
|
||||
import com.runicgateway.app.ui.theme.ShardFaint
|
||||
import com.runicgateway.app.ui.theme.ShardOutline
|
||||
import com.runicgateway.app.ui.theme.ShardPillBg
|
||||
import com.runicgateway.app.ui.theme.ShardPillFg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||
import com.runicgateway.app.ui.theme.ShardWarning
|
||||
import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||
|
||||
/**
|
||||
* Shared visual building blocks for the M5 shard-website design pass
|
||||
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||
* no state, no data dependencies — so any screen can adopt them.
|
||||
*/
|
||||
|
||||
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||
enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
||||
|
||||
private data class PillColors(val fg: Color, val bg: Color)
|
||||
|
||||
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
||||
}
|
||||
|
||||
/**
|
||||
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
||||
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val c = toneColors(tone)
|
||||
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = c.fg,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The small colored status dot (green when live), matched to a [PillTone]. */
|
||||
@Composable
|
||||
fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val color = when (tone) {
|
||||
PillTone.Success -> ShardSuccessDot
|
||||
PillTone.Warning -> ShardWarning
|
||||
PillTone.Danger -> ShardDanger
|
||||
PillTone.Neutral, PillTone.Info -> ShardFaint
|
||||
}
|
||||
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
|
||||
/**
|
||||
* The muted, uppercase, letter-spaced section header the design uses above grouped
|
||||
* content ("Attributes", "Vitals", "Password", "Linked accounts").
|
||||
*/
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ShardFaint,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
||||
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||
* vendor card. [content] is laid out in a padded [Column].
|
||||
*/
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: Int = 18,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Column(Modifier.padding(contentPadding.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||
* the slate accent over a bordered dark track.
|
||||
*/
|
||||
@Composable
|
||||
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||
val pct = fraction.coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(ShardElevated)
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(pct)
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.secondary),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ private fun errorMessage(error: ConnectError): String = when (error) {
|
||||
ConnectError.UnsupportedScheme -> stringResource(R.string.connect_error_scheme)
|
||||
ConnectError.Insecure -> stringResource(R.string.connect_error_insecure)
|
||||
ConnectError.NotRunicGateway -> stringResource(R.string.connect_error_not_runic)
|
||||
is ConnectError.VersionMismatch -> stringResource(R.string.connect_error_version, error.serverApi)
|
||||
ConnectError.Unreachable -> stringResource(R.string.connect_error_unreachable)
|
||||
is ConnectError.Server -> stringResource(R.string.connect_error_server, error.status)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ConnectViewModel @Inject constructor(
|
||||
data object UnsupportedScheme : ConnectError
|
||||
data object Insecure : ConnectError
|
||||
data object NotRunicGateway : ConnectError
|
||||
data class VersionMismatch(val serverApi: String) : ConnectError
|
||||
data object Unreachable : ConnectError
|
||||
data class Server(val status: Int) : ConnectError
|
||||
}
|
||||
@@ -61,6 +62,7 @@ class ConnectViewModel @Inject constructor(
|
||||
}
|
||||
is ProbeResult.InvalidUrl -> fail(result.reason.toError())
|
||||
ProbeResult.NotRunicGateway -> fail(ConnectError.NotRunicGateway)
|
||||
is ProbeResult.VersionMismatch -> fail(ConnectError.VersionMismatch(result.serverApi))
|
||||
is ProbeResult.Unreachable -> fail(ConnectError.Unreachable)
|
||||
is ProbeResult.ServerError -> fail(ConnectError.Server(result.status))
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
package com.runicgateway.app.ui.home
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -27,7 +27,10 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.OnlineDot
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
|
||||
/**
|
||||
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
|
||||
@@ -83,40 +86,37 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
||||
@Composable
|
||||
private fun StatusCard(status: StatusDto) {
|
||||
val online = !status.isMaintenance
|
||||
val containerColor =
|
||||
if (online) MaterialTheme.colorScheme.secondaryContainer
|
||||
else MaterialTheme.colorScheme.errorContainer
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
FeatureCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OnlineDot(if (online) PillTone.Success else PillTone.Danger)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (online) R.string.home_status_live else R.string.home_status_maintenance,
|
||||
),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = status.statusMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
if (status.version.server.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.home_server_version,
|
||||
status.version.server,
|
||||
status.version.api,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
if (status.isMaintenance && status.statusMessage.isNotBlank()) {
|
||||
Text(
|
||||
text = status.statusMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
if (status.version.server.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.home_server_version,
|
||||
status.version.server,
|
||||
status.version.api,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
|
||||
/**
|
||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||
* single list where each entry declares the minimum access it needs, filtered by
|
||||
* the current session — not a pile of `if role ==` checks. The server stays the
|
||||
* source of truth; a hidden item is a UX convenience and every gated call still
|
||||
* enforces on the backend.
|
||||
*/
|
||||
enum class MenuAccess {
|
||||
/** Visible to everyone, signed in or not. */
|
||||
PUBLIC,
|
||||
|
||||
/** Visible to any signed-in account (§5, "My Account"). */
|
||||
SIGNED_IN,
|
||||
|
||||
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||
PLAYER,
|
||||
}
|
||||
|
||||
data class MenuEntry(
|
||||
val route: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||
)
|
||||
|
||||
/**
|
||||
* The full menu, in display order. Public content first, then the signed-in
|
||||
* surfaces, then the player-only game-data groups (revealed once the session's
|
||||
* role is `player`, §6.3).
|
||||
*/
|
||||
val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
||||
)
|
||||
|
||||
/**
|
||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
||||
* gating is unit-tested without Compose.
|
||||
*/
|
||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ object Routes {
|
||||
const val WIKI = "wiki"
|
||||
const val CONTACT = "contact"
|
||||
|
||||
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
|
||||
@@ -23,6 +27,14 @@ object Routes {
|
||||
const val SHARD_GOVERNORS = "shard/governors"
|
||||
const val SHARD_HOUSES = "shard/houses"
|
||||
|
||||
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
|
||||
const val PLAYER_CHARACTERS = "player/characters"
|
||||
const val PLAYER_VENDORS = "player/vendors"
|
||||
const val PLAYER_HOUSES = "player/houses"
|
||||
|
||||
/** A single character sheet by in-game (hex) serial. */
|
||||
const val PLAYER_CHAR = "player/char/{serial}"
|
||||
|
||||
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
||||
const val PAGE = "page/{slug}"
|
||||
|
||||
@@ -36,9 +48,13 @@ object Routes {
|
||||
const val SLUG = "slug"
|
||||
const val CATEGORY = "category"
|
||||
const val ID_OR_SLUG = "idOrSlug"
|
||||
const val SERIAL = "serial"
|
||||
}
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
||||
fun wikiPage(slug: String) = "wiki/$slug"
|
||||
|
||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||
fun playerChar(serial: String) = "player/char/$serial"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||
import com.runicgateway.app.data.api.dto.ResistDto
|
||||
import com.runicgateway.app.data.api.dto.SkillDto
|
||||
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatBar
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* A text-only character sheet (PLAN.md §6.3): identity + standing, attributes and
|
||||
* vitals, resistances, skills, and equipment. No item icons / paperdoll art — a
|
||||
* richer view is a future enhancement pending the platform art work.
|
||||
*/
|
||||
@Composable
|
||||
fun CharacterSheetScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CharacterViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> CharacterSheet(s.data, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
IdentityBlock(char)
|
||||
StandingChips(char)
|
||||
char.stats?.let { AttributesBlock(it) }
|
||||
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||
SkillsBlock(char.skills)
|
||||
EquipmentBlock(char.equipment)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityBlock(char: CharProfileDto) {
|
||||
Column {
|
||||
Text(
|
||||
char.name ?: stringResource(R.string.player_char_unknown),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
char.title?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Row(Modifier.padding(top = 4.dp)) {
|
||||
Text(
|
||||
stringResource(if (char.online) R.string.player_char_online else R.string.player_char_offline),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (char.online) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
char.serial?.let {
|
||||
Text(
|
||||
" · $it",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun StandingChips(char: CharProfileDto) {
|
||||
val chips = buildList {
|
||||
char.governorOf.forEach { add(stringResource(R.string.player_char_governor, it)) }
|
||||
char.guild?.let { g ->
|
||||
val abbr = g.abbr?.let { " [$it]" } ?: ""
|
||||
add(stringResource(R.string.player_char_guildmaster, "${g.name.orEmpty()}$abbr"))
|
||||
}
|
||||
addAll(displayTitles(char.titles))
|
||||
}
|
||||
if (chips.isEmpty()) return
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
chips.forEach { Chip(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(text: String) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttributesBlock(stats: CharStatsDto) {
|
||||
SheetCard(R.string.player_char_attributes) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Stat(stringResource(R.string.player_char_str), stats.str)
|
||||
Stat(stringResource(R.string.player_char_dex), stats.dex)
|
||||
Stat(stringResource(R.string.player_char_int), stats.int)
|
||||
}
|
||||
Column(Modifier.padding(top = 16.dp)) {
|
||||
SectionLabel(stringResource(R.string.player_char_vitals))
|
||||
Column(Modifier.padding(top = 8.dp)) {
|
||||
Vital(stringResource(R.string.player_char_hits), stats.hits, stats.hitsMax)
|
||||
Vital(stringResource(R.string.player_char_mana), stats.mana, stats.manaMax)
|
||||
Vital(stringResource(R.string.player_char_stam), stats.stam, stats.stamMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Stat(label: String, value: Int?) {
|
||||
Column {
|
||||
Text("${value ?: "—"}", style = MaterialTheme.typography.titleLarge)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Vital(label: String, cur: Int?, max: Int?) {
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${cur ?: "—"} / ${max ?: "—"}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
val fraction = if (max != null && max > 0 && cur != null) cur.toFloat() / max else 0f
|
||||
StatBar(fraction)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResistancesBlock(resist: ResistDto) {
|
||||
val rows = listOf(
|
||||
stringResource(R.string.player_char_phys) to resist.phys,
|
||||
stringResource(R.string.player_char_fire) to resist.fire,
|
||||
stringResource(R.string.player_char_cold) to resist.cold,
|
||||
stringResource(R.string.player_char_pois) to resist.pois,
|
||||
stringResource(R.string.player_char_energy) to resist.energy,
|
||||
)
|
||||
if (rows.all { it.second == null }) return
|
||||
SheetCard(R.string.player_char_resistances) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
rows.forEach { (label, value) ->
|
||||
Column {
|
||||
Text("${value ?: 0}", style = MaterialTheme.typography.titleMedium)
|
||||
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkillsBlock(skills: List<SkillDto>) {
|
||||
val shown = skills
|
||||
.filter { (it.value ?: it.base ?: 0.0) > 0.0 }
|
||||
.sortedByDescending { it.value ?: 0.0 }
|
||||
if (shown.isEmpty()) return
|
||||
SheetCard(R.string.player_char_skills) {
|
||||
shown.forEach { skill ->
|
||||
val value = skill.value ?: 0.0
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(skill.n ?: "—", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(formatSkill(value), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
StatBar((value / SKILL_CAP).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
if (equipment.isEmpty()) return
|
||||
SheetCard(R.string.player_char_equipment) {
|
||||
equipment.forEach { item ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
item.layer ?: stringResource(R.string.player_char_item),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
item.itemId?.let { stringResource(R.string.player_char_item_id, it) },
|
||||
item.hue?.takeIf { it != 0 }?.let { stringResource(R.string.player_char_item_hue, it) },
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
val mods = item.mods.orEmpty()
|
||||
if (mods.isNotEmpty()) {
|
||||
FlowRow(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
mods.forEach { (k, v) -> Chip("$k ${jsonText(v)}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
|
||||
|
||||
/** ServUO's default skill cap; used to scale the skill meter fill (0..1). */
|
||||
private const val SKILL_CAP = 120.0
|
||||
|
||||
/** Format a skill value: drop the ".0" on whole numbers, else one decimal. */
|
||||
internal fun formatSkill(value: Double): String =
|
||||
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
|
||||
|
||||
/**
|
||||
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||
* reward title — but only if it is a literal string, not a bare cliloc number
|
||||
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
||||
*/
|
||||
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||
if (titles == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
titles.fameKarma?.let { out.add(it) }
|
||||
titles.skill?.let { out.add(it) }
|
||||
val reward = titles.reward
|
||||
val sel = titles.selected ?: -1
|
||||
val candidate = when {
|
||||
sel in reward.indices -> reward[sel]
|
||||
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
||||
}
|
||||
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
||||
return out.filter { it.isNotBlank() }.distinct()
|
||||
}
|
||||
|
||||
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A single character sheet (PLAN.md §6.3), read from `/player/shard/char/:serial`
|
||||
* for a character on one of the caller's linked accounts (ownership-checked
|
||||
* server-side). A `503` renders as offline/retry, a `403` as not-found (§7).
|
||||
* Text-only presentation for v1.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CharacterViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serial: String = savedStateHandle.get<String>(Routes.Args.SERIAL).orEmpty()
|
||||
|
||||
private val _state = MutableStateFlow<UiState<CharProfileDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<CharProfileDto>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.char(serial).toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The player's linked game accounts + character rosters (PLAN.md §6.3). Tapping a
|
||||
* character opens its text-only sheet. Not-yet-linked players get the `[link` code
|
||||
* prompt (and, when the shard offers it, a hybrid signup form).
|
||||
*/
|
||||
@Composable
|
||||
fun CharactersScreen(
|
||||
onOpenChar: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CharactersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val accounts = state.accounts) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
LinkCard(state, viewModel)
|
||||
if (state.signupEnabled) CreateAccountCard(state, viewModel)
|
||||
|
||||
if (accounts.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_characters_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
accounts.data.forEach { account ->
|
||||
AccountRoster(
|
||||
account = account,
|
||||
roster = state.rosters[account] ?: UiState.Loading,
|
||||
onRetry = { viewModel.loadRoster(account) },
|
||||
onOpenChar = onOpenChar,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.player_link_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.uppercase() },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.player_link_code)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.link(code); code = "" },
|
||||
enabled = !state.busy && code.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.player_link_action)) }
|
||||
FormFeedback(state.feedback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = account,
|
||||
onValueChange = { account = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.player_create_account)) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.player_create_password)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = { viewModel.createAccount(account, password); password = "" },
|
||||
enabled = !state.busy && account.isNotBlank() && password.length >= 8,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.player_create_action)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountRoster(
|
||||
account: String,
|
||||
roster: UiState<List<RosterCharDto>>,
|
||||
onRetry: () -> Unit,
|
||||
onOpenChar: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
when (roster) {
|
||||
is UiState.Loading -> Text(
|
||||
stringResource(R.string.player_roster_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is UiState.Error -> RosterError(roster.kind, onRetry)
|
||||
is UiState.Success -> {
|
||||
if (roster.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_roster_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
roster.data.forEach { CharRow(it, onOpenChar) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||
Card(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(enabled = char.serial.isNotBlank()) { onOpenChar(char.serial) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
char.name ?: stringResource(R.string.player_char_unknown),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
if (char.online) R.string.player_char_online else R.string.player_char_offline,
|
||||
),
|
||||
tone = if (char.online) PillTone.Success else PillTone.Neutral,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text("›", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FormFeedback(feedback: CharactersViewModel.Feedback?) {
|
||||
if (feedback == null) return
|
||||
Text(
|
||||
text = stringResource(feedback.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (feedback.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's linked game accounts + per-account character rosters (PLAN.md §6.3):
|
||||
* link a game account with a `[link` one-time code (or, when the shard offers it,
|
||||
* a hybrid signup), then browse characters and open a text-only sheet. A per-account
|
||||
* roster carries its own load state so a down shard degrades that account to a
|
||||
* retry without blocking the rest (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CharactersViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot banner for the link / create-account forms. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val accounts: UiState<List<String>> = UiState.Loading,
|
||||
val rosters: Map<String, UiState<List<RosterCharDto>>> = emptyMap(),
|
||||
/** Whether the shard currently offers hybrid game-account signup. */
|
||||
val signupEnabled: Boolean = false,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(accounts = UiState.Loading, rosters = emptyMap()) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.accounts()) {
|
||||
is ApiResult.Ok -> {
|
||||
val names = result.data.map { it.account }
|
||||
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||
names.forEach { loadRoster(it) }
|
||||
}
|
||||
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||
}
|
||||
}
|
||||
// Non-critical: whether hybrid signup is offered right now.
|
||||
viewModelScope.launch {
|
||||
when (val settings = settingsRepository.getSettings()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(signupEnabled = settings.data.gameAccountSignup) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadRoster(account: String) {
|
||||
_state.update { it.copy(rosters = it.rosters + (account to UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
val roster = repository.roster(account).map { it.chars }.toUiState()
|
||||
_state.update { it.copy(rosters = it.rosters + (account to roster)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun link(code: String) {
|
||||
if (_state.value.busy || code.isBlank()) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.link(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_link_ok)) }
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, linkErrorRes(result.status)))
|
||||
}
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAccount(account: String, password: String) {
|
||||
if (_state.value.busy || account.isBlank() || password.length < 8) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.createAccount(account.trim(), password)) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(true, R.string.player_create_ok)) }
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
|
||||
}
|
||||
is ApiResult.NetworkError -> _state.update {
|
||||
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun linkErrorRes(status: Int): Int = when (status) {
|
||||
400 -> R.string.player_link_bad_code
|
||||
503 -> R.string.error_shard_offline
|
||||
else -> R.string.player_link_error
|
||||
}
|
||||
|
||||
private fun createErrorRes(status: Int): Int = when (status) {
|
||||
409 -> R.string.player_create_taken
|
||||
429 -> R.string.player_create_capped
|
||||
403 -> R.string.player_create_unavailable
|
||||
400 -> R.string.player_create_rejected
|
||||
503 -> R.string.error_shard_offline
|
||||
else -> R.string.player_create_error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/**
|
||||
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||
* IDOC house is flagged prominently. Only the caller's own property is ever shown.
|
||||
*/
|
||||
@Composable
|
||||
fun MyHousesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MyHousesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.player_houses_empty), modifier)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
items(s.data, key = { it.serial }) { house -> HouseCard(house) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: PlayerHouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = house.name?.takeIf { it.isNotBlank() }
|
||||
?: house.region
|
||||
?: stringResource(R.string.player_house_fallback),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (house.isIdoc) {
|
||||
Text(
|
||||
stringResource(R.string.houses_idoc_badge),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
house.stage?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
stringResource(R.string.player_house_stage, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (house.isIdoc) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(
|
||||
house.region,
|
||||
house.map,
|
||||
house.x?.let { "(${house.x}, ${house.y})" },
|
||||
).joinToString(" · ")
|
||||
if (where.isNotBlank()) {
|
||||
Text(
|
||||
where,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's OWN houses with decay/IDOC status (PLAN.md §6.3), read from
|
||||
* `/player/shard/houses` — never anyone else's. A `503` renders as offline/retry (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MyHousesViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<PlayerHouseDto>>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<List<PlayerHouseDto>>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.houses().toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.VendorDto
|
||||
import com.runicgateway.app.data.api.dto.VendorListingDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* The player's own player-vendors + recent sales (PLAN.md §6.3), text-only. Vendor
|
||||
* shops group under their game account; recent sales list across all linked
|
||||
* accounts. A down shard degrades to a per-account retry (§7).
|
||||
*/
|
||||
@Composable
|
||||
fun VendorsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: VendorsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val accounts = state.accounts) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(accounts.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SalesCard(state.sales)
|
||||
|
||||
if (accounts.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_vendors_no_accounts),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
accounts.data.forEach { account ->
|
||||
AccountVendors(
|
||||
account = account,
|
||||
vendors = state.vendors[account] ?: UiState.Loading,
|
||||
onRetry = { viewModel.loadVendors(account) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||
when (sales) {
|
||||
is UiState.Loading -> Text(
|
||||
stringResource(R.string.player_roster_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
is UiState.Error -> Text(
|
||||
stringResource(R.string.error_server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
is UiState.Success -> {
|
||||
if (sales.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_sales_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
} else {
|
||||
sales.data.forEach { SaleRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaleRow(sale: VendorSaleDto) {
|
||||
Row(Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(Modifier.padding(end = 12.dp)) {
|
||||
Text(
|
||||
sale.itemType ?: stringResource(R.string.player_char_item),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
sale.t?.let {
|
||||
Text(
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.player_gold_amount, "%,d".format(sale.price ?: 0L)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountVendors(
|
||||
account: String,
|
||||
vendors: UiState<List<VendorDto>>,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
when (vendors) {
|
||||
is UiState.Loading -> Text(
|
||||
stringResource(R.string.player_roster_loading),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is UiState.Error -> VendorError(vendors.kind, onRetry)
|
||||
is UiState.Success -> {
|
||||
if (vendors.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.player_vendors_empty),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
vendors.data.forEach { VendorCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(if (kind == ErrorKind.SHARD_OFFLINE) R.string.error_shard_offline else R.string.error_server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VendorCard(vendor: VendorDto) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
vendor.holdGold?.let { stringResource(R.string.player_vendor_hold, "%,d".format(it)) },
|
||||
vendor.map,
|
||||
).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
if (vendor.listings.isNotEmpty()) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
vendor.listings.forEach { ListingRow(it) }
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.player_vendor_no_listings),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListingRow(listing: VendorListingDto) {
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
stringResource(R.string.player_listing_item, listing.itemId ?: 0, listing.amount ?: 1),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.player_gold_amount, "%,d".format(listing.price ?: 0L)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.data.api.dto.VendorDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The player's own player-vendors and recent vendor sales (PLAN.md §6.3): a
|
||||
* per-account vendor snapshot (shops + listings) over `/player/shard/vendors/:account`
|
||||
* plus the recent-sales log over `/player/shard/sales`. Each account's snapshot
|
||||
* carries its own load state so a down shard degrades one account to a retry (§7).
|
||||
* Text-only listings — item names are clilocs the app has no table for.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VendorsViewModel @Inject constructor(
|
||||
private val repository: PlayerShardRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
val accounts: UiState<List<String>> = UiState.Loading,
|
||||
val vendors: Map<String, UiState<List<VendorDto>>> = emptyMap(),
|
||||
val sales: UiState<List<VendorSaleDto>> = UiState.Loading,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(accounts = UiState.Loading, vendors = emptyMap(), sales = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.accounts()) {
|
||||
is ApiResult.Ok -> {
|
||||
val names = result.data.map { it.account }
|
||||
_state.update { it.copy(accounts = UiState.Success(names)) }
|
||||
names.forEach { loadVendors(it) }
|
||||
}
|
||||
else -> _state.update { it.copy(accounts = result.map { emptyList<String>() }.toUiState()) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(sales = repository.sales().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadVendors(account: String) {
|
||||
_state.update { it.copy(vendors = it.vendors + (account to UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
val vendors = repository.vendors(account).map { it.vendors }.toUiState()
|
||||
_state.update { it.copy(vendors = it.vendors + (account to vendors)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.session
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Activity-scoped view of the current session for the app shell (PLAN.md §4.3,
|
||||
* §5): the shared menu observes [session] to reveal signed-in groups + the
|
||||
* sign-in/out toggle, and the shell drives resume re-validation and sign-out.
|
||||
* Login itself lives in [com.runicgateway.app.ui.auth.LoginViewModel].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SessionViewModel @Inject constructor(
|
||||
sessionManager: SessionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val session: StateFlow<Session> = sessionManager.state
|
||||
|
||||
/** Re-validate the cached role against the backend on app resume. */
|
||||
fun revalidate() {
|
||||
viewModelScope.launch { authRepository.revalidate() }
|
||||
}
|
||||
|
||||
/** Sign out of this session, or every session with [allDevices]. */
|
||||
fun signOut(allDevices: Boolean = false) {
|
||||
viewModelScope.launch { authRepository.logout(allDevices) }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
@@ -53,11 +55,7 @@ private fun ChampCard(champ: ChampDto) {
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
champ.status?.let {
|
||||
Text(
|
||||
text = it.replaceFirstChar { c -> c.uppercase() },
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
StatusPill(text = it, tone = champStatusTone(it))
|
||||
}
|
||||
}
|
||||
val detail = champDetail(champ)
|
||||
@@ -82,6 +80,13 @@ private fun ChampCard(champ: ChampDto) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a champ status to a pill tone: live spawns read green, cooldowns amber. */
|
||||
private fun champStatusTone(status: String): PillTone = when (status.lowercase()) {
|
||||
"active", "up", "spawned" -> PillTone.Success
|
||||
"cooldown", "restarting", "advancing" -> PillTone.Warning
|
||||
else -> PillTone.Neutral
|
||||
}
|
||||
|
||||
/** A short category-specific status line, mirroring the website's champ detail. */
|
||||
@Composable
|
||||
private fun champDetail(champ: ChampDto): String = when (champ.category) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||
@Composable
|
||||
@@ -54,10 +56,9 @@ private fun HouseCard(house: HouseDto) {
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
StatusPill(
|
||||
text = stringResource(R.string.houses_idoc_badge),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
tone = PillTone.Danger,
|
||||
)
|
||||
}
|
||||
val where = listOfNotNull(
|
||||
|
||||
@@ -27,6 +27,8 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||
|
||||
/**
|
||||
* A small "Live / Offline" indicator for the shard screens (PLAN.md §6.2): a dot +
|
||||
@@ -34,22 +36,19 @@ import com.runicgateway.app.ui.components.LoadingView
|
||||
*/
|
||||
@Composable
|
||||
fun LiveChip(connected: Boolean, modifier: Modifier = Modifier) {
|
||||
val color = if (connected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val dotColor = if (connected) ShardSuccessDot else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val textColor = if (connected) ShardSuccess else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
.background(dotColor),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(if (connected) R.string.shard_live else R.string.shard_offline),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** Board destinations reachable from the hub. */
|
||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
||||
@@ -114,29 +118,42 @@ private fun HubContent(
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (status.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val online = presenceCount ?: status.onlineCount
|
||||
Text(
|
||||
text = stringResource(R.string.shard_online_count, online),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
status.economy?.gold?.let { gold ->
|
||||
FeatureCard {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.shard_economy_gold, "%,.0f".format(gold)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
text = stringResource(
|
||||
if (status.isOnline) R.string.shard_status_online else R.string.shard_status_offline,
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val online = presenceCount ?: status.onlineCount
|
||||
Text(
|
||||
text = stringResource(R.string.shard_online_count, online),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
status.economy?.gold?.let { gold ->
|
||||
Text(
|
||||
text = stringResource(R.string.shard_economy_gold, "%,.0f".format(gold)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
if (status.isOnline) R.string.shard_live else R.string.shard_offline,
|
||||
),
|
||||
tone = if (status.isOnline) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,11 +185,7 @@ private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
SectionLabel(text = text, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -5,12 +5,44 @@ package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Placeholder palette for the M0 skeleton. The M5 design pass replaces this and
|
||||
// derives Material 3 colors from each shard's per-install branding (PLAN.md §3, §5).
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shard-website palette (M5 design pass, docs/android/PLAN.md §M5). Deep blue-black
|
||||
// surfaces, a slate-blue accent, parchment serif text, and a light CTA fill —
|
||||
// mirrored from the "Runic Gateway Screens" design. Dark-only by design.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
val Purple40 = Color(0xFF6650A4)
|
||||
val PurpleGrey40 = Color(0xFF625B71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
// Surfaces
|
||||
val ShardPage = Color(0xFF0B0F14) // app/page background
|
||||
val ShardSurface = Color(0xFF0E1318) // screen background
|
||||
val ShardElevated = Color(0xFF11161D) // top bar, inputs, drawer, list tracks
|
||||
val ShardCardTop = Color(0xFF192231) // feature-card gradient (top)
|
||||
val ShardCardBottom = Color(0xFF141A21) // feature-card gradient (bottom)
|
||||
|
||||
// Lines
|
||||
val ShardOutline = Color(0xFF2A3544) // borders / input outlines
|
||||
val ShardDivider = Color(0xFF1D2733) // hairline row dividers
|
||||
|
||||
// Text
|
||||
val ShardHeading = Color(0xFFEEF3F8) // brightest headings
|
||||
val ShardHeadingDim = Color(0xFFE6EDF6) // heading on surface
|
||||
val ShardBody = Color(0xFFC4CDD8) // body copy
|
||||
val ShardMuted = Color(0xFFAEB8C4) // secondary text
|
||||
val ShardFaint = Color(0xFF6F7D8E) // meta / faint labels
|
||||
|
||||
// Accent + CTA
|
||||
val ShardAccent = Color(0xFF7F99BD) // links, secondary highlights
|
||||
val ShardCta = Color(0xFFCDD9E8) // filled button surface
|
||||
val ShardOnCta = Color(0xFF0B0F14) // text on the filled button
|
||||
|
||||
// Neutral / info pill
|
||||
val ShardPillBg = Color(0xFF13243C)
|
||||
val ShardPillFg = Color(0xFFCDD9E8)
|
||||
|
||||
// Semantic (status pills + dots)
|
||||
val ShardSuccess = Color(0xFF7FD0A4)
|
||||
val ShardSuccessBg = Color(0x295FB98A) // rgba(95,185,138,0.16)
|
||||
val ShardSuccessDot = Color(0xFF5FB98A)
|
||||
val ShardWarning = Color(0xFFE6C26A)
|
||||
val ShardWarningBg = Color(0x2EE6C26A) // rgba(230,194,106,0.18)
|
||||
val ShardDanger = Color(0xFFD98B84)
|
||||
val ShardDangerBg = Color(0x29D98B84) // rgba(217,139,132,0.16)
|
||||
|
||||
40
app/src/main/java/com/runicgateway/app/ui/theme/Font.kt
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.runicgateway.app.R
|
||||
|
||||
/**
|
||||
* Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5).
|
||||
*
|
||||
* - [Cinzel] — the engraved serif display face used for headings, screen titles,
|
||||
* and the top-bar title. Shipped as a single weight-axis **variable** font
|
||||
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
|
||||
* the 500/600/700 instances the design uses are pinned via [FontVariation]
|
||||
* (supported on API 26+, and our minSdk is 29).
|
||||
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
|
||||
* which reads as the design's Georgia body copy without bundling another binary.
|
||||
* - [AppSans] — the label/meta/button face (the design's "Helvetica Neue" runs).
|
||||
*/
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
private fun cinzel(weight: FontWeight) =
|
||||
Font(
|
||||
R.font.cinzel_variable,
|
||||
weight = weight,
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
|
||||
)
|
||||
|
||||
val Cinzel = FontFamily(
|
||||
cinzel(FontWeight.Medium), // 500
|
||||
cinzel(FontWeight.SemiBold), // 600
|
||||
cinzel(FontWeight.Bold), // 700
|
||||
)
|
||||
|
||||
val AppSerif = FontFamily.Serif
|
||||
val AppSans = FontFamily.SansSerif
|
||||
@@ -3,54 +3,78 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80,
|
||||
/**
|
||||
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
|
||||
* design is a single deep blue-black theme, so there is no light variant and the
|
||||
* system light/dark setting is intentionally ignored. Material roles are mapped
|
||||
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
|
||||
* the theme without per-screen color work.
|
||||
*/
|
||||
private val ShardColorScheme = darkColorScheme(
|
||||
primary = ShardCta, // filled CTA buttons
|
||||
onPrimary = ShardOnCta,
|
||||
secondary = ShardAccent, // links / secondary highlights
|
||||
onSecondary = ShardOnCta,
|
||||
tertiary = ShardAccent,
|
||||
onTertiary = ShardOnCta,
|
||||
background = ShardPage,
|
||||
onBackground = ShardBody,
|
||||
surface = ShardSurface,
|
||||
onSurface = ShardBody,
|
||||
surfaceVariant = ShardElevated,
|
||||
onSurfaceVariant = ShardMuted,
|
||||
surfaceContainer = ShardElevated,
|
||||
surfaceContainerHigh = ShardElevated,
|
||||
surfaceContainerLow = ShardSurface,
|
||||
outline = ShardOutline,
|
||||
outlineVariant = ShardDivider,
|
||||
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
|
||||
onSecondaryContainer = ShardPillFg,
|
||||
error = ShardDanger,
|
||||
onError = ShardOnCta,
|
||||
errorContainer = ShardDangerBg,
|
||||
onErrorContainer = ShardDanger,
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40,
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
private val ShardShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* App theme for the functional pass. The color scheme is seeded from the
|
||||
* per-shard brand accent (PLAN.md §3: the app themes itself from the site's
|
||||
* branding) when one is available, so the app takes on each shard's color; it
|
||||
* falls back to a neutral placeholder scheme before connect or when a site
|
||||
* publishes no accent.
|
||||
*
|
||||
* This is deliberately a minimal seeding — a single-color override on the
|
||||
* default Material 3 schemes. The M5 design pass replaces it with a full,
|
||||
* designed color system (§2.1, §5); nothing here is meant to be the final look.
|
||||
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
|
||||
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
|
||||
* secondary roles so buttons and highlights carry that shard's color while the rest
|
||||
* of the deep blue-black system stays intact. With no accent, the slate default is
|
||||
* used.
|
||||
*/
|
||||
@Composable
|
||||
fun RunicGatewayTheme(
|
||||
accent: Color? = null,
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val base = if (darkTheme) DarkColorScheme else LightColorScheme
|
||||
val colorScheme = if (accent != null) {
|
||||
// Brand consistency across a shard wins over device dynamic color: seed
|
||||
// the primary role from the accent so buttons/highlights carry the brand.
|
||||
base.copy(primary = accent, secondary = accent)
|
||||
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
|
||||
} else {
|
||||
base
|
||||
ShardColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = ShardShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,17 +5,77 @@ package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Default Material 3 type scale for the skeleton; refined in the M5 design pass.
|
||||
/**
|
||||
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
|
||||
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
|
||||
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
|
||||
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
|
||||
*/
|
||||
val Typography = Typography(
|
||||
// Display / headline / title — Cinzel engraved serif
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
||||
),
|
||||
// Body — parchment serif
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
// Labels / meta / buttons — sans, letter-spaced
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = AppSans, fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
|
||||
19
app/src/main/res/drawable-anydpi/ic_stat_name.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="172"
|
||||
android:viewportHeight="218.95209"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="0.7227152"
|
||||
android:scaleY="0.92"
|
||||
android:translateX="23.846495"
|
||||
android:translateY="8.758083">
|
||||
<group android:translateY="154.51205">
|
||||
<path android:pathData="M9.28125,-0L9.28125,-103.109375L42.265625,-103.109375Q53.140625,-103.109375,60.984375,-99.25Q68.828125,-95.40625,73.109375,-88.203125Q77.40625,-81,77.40625,-71.203125L77.40625,-70.0625Q77.40625,-60.1875,73.109375,-53.015625Q68.828125,-45.859375,60.9375,-41.96875Q53.0625,-38.09375,42.265625,-38.09375L20.296875,-38.09375L20.296875,-54.5L41.828125,-54.5Q49.46875,-54.5,53.75,-58.390625Q58.03125,-62.28125,58.03125,-69.125L58.03125,-70.984375Q58.03125,-77.828125,53.75,-81.71875Q49.46875,-85.609375,41.828125,-85.609375L28.4375,-85.609375L28.4375,-0L9.28125,-0ZM61.78125,-0L35.5625,-45.359375L56.65625,-45.359375L83.453125,-0L61.78125,-0Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path android:pathData="M132.65625,2.375Q119.765625,2.375,110.40625,-3.921875Q101.046875,-10.21875,96.046875,-21.953125Q91.046875,-33.703125,91.046875,-49.96875L91.046875,-52.625Q91.046875,-69.046875,96.046875,-80.890625Q101.046875,-92.734375,110.296875,-99.109375Q119.546875,-105.484375,132.375,-105.484375Q145.25,-105.484375,153.96875,-99.109375Q162.6875,-92.734375,166.5625,-80.5L149.21875,-74.09375Q147.125,-81,143.04688,-84.234375Q138.98438,-87.484375,132.57812,-87.484375Q121.859375,-87.484375,116.125,-78.546875Q110.40625,-69.625,110.40625,-52.984375L110.40625,-49.609375Q110.40625,-32.96875,116.15625,-24.21875Q121.921875,-15.484375,132.79688,-15.484375Q141.57812,-15.484375,146.35938,-21.453125Q151.15625,-27.4375,151.15625,-38.453125L151.15625,-41.828125L129.84375,-41.828125L129.84375,-57.890625L169.09375,-57.890625L169.09375,-41.90625Q169.09375,-20.734375,159.57812,-9.171875Q150.07812,2.375,132.65625,2.375Z"
|
||||
android:fillColor="#000000"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 493 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 352 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 647 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_name.png
Normal file
|
After Width: | Height: | Size: 975 B |
17
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
Adaptive-icon background layer (M6): a solid deep-indigo fill behind the
|
||||
transparent gateway-medallion foreground. Replaces the Image Asset wizard's
|
||||
default green grid. Sourced from @color/ic_launcher_background so the launcher
|
||||
background stays a single source of truth.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="@color/ic_launcher_background"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!-- Placeholder launcher glyph: a stylized gateway arch. Replaced in the M5 design pass. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#D0BCFF"
|
||||
android:pathData="M38,74 L38,50 A16,16 0 0,1 70,50 L70,74 L62,74 L62,50 A8,8 0 0,0 46,50 L46,74 Z" />
|
||||
<path
|
||||
android:fillColor="#EFB8C8"
|
||||
android:pathData="M52,34 L56,34 L56,40 L52,40 Z M50,40 L58,40 L58,44 L50,44 Z" />
|
||||
</vector>
|
||||
BIN
app/src/main/res/font/cinzel_variable.ttf
Normal file
@@ -1,7 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
Adaptive launcher icon (API 26+, which is every target device at minSdk 29):
|
||||
the gateway-medallion foreground (transparent PNG, all densities) over the
|
||||
deep-indigo background. No <monochrome> layer: the medallion is a full-colour
|
||||
mark that does not reduce to a clean single-tone silhouette, so themed-icon
|
||||
mode falls back to this standard icon rather than a tinted blob.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!-- Round adaptive launcher icon — same layers as ic_launcher.xml. -->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -2,4 +2,7 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1B1033</color>
|
||||
<!-- M5 design pass: the shard-website page color, used as the launch window
|
||||
background so the first frame matches the dark Compose theme (no white flash). -->
|
||||
<color name="launch_background">#0B0F14</color>
|
||||
</resources>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="connect_error_scheme">Only http and https addresses are supported.</string>
|
||||
<string name="connect_error_insecure">A secure https address is required.</string>
|
||||
<string name="connect_error_not_runic">That site isn\'t a Runic Gateway shard.</string>
|
||||
<string name="connect_error_version">This app is out of date for that site (it speaks API %1$s). Update the app and try again.</string>
|
||||
<string name="connect_error_unreachable">Couldn\'t reach that site. Check the address and your connection.</string>
|
||||
<string name="connect_error_server">The site responded with an error (%1$d). Try again shortly.</string>
|
||||
|
||||
@@ -41,8 +42,148 @@
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_about">About</string>
|
||||
<string name="menu_contact">Contact</string>
|
||||
<string name="menu_account">My account</string>
|
||||
<string name="menu_my_characters">My characters</string>
|
||||
<string name="menu_my_vendors">My vendors</string>
|
||||
<string name="menu_my_houses">My houses</string>
|
||||
<string name="menu_sign_in">Sign in</string>
|
||||
<string name="menu_sign_out">Sign out</string>
|
||||
<string name="menu_change_server">Change server</string>
|
||||
|
||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||
<string name="login_title">Sign in</string>
|
||||
<string name="login_subtitle">Sign in with your shard account.</string>
|
||||
<string name="login_username">Username</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_totp_code">Authentication code</string>
|
||||
<string name="login_totp_hint">Enter the 6-digit code from your authenticator app.</string>
|
||||
<string name="login_button">Sign in</string>
|
||||
<string name="login_register">Create an account</string>
|
||||
<string name="login_forgot">Forgot your password?</string>
|
||||
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
|
||||
<string name="login_error_credentials">Incorrect username or password.</string>
|
||||
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
||||
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
||||
<string name="login_error_server">Something went wrong. Please try again.</string>
|
||||
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
|
||||
|
||||
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
|
||||
<string name="account_title">My account</string>
|
||||
<string name="account_sign_out">Sign out</string>
|
||||
<string name="account_sign_out_all">Sign out on all devices</string>
|
||||
|
||||
<!-- Username -->
|
||||
<string name="account_username_title">Username</string>
|
||||
<string name="account_username_action">Change username</string>
|
||||
<string name="account_username_changed">Username updated.</string>
|
||||
<string name="account_username_taken">That username is already taken.</string>
|
||||
<string name="account_username_error">Couldn\'t change your username.</string>
|
||||
|
||||
<!-- Password -->
|
||||
<string name="account_password_title">Password</string>
|
||||
<string name="account_password_set_title">Set a password</string>
|
||||
<string name="account_password_set_hint">Your account was created through a linked provider and has no password yet. Set one to also sign in with a username and password.</string>
|
||||
<string name="account_password_current">Current password</string>
|
||||
<string name="account_password_new">New password</string>
|
||||
<string name="account_password_action">Change password</string>
|
||||
<string name="account_password_set_action">Set password</string>
|
||||
<string name="account_password_changed">Password changed.</string>
|
||||
<string name="account_password_error">Couldn\'t change your password. Check your current password and try again.</string>
|
||||
|
||||
<!-- Two-factor -->
|
||||
<string name="account_totp_title">Two-factor authentication</string>
|
||||
<string name="account_totp_on">Enabled</string>
|
||||
<string name="account_totp_off">Not enabled</string>
|
||||
<string name="account_totp_scan">Scan this code with your authenticator app, then enter the current code.</string>
|
||||
<string name="account_totp_qr_desc">Two-factor QR code</string>
|
||||
<string name="account_totp_setup">Set up two-factor</string>
|
||||
<string name="account_totp_confirm">Confirm & enable</string>
|
||||
<string name="account_totp_cancel">Cancel</string>
|
||||
<string name="account_totp_disable">Disable two-factor</string>
|
||||
<string name="account_totp_enabled">Two-factor is now enabled.</string>
|
||||
<string name="account_totp_disabled">Two-factor has been disabled.</string>
|
||||
<string name="account_totp_code_error">That code isn\'t valid. Try the current code.</string>
|
||||
<string name="account_totp_setup_error">Couldn\'t start two-factor setup.</string>
|
||||
|
||||
<!-- Linked identities -->
|
||||
<string name="account_identities_title">Linked accounts</string>
|
||||
<string name="account_identities_empty">No linked sign-in providers.</string>
|
||||
<string name="account_identity_unlink">Unlink</string>
|
||||
<string name="account_identity_unlinked">Account unlinked.</string>
|
||||
<string name="account_identity_error">Couldn\'t unlink that account.</string>
|
||||
|
||||
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
|
||||
<string name="player_link_title">Link your game account</string>
|
||||
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>
|
||||
<string name="player_link_code">Link code</string>
|
||||
<string name="player_link_action">Link account</string>
|
||||
<string name="player_link_ok">Account linked.</string>
|
||||
<string name="player_link_bad_code">That code is unknown or has expired. Run [link in game for a new one.</string>
|
||||
<string name="player_link_error">Couldn\'t link that code. Please try again.</string>
|
||||
|
||||
<string name="player_create_title">Create a game account</string>
|
||||
<string name="player_create_account">Account name</string>
|
||||
<string name="player_create_password">Password</string>
|
||||
<string name="player_create_action">Create account</string>
|
||||
<string name="player_create_ok">Game account created and linked.</string>
|
||||
<string name="player_create_taken">That account name is already taken.</string>
|
||||
<string name="player_create_capped">The account limit for your network has been reached.</string>
|
||||
<string name="player_create_unavailable">Game-account signup isn\'t available right now.</string>
|
||||
<string name="player_create_rejected">That account name or password wasn\'t accepted.</string>
|
||||
<string name="player_create_error">Couldn\'t create that account. Please try again.</string>
|
||||
|
||||
<!-- ── Player: characters (§6.3) ───────────────────────────────────── -->
|
||||
<string name="player_characters_empty">Link a game account above to see your characters.</string>
|
||||
<string name="player_roster_loading">Loading…</string>
|
||||
<string name="player_roster_empty">No characters on this account.</string>
|
||||
<string name="player_char_unknown">Unknown</string>
|
||||
<string name="player_char_online">Online</string>
|
||||
<string name="player_char_offline">Offline</string>
|
||||
|
||||
<!-- Character sheet -->
|
||||
<string name="player_char_governor">Governor of %1$s</string>
|
||||
<string name="player_char_guildmaster">Guildmaster, %1$s</string>
|
||||
<string name="player_char_attributes">Attributes</string>
|
||||
<string name="player_char_vitals">Vitals</string>
|
||||
<string name="player_char_str">STR</string>
|
||||
<string name="player_char_dex">DEX</string>
|
||||
<string name="player_char_int">INT</string>
|
||||
<string name="player_char_hits">Hits</string>
|
||||
<string name="player_char_mana">Mana</string>
|
||||
<string name="player_char_stam">Stamina</string>
|
||||
<string name="player_char_resistances">Resistances</string>
|
||||
<string name="player_char_phys">Physical</string>
|
||||
<string name="player_char_fire">Fire</string>
|
||||
<string name="player_char_cold">Cold</string>
|
||||
<string name="player_char_pois">Poison</string>
|
||||
<string name="player_char_energy">Energy</string>
|
||||
<string name="player_char_skills">Skills</string>
|
||||
<string name="player_char_equipment">Equipment</string>
|
||||
<string name="player_char_item">Item</string>
|
||||
<string name="player_char_item_id">id %1$d</string>
|
||||
<string name="player_char_item_hue">hue %1$d</string>
|
||||
|
||||
<!-- ── Player: vendors & sales (§6.3) ──────────────────────────── -->
|
||||
<string name="player_sales_title">Recent sales</string>
|
||||
<string name="player_sales_empty">No recent sales.</string>
|
||||
<string name="player_gold_amount">%1$s gp</string>
|
||||
<string name="player_vendors_no_accounts">Link a game account to see your vendors.</string>
|
||||
<string name="player_vendors_empty">No vendors on this account.</string>
|
||||
<string name="player_vendor_fallback">Player vendor</string>
|
||||
<string name="player_vendor_hold">Holding %1$s gp</string>
|
||||
<string name="player_vendor_no_listings">No items listed.</string>
|
||||
<string name="player_listing_item">Item %1$d ×%2$d</string>
|
||||
|
||||
<!-- ── Player: houses (§6.3) ───────────────────────────────────────── -->
|
||||
<string name="player_houses_empty">You don\'t have any houses on your linked accounts.</string>
|
||||
<string name="player_house_fallback">A house</string>
|
||||
<string name="player_house_stage">Status: %1$s</string>
|
||||
<string name="role_player">Player</string>
|
||||
<string name="role_moderator">Moderator</string>
|
||||
<string name="role_editor">Editor</string>
|
||||
<string name="role_admin">Administrator</string>
|
||||
<string name="role_unknown">Signed in</string>
|
||||
|
||||
<!-- ── Home / status (§6.1) ────────────────────────────────────────── -->
|
||||
<string name="home_status_live">Online</string>
|
||||
<string name="home_status_maintenance">Under maintenance</string>
|
||||
|
||||
@@ -6,5 +6,11 @@
|
||||
background / status-bar behavior for launch.
|
||||
-->
|
||||
<resources>
|
||||
<style name="Theme.RunicGateway" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
<!-- Dark launch theme (the app is dark-only, M5). Sets the window background to the
|
||||
page color so the pre-Compose frame matches; Compose owns the live UI theme. -->
|
||||
<style name="Theme.RunicGateway" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/launch_background</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Session lifecycle over a fake [TokenStore] (PLAN.md §4.3): restore on launch,
|
||||
* establish/rotate/tear-down, and the invariant that the in-memory token pair and
|
||||
* the observable [Session] state always agree.
|
||||
*/
|
||||
class SessionManagerTest {
|
||||
|
||||
/** In-memory stand-in for EncryptedSharedPreferences. */
|
||||
private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
|
||||
override fun load(): StoredSession? = stored
|
||||
override fun save(session: StoredSession) { stored = session }
|
||||
override fun clear() { stored = null }
|
||||
}
|
||||
|
||||
private fun user(id: Long = 1, name: String = "alice", role: String = "player") =
|
||||
SafeUserDto(id = id, username = name, role = role)
|
||||
|
||||
@Test fun startsSignedOutWithNoStoredSession() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
assertEquals(Session.SignedOut, mgr.state.value)
|
||||
assertNull(mgr.currentAccessToken())
|
||||
assertNull(mgr.currentRefreshToken())
|
||||
}
|
||||
|
||||
@Test fun restoresSignedInFromStore() {
|
||||
val store = FakeTokenStore(
|
||||
StoredSession("access-1", "refresh-1", 5, "bob", "admin"),
|
||||
)
|
||||
val mgr = SessionManager(store)
|
||||
val state = mgr.state.value
|
||||
assertTrue(state is Session.SignedIn)
|
||||
assertEquals("bob", (state as Session.SignedIn).user.username)
|
||||
assertEquals(Role.ADMIN, state.user.role)
|
||||
assertEquals("access-1", mgr.currentAccessToken())
|
||||
assertEquals("refresh-1", mgr.currentRefreshToken())
|
||||
}
|
||||
|
||||
@Test fun signInPersistsAndExposesTokens() {
|
||||
val store = FakeTokenStore()
|
||||
val mgr = SessionManager(store)
|
||||
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
|
||||
|
||||
assertTrue(mgr.state.value is Session.SignedIn)
|
||||
assertEquals("access-A", mgr.currentAccessToken())
|
||||
assertEquals("refresh-A", mgr.currentRefreshToken())
|
||||
// Persisted so a relaunch restores it.
|
||||
assertEquals("refresh-A", store.stored?.refreshToken)
|
||||
}
|
||||
|
||||
@Test fun refreshRotatesTokensWhileSignedIn() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
mgr.onSignedIn("access-A", "refresh-A", user())
|
||||
mgr.onRefreshed("access-B", "refresh-B", user())
|
||||
|
||||
assertEquals("access-B", mgr.currentAccessToken())
|
||||
assertEquals("refresh-B", mgr.currentRefreshToken())
|
||||
}
|
||||
|
||||
@Test fun refreshIsNoOpAfterSignOut() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
mgr.onSignedIn("access-A", "refresh-A", user())
|
||||
mgr.onSignedOut()
|
||||
// A refresh that races a logout must not resurrect the session.
|
||||
mgr.onRefreshed("access-B", "refresh-B", user())
|
||||
|
||||
assertEquals(Session.SignedOut, mgr.state.value)
|
||||
assertNull(mgr.currentAccessToken())
|
||||
}
|
||||
|
||||
@Test fun userRefreshUpdatesRoleKeepingTokens() {
|
||||
val mgr = SessionManager(FakeTokenStore())
|
||||
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
|
||||
// A /auth/me re-validation reports a promotion.
|
||||
mgr.onUserRefreshed(user(role = "editor"))
|
||||
|
||||
val state = mgr.state.value as Session.SignedIn
|
||||
assertEquals(Role.EDITOR, state.user.role)
|
||||
assertEquals("access-A", mgr.currentAccessToken())
|
||||
}
|
||||
|
||||
@Test fun signOutClearsStoreAndState() {
|
||||
val store = FakeTokenStore()
|
||||
val mgr = SessionManager(store)
|
||||
mgr.onSignedIn("access-A", "refresh-A", user())
|
||||
mgr.onSignedOut()
|
||||
|
||||
assertEquals(Session.SignedOut, mgr.state.value)
|
||||
assertNull(store.stored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding tests for the self-service ("me") account DTOs (PLAN.md §6.3, §6.4).
|
||||
* Shapes come from the website's `account.controller` handlers surfaced under
|
||||
* `/auth/me/account*`; unknown keys are ignored (additive fields, §8).
|
||||
*/
|
||||
class AccountDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun accountDecodesSecurityFlags() {
|
||||
val dto = json.decodeFromString<PlayerAccountDto>(
|
||||
"""{"id":42,"username":"newplayer","role":"player","email":"p@example.com",
|
||||
"status":"active","totp_enabled":false,"has_password":true}""",
|
||||
)
|
||||
assertEquals(42L, dto.id)
|
||||
assertEquals("newplayer", dto.username)
|
||||
assertTrue(dto.has_password)
|
||||
assertFalse(dto.totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun ssoAccountHasNoPassword() {
|
||||
// An SSO-provisioned account omits email and reports has_password=false.
|
||||
val dto = json.decodeFromString<PlayerAccountDto>(
|
||||
"""{"id":7,"username":"ssouser","role":"player","email":null,
|
||||
"status":"active","totp_enabled":true,"has_password":false}""",
|
||||
)
|
||||
assertFalse(dto.has_password)
|
||||
assertTrue(dto.totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun totpSetupDecodesQr() {
|
||||
val dto = json.decodeFromString<TotpSetupDto>(
|
||||
"""{"otpauthUrl":"otpauth://totp/Runic:admin?secret=ABC","qr":"data:image/png;base64,iVBORw0KGgo="}""",
|
||||
)
|
||||
assertTrue(dto.qr!!.startsWith("data:image/png;base64,"))
|
||||
}
|
||||
|
||||
@Test fun totpStateDecodes() {
|
||||
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun linkedIdentityDecodes() {
|
||||
val dto = json.decodeFromString<LinkedIdentityDto>(
|
||||
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
|
||||
)
|
||||
assertEquals("discord", dto.provider)
|
||||
assertEquals("u@example.com", dto.email)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding tests for the mobile auth wire shapes (PLAN.md §4.1). Field names come
|
||||
* from the website's `auth/mobile` controller + `/auth/me`; the parser ignores
|
||||
* unknown keys (additive backend fields, §8).
|
||||
*/
|
||||
class AuthDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesWithSafeUser() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"aaa.bbb.ccc","refreshToken":"r-123","expiresIn":"15m",
|
||||
"user":{"id":7,"username":"alice","role":"player"}}""",
|
||||
)
|
||||
assertEquals("aaa.bbb.ccc", dto.accessToken)
|
||||
assertEquals("r-123", dto.refreshToken)
|
||||
assertEquals("15m", dto.expiresIn)
|
||||
assertEquals(7L, dto.user.id)
|
||||
assertEquals("alice", dto.user.username)
|
||||
assertEquals("player", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun meResponseIgnoresExtraUserFields() {
|
||||
// /auth/me returns the full safe user row — the app only needs id/username/role.
|
||||
val dto = json.decodeFromString<MeResponse>(
|
||||
"""{"user":{"id":1,"username":"staff","role":"editor","status":"active",
|
||||
"createdAt":"2026-01-01T00:00:00Z","totp_enabled":true}}""",
|
||||
)
|
||||
assertEquals(1L, dto.user.id)
|
||||
assertEquals("editor", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun totpRequiredErrorDecodes() {
|
||||
val dto = json.decodeFromString<TotpRequiredError>(
|
||||
"""{"totpRequired":true,"message":"A verification code is required."}""",
|
||||
)
|
||||
assertTrue(dto.totpRequired)
|
||||
}
|
||||
|
||||
@Test fun totpRequiredDefaultsFalseForPlainCredentialFailure() {
|
||||
// A bad-password 401 has no totpRequired flag — must not read as a 2FA prompt.
|
||||
val dto = json.decodeFromString<TotpRequiredError>(
|
||||
"""{"message":"Incorrect username or password."}""",
|
||||
)
|
||||
assertFalse(dto.totpRequired)
|
||||
}
|
||||
|
||||
@Test fun tokenResponseExpiresInOptional() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":2,"username":"bob","role":"admin"}}""",
|
||||
)
|
||||
assertNull(dto.expiresIn)
|
||||
assertEquals("admin", dto.user.role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding tests for a player's own game-data DTOs (PLAN.md §6.3). Shapes come from
|
||||
* the website's `player/shard.controller.js` + `docs/link/INTEGRATION.md` §5. The
|
||||
* roster / char / vendor reads are permissive objects, so the parser must ignore
|
||||
* unknown keys (additive fields, §8) and tolerate hex-string serials.
|
||||
*/
|
||||
class PlayerShardDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun rosterDecodesCharsIncludingOffline() {
|
||||
val dto = json.decodeFromString<RosterDto>(
|
||||
"""{"kind":"account.roster","acct":"whitlocktech",
|
||||
"chars":[{"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false}]}""",
|
||||
)
|
||||
assertEquals("whitlocktech", dto.acct)
|
||||
assertEquals(1, dto.chars.size)
|
||||
assertEquals("0x24C", dto.chars[0].serial)
|
||||
assertEquals("Darrow", dto.chars[0].name)
|
||||
}
|
||||
|
||||
@Test fun charProfileDecodesStatsSkillsEquipmentAndTitles() {
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{"kind":"char.profile","serial":"0x24C","name":"Darrow","online":true,"acct":"wt",
|
||||
"stats":{"str":100,"dex":90,"int":75,"hits":100,"hitsMax":110,
|
||||
"resist":{"phys":70,"fire":68,"cold":60,"pois":55,"energy":50}},
|
||||
"skills":[{"n":"Swordsmanship","base":100.0,"value":120.0,"cap":120.0},
|
||||
{"n":"Anatomy","base":0.0,"value":0.0,"cap":100.0}],
|
||||
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"hue":1153,
|
||||
"mods":{"DamageIncrease":50,"HitLightning":30}}],
|
||||
"titles":{"selected":0,"reward":["Knight"],"fameKarma":"The Great"},
|
||||
"guild":{"name":"Knights","abbr":"KNT"},"governorOf":["Britain"]}""",
|
||||
)
|
||||
assertEquals("Darrow", dto.name)
|
||||
assertTrue(dto.online)
|
||||
assertEquals(100, dto.stats?.str)
|
||||
assertEquals(70, dto.stats?.resist?.phys)
|
||||
assertEquals(2, dto.skills.size)
|
||||
assertEquals(120.0, dto.skills[0].value!!, 0.0)
|
||||
val mods = dto.equipment[0].mods
|
||||
assertNotNull(mods)
|
||||
assertEquals("50", mods!!["DamageIncrease"]!!.jsonPrimitive.content)
|
||||
assertEquals("KNT", dto.guild?.abbr)
|
||||
assertEquals(listOf("Britain"), dto.governorOf)
|
||||
}
|
||||
|
||||
@Test fun vendorSnapshotDecodesListings() {
|
||||
val dto = json.decodeFromString<VendorSnapshotDto>(
|
||||
"""{"kind":"vendor.snapshot","acct":"seed_000",
|
||||
"vendors":[{"serial":"0x2C0","shopName":"Seed Shop","holdGold":24186,
|
||||
"ownerSerial":"0x1F5","map":"Felucca","x":1402,"y":1604,
|
||||
"listings":[{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}]}]}""",
|
||||
)
|
||||
assertEquals(1, dto.vendors.size)
|
||||
assertEquals(24186L, dto.vendors[0].holdGold)
|
||||
assertEquals(69819L, dto.vendors[0].listings[0].price)
|
||||
assertTrue(dto.vendors[0].listings[0].forSale)
|
||||
}
|
||||
|
||||
@Test fun vendorSaleDecodes() {
|
||||
val dto = json.decodeFromString<VendorSaleDto>(
|
||||
"""{"t":1783720195626,"itemType":"Longsword","amount":1,"price":100,
|
||||
"commission":5,"ownerAcct":"whitlocktech"}""",
|
||||
)
|
||||
assertEquals(1783720195626L, dto.t)
|
||||
assertEquals("Longsword", dto.itemType)
|
||||
assertEquals(100L, dto.price)
|
||||
}
|
||||
|
||||
@Test fun playerHouseDecodesHexSerialAndIdoc() {
|
||||
val dto = json.decodeFromString<PlayerHouseDto>(
|
||||
"""{"serial":"0x4004705F","stage":"IDOC","map":"Trammel","x":1,"y":2,"z":3,
|
||||
"region":"Britain","name":"An Unnamed House","isIdoc":true,
|
||||
"ownerAcct":"wt","updatedAt":"2026-07-19T22:00:00Z"}""",
|
||||
)
|
||||
assertEquals("0x4004705F", dto.serial)
|
||||
assertEquals("IDOC", dto.stage)
|
||||
assertTrue(dto.isIdoc)
|
||||
}
|
||||
|
||||
@Test fun linkResultDecodes() {
|
||||
val dto = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"whitlocktech"}""")
|
||||
assertTrue(dto.linked)
|
||||
assertEquals("whitlocktech", dto.account)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.dto.VersionDto
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository.Companion.evaluateVersion
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository.VersionVerdict
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The connect-probe version guard (§3): a Runic Gateway backend on the app's
|
||||
* supported API is accepted; a wrong service identity or an unsupported API
|
||||
* version is refused clearly rather than mis-rendered.
|
||||
*/
|
||||
class ConnectionVersionGuardTest {
|
||||
|
||||
@Test
|
||||
fun `matching service and api is Ok`() {
|
||||
val v = VersionDto(service = "runic-gateway", api = "v1", server = "1.2.3")
|
||||
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `service id is case-insensitive and trimmed`() {
|
||||
val v = VersionDto(service = " Runic-Gateway ", api = "V1", server = "x")
|
||||
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong service is NotRunicGateway`() {
|
||||
val v = VersionDto(service = "some-other-app", api = "v1", server = "x")
|
||||
assertEquals(VersionVerdict.NotRunicGateway, evaluateVersion(v))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank api is lenient (older backend) and accepted`() {
|
||||
val v = VersionDto(service = "runic-gateway", api = "", server = "x")
|
||||
assertEquals(VersionVerdict.Ok, evaluateVersion(v))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `future api version is a mismatch carrying the server value`() {
|
||||
val v = VersionDto(service = "runic-gateway", api = "v2", server = "x")
|
||||
val verdict = evaluateVersion(v)
|
||||
assertTrue(verdict is VersionVerdict.Mismatch)
|
||||
assertEquals("v2", (verdict as VersionVerdict.Mismatch).serverApi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The declarative access-level menu filter (PLAN.md §5): one list gated by the
|
||||
* current session, not scattered `if role ==` checks.
|
||||
*/
|
||||
class MenuAccessTest {
|
||||
|
||||
private fun signedIn(role: Role) =
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||
|
||||
private fun routes(session: Session) =
|
||||
visibleEntries(APP_MENU, session).map { it.route }
|
||||
|
||||
@Test fun anonymousSeesOnlyPublicEntries() {
|
||||
val visible = routes(Session.SignedOut)
|
||||
assertTrue(visible.contains(Routes.HOME))
|
||||
assertTrue(visible.contains(Routes.SHARD))
|
||||
// Signed-in surfaces are hidden.
|
||||
assertFalse(visible.contains(Routes.ACCOUNT))
|
||||
}
|
||||
|
||||
@Test fun signedInPlayerSeesAccount() {
|
||||
val visible = routes(signedIn(Role.PLAYER))
|
||||
assertTrue(visible.contains(Routes.ACCOUNT))
|
||||
assertTrue(visible.contains(Routes.HOME))
|
||||
}
|
||||
|
||||
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
|
||||
val visible = routes(signedIn(Role.EDITOR))
|
||||
assertTrue(visible.contains(Routes.ACCOUNT))
|
||||
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
|
||||
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
|
||||
assertTrue(playerOnly.none { visible.contains(it) })
|
||||
}
|
||||
|
||||
@Test fun publicEntryCountIsStableAcrossSessions() {
|
||||
val publicCount = APP_MENU.count { it.access == MenuAccess.PUBLIC }
|
||||
assertEquals(publicCount, routes(Session.SignedOut).size)
|
||||
}
|
||||
|
||||
@Test fun roleFromWireMapsKnownAndUnknown() {
|
||||
assertEquals(Role.ADMIN, Role.fromWire("admin"))
|
||||
assertEquals(Role.PLAYER, Role.fromWire("player"))
|
||||
assertEquals(Role.UNKNOWN, Role.fromWire("superuser"))
|
||||
assertEquals(Role.UNKNOWN, Role.fromWire(null))
|
||||
}
|
||||
|
||||
@Test fun playerAccessGatedFunction() {
|
||||
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
|
||||
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
||||
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
|
||||
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal*
|
||||
* selected reward title, dropping bare cliloc numbers the app can't resolve.
|
||||
*/
|
||||
class CharacterSheetHelpersTest {
|
||||
|
||||
@Test fun formatSkillDropsTrailingZero() {
|
||||
assertEquals("100", formatSkill(100.0))
|
||||
assertEquals("85.3", formatSkill(85.3))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesEmptyForNull() {
|
||||
assertEquals(emptyList<String>(), displayTitles(null))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesUsesFameKarmaSkillAndSelectedLiteralReward() {
|
||||
val titles = TitlesDto(
|
||||
selected = 1,
|
||||
reward = listOf("1049565", "Slayer of Dragons"),
|
||||
fameKarma = "The Great",
|
||||
skill = "Grandmaster Swordsman",
|
||||
)
|
||||
assertEquals(
|
||||
listOf("The Great", "Grandmaster Swordsman", "Slayer of Dragons"),
|
||||
displayTitles(titles),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun displayTitlesSkipsNumericSelectedReward() {
|
||||
// Selected points at a bare cliloc number → skipped (no cliloc table).
|
||||
val titles = TitlesDto(selected = 0, reward = listOf("1049565"))
|
||||
assertEquals(emptyList<String>(), displayTitles(titles))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesFallsBackToFirstLiteralWhenNoneSelected() {
|
||||
val titles = TitlesDto(selected = -1, reward = listOf("1049565", "Champion"))
|
||||
assertEquals(listOf("Champion"), displayTitles(titles))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesDeduplicates() {
|
||||
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
|
||||
assertEquals(listOf("The Great"), displayTitles(titles))
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ retrofitSerializationConverter = "1.0.0"
|
||||
datastore = "1.1.1"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
|
||||
# Web hand-off (Chrome Custom Tabs) — §4.2
|
||||
browser = "1.8.0"
|
||||
|
||||
# Images
|
||||
coil = "2.7.0"
|
||||
|
||||
@@ -79,6 +82,9 @@ retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit",
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
|
||||
# Web hand-off
|
||||
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
|
||||
# Images
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
|
||||