Compare commits
1 Commits
ci/sonarqu
...
feat/m6-re
| Author | SHA1 | Date | |
|---|---|---|---|
| 68da9da805 |
@@ -1,23 +1,37 @@
|
|||||||
# Automated release for the Runic Gateway Android app.
|
# Automated build + release for the Runic Gateway Android app.
|
||||||
#
|
#
|
||||||
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
|
# Trigger: every push to `main` (i.e. every merged PR).
|
||||||
# build never has to push to protected `main`; the tag *is* the release input.
|
|
||||||
#
|
#
|
||||||
# To cut a release:
|
# Flow (two conceptual halves, kept separate on purpose) — mirrors link/'s engine:
|
||||||
# git tag v0.1.0 && git push origin v0.1.0
|
|
||||||
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
|
|
||||||
# the workflow_dispatch input below.
|
|
||||||
#
|
#
|
||||||
# versionName = the tag without its leading `v`; versionCode = major*10000 +
|
# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐
|
||||||
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
|
# │ reads: latest v* git tag + conventional-commit subjects │
|
||||||
# into app/build.gradle.kts for the build only — nothing is committed back to main.
|
# │ produces: next version, changelog, and (at the end) the release │
|
||||||
|
# └───────────────────────────────────────────────────────────────────┘
|
||||||
|
# ┌── ANDROID ADAPTER (the only Android-specific part) ───────────────┐
|
||||||
|
# │ consumes: the version │
|
||||||
|
# │ produces: the artifact (a signed release APK + SHA256SUMS) │
|
||||||
|
# └───────────────────────────────────────────────────────────────────┘
|
||||||
|
#
|
||||||
|
# Version bump (conventional commits since the last v* tag):
|
||||||
|
# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch
|
||||||
|
# nothing releasable -> no release is cut
|
||||||
|
# (first ever run, no tag) -> releases the current build.gradle.kts version as-is
|
||||||
|
# versionName is the semver; versionCode is derived major*10000+minor*100+patch so
|
||||||
|
# it is deterministic + monotonic (PLAN.md §10). The bump is committed back to
|
||||||
|
# app/build.gradle.kts, then tagged.
|
||||||
#
|
#
|
||||||
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
||||||
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release)
|
# REGISTRY_USER — Gitea username the token below belongs to
|
||||||
|
# REGISTRY_TOKEN — Gitea access token with `write:repository` (push the bump
|
||||||
|
# commit + tag and create the release)
|
||||||
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
||||||
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
# ANDROID_KEYSTORE_PASSWORD — keystore password
|
||||||
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
# ANDROID_KEY_ALIAS — key alias (e.g. runicgateway)
|
||||||
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
# ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore)
|
||||||
|
# Also: `main` must accept a direct push from the REGISTRY_USER account (disable
|
||||||
|
# branch protection for it, or add it as an exception) — the bump commit lands on
|
||||||
|
# main. The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
|
||||||
#
|
#
|
||||||
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
|
# Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container
|
||||||
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
|
# lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base
|
||||||
@@ -28,16 +42,11 @@ name: Release APK
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches: [main]
|
||||||
- 'v*'
|
workflow_dispatch: {}
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Existing v* tag to (re)build and release'
|
|
||||||
required: true
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
|
group: release-apk
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -48,10 +57,10 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on
|
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
|
||||||
# the self-hosted runner) instead of hanging forever and — because concurrency
|
# Quoted because the expression contains a colon (`chore(release):`), which an
|
||||||
# is `cancel-in-progress: false` — blocking every later release behind it.
|
# unquoted YAML scalar would misparse as a mapping value.
|
||||||
timeout-minutes: 30
|
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Install base tools + JDK 17
|
- name: Install base tools + JDK 17
|
||||||
run: |
|
run: |
|
||||||
@@ -59,46 +68,70 @@ jobs:
|
|||||||
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
|
apt-get install -y git curl unzip jq openjdk-17-jdk-headless
|
||||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Check out the release tag (full history for the changelog)
|
- name: Check out full history (need tags + commit log for the bump)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.tag || github.ref_name }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# ── Derive version + changelog straight from the tag ─────────────────
|
# ── RELEASE ENGINE: decide the next version + changelog ──────────────
|
||||||
- name: Plan the release (version + changelog from the tag)
|
- name: Plan the release (version + changelog)
|
||||||
id: plan
|
id: plan
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
git fetch --tags --force >/dev/null 2>&1 || true
|
git fetch --tags --force >/dev/null 2>&1 || true
|
||||||
|
|
||||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
# Current committed version (the `?: "x.y.z"` default in build.gradle.kts).
|
||||||
case "$TAG" in
|
MANIFEST_VERSION="$(sed -nE 's/.*\?: "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "${GRADLE_MODULE}/build.gradle.kts" | head -1)"
|
||||||
v[0-9]*) : ;;
|
LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)"
|
||||||
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
|
if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi
|
||||||
esac
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
|
|
||||||
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
|
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
||||||
|
BODIES="$(git log --no-merges --format='%B' $RANGE || true)"
|
||||||
|
|
||||||
|
BUMP=none
|
||||||
|
if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi
|
||||||
|
if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi
|
||||||
|
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi
|
||||||
|
if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi
|
||||||
|
|
||||||
|
bump() { # <x.y.z> <major|minor|patch> -> bumped
|
||||||
|
IFS=. read -r MA MI PA <<< "$1"
|
||||||
|
case "$2" in
|
||||||
|
major) echo "$((MA+1)).0.0" ;;
|
||||||
|
minor) echo "${MA}.$((MI+1)).0" ;;
|
||||||
|
patch) echo "${MA}.${MI}.$((PA+1))" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
RELEASE=true
|
||||||
|
if [ -z "$LAST_TAG" ]; then
|
||||||
|
VERSION="$MANIFEST_VERSION" # first release: ship what's committed
|
||||||
|
elif [ "$BUMP" = none ]; then
|
||||||
|
RELEASE=false # no feat/fix/breaking since last tag
|
||||||
|
VERSION="${LAST_TAG#v}"
|
||||||
|
else
|
||||||
|
VERSION="$(bump "${LAST_TAG#v}" "$BUMP")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
|
||||||
|
echo "Tag v${VERSION} already exists — nothing to release."
|
||||||
|
RELEASE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Derive a deterministic, monotonic versionCode from the semver.
|
||||||
IFS=. read -r MA MI PA <<< "$VERSION"
|
IFS=. read -r MA MI PA <<< "$VERSION"
|
||||||
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
|
|
||||||
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
|
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
|
||||||
|
|
||||||
# Changelog: conventional-commit subjects since the previous v* tag.
|
|
||||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
|
||||||
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
|
|
||||||
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "## Runic Gateway Android ${TAG}"
|
echo "## Runic Gateway Android v${VERSION}"
|
||||||
echo
|
echo
|
||||||
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
||||||
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
||||||
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
||||||
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
||||||
echo "### All changes"
|
echo "### All changes"
|
||||||
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
|
if [ -n "$LAST_TAG" ]; then echo "Since ${LAST_TAG}:"; fi
|
||||||
echo "$SUBJECTS" | sed 's/^/- /'
|
echo "$SUBJECTS" | sed 's/^/- /'
|
||||||
echo
|
echo
|
||||||
echo "---"
|
echo "---"
|
||||||
@@ -107,25 +140,35 @@ jobs:
|
|||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
|
echo "release=${RELEASE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "==> release=${RELEASE} version=${VERSION} code=${VERSION_CODE} bump=${BUMP} last_tag=${LAST_TAG:-<none>}"
|
||||||
|
|
||||||
# ── SDK + signing keystore ───────────────────────────────────────────
|
# ── ANDROID ADAPTER: SDK + signing keystore ──────────────────────────
|
||||||
- name: Set up Android SDK
|
- name: Set up Android SDK
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
|
||||||
# Only put cmdline-tools on PATH. The action's default package set drags in
|
|
||||||
# the whole emulator + the legacy `tools` package (hundreds of MB, network-
|
|
||||||
# bound on this runner) that a headless APK build never uses. The next step
|
|
||||||
# installs exactly the packages we need.
|
|
||||||
packages: ''
|
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
set +o pipefail
|
set +o pipefail
|
||||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||||
|
|
||||||
|
- name: Cache Gradle
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
gradle-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Decode signing keystore
|
- name: Decode signing keystore
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
@@ -137,8 +180,9 @@ jobs:
|
|||||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
|
||||||
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
|
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
# ── Set the version, build the signed APK ────────────────────────────
|
# ── ANDROID ADAPTER: set the version, gate, build the signed APK ─────
|
||||||
- name: Set the app version to match the tag
|
- name: Set the app version to match the release
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION="${{ steps.plan.outputs.version }}"
|
VERSION="${{ steps.plan.outputs.version }}"
|
||||||
@@ -149,6 +193,7 @@ jobs:
|
|||||||
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
|
grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts"
|
||||||
|
|
||||||
- name: Unit tests + signed release APK
|
- name: Unit tests + signed release APK
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
@@ -159,6 +204,7 @@ jobs:
|
|||||||
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease
|
||||||
|
|
||||||
- name: Package APK + SHA256SUMS
|
- name: Package APK + SHA256SUMS
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk"
|
||||||
@@ -167,8 +213,37 @@ jobs:
|
|||||||
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS )
|
||||||
ls -l dist && cat dist/SHA256SUMS
|
ls -l dist && cat dist/SHA256SUMS
|
||||||
|
|
||||||
# ── Create the Gitea release + upload assets (no push to main) ───────
|
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
||||||
|
- name: Commit version bump and push tag
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
|
# Secrets can arrive with a trailing newline; a stray CR/LF corrupts the
|
||||||
|
# remote URL / auth header. Strip line breaks before use.
|
||||||
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
git config user.name "android-app-ci"
|
||||||
|
git config user.email "ci@whitlocktech.com"
|
||||||
|
git remote set-url origin \
|
||||||
|
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||||
|
|
||||||
|
git add "${GRADLE_MODULE}/build.gradle.kts"
|
||||||
|
if ! git diff --cached --quiet; then
|
||||||
|
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
|
||||||
|
git push origin "HEAD:main"
|
||||||
|
else
|
||||||
|
echo "Version unchanged (first release) — no bump commit needed."
|
||||||
|
fi
|
||||||
|
git tag "${TAG}"
|
||||||
|
git push origin "${TAG}"
|
||||||
|
|
||||||
|
# ── RELEASE ENGINE: create the Gitea release + upload assets ─────────
|
||||||
- name: Create Gitea release and upload assets
|
- name: Create Gitea release and upload assets
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# Run SonarQube static analysis against the code that just landed on `main` and
|
|
||||||
# report the results to the self-hosted SonarQube server for review. This is
|
|
||||||
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
|
||||||
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
|
|
||||||
# (which gates PRs) and release.yml (which ships the APK) — this one only feeds
|
|
||||||
# the dashboard.
|
|
||||||
#
|
|
||||||
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
|
||||||
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
|
||||||
# My Account → Security in SonarQube for the
|
|
||||||
# runic-gateway-android-app project (or a global one).
|
|
||||||
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
|
||||||
# http://192.168.0.56:9000
|
|
||||||
# (kept as a variable, not committed, so the internal address stays out of git.)
|
|
||||||
#
|
|
||||||
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
|
|
||||||
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
|
|
||||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
|
||||||
# dashboard when you want to.
|
|
||||||
#
|
|
||||||
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
|
|
||||||
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
|
|
||||||
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
|
|
||||||
# enrichment" note in sonar-project.properties for wiring in Android Lint /
|
|
||||||
# coverage reports later.
|
|
||||||
|
|
||||||
name: SonarQube
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
# Allow re-running the analysis on demand from the Actions tab.
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: sonarqube-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analysis:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out (full history for accurate new-code + blame)
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
# SonarQube uses git history to attribute issues to authors and to
|
|
||||||
# compute "new code". A shallow clone degrades both.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Run SonarQube scan
|
|
||||||
uses: sonarsource/sonarqube-scan-action@v4
|
|
||||||
env:
|
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
||||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
|
||||||
@@ -48,19 +48,6 @@ android {
|
|||||||
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
|
versionName = (project.findProperty("versionName") as String?)?.takeIf { it.isNotBlank() } ?: "0.1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
// Android App Links host (docs/android/APP_LINKS.md). autoVerify needs a
|
|
||||||
// *literal* host at build time, so a single multi-tenant APK cannot verify
|
|
||||||
// open-ended shard domains: App Links are a build-time opt-in. Left empty for
|
|
||||||
// the generic build (custom scheme only); a white-label/first-party build
|
|
||||||
// bakes one host with `-PappLinkHost=play.myshard.com`.
|
|
||||||
// • BuildConfig.APP_LINK_HOST — SsoAuthManager reads it to pick the redirect.
|
|
||||||
// • manifestPlaceholder appLinkHost — substituted into the intent-filter host;
|
|
||||||
// empty falls back to the reserved `.invalid` sentinel so the autoVerify
|
|
||||||
// filter is inert (matches no real link, never verifies).
|
|
||||||
val appLinkHost = (project.findProperty("appLinkHost") as String?)?.trim().orEmpty()
|
|
||||||
buildConfigField("String", "APP_LINK_HOST", "\"$appLinkHost\"")
|
|
||||||
manifestPlaceholders["appLinkHost"] = appLinkHost.ifBlank { "runic-gateway.invalid" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -6,13 +6,6 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!-- Opt-in push notifications (M7): the runtime notification permission (API 33+)
|
|
||||||
and a foreground service that holds the persistent ntfy connection open — the
|
|
||||||
embedded UnifiedPush distributor, so no separate app is needed (PLAN.md §11). -->
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RunicGatewayApp"
|
android:name=".RunicGatewayApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -24,56 +17,15 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.RunicGateway">
|
android:theme="@style/Theme.RunicGateway">
|
||||||
|
|
||||||
<!-- singleTop so the SSO Custom Tab returning via the deep link reuses the
|
|
||||||
running task (onNewIntent) instead of stacking a second activity. -->
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:theme="@style/Theme.RunicGateway">
|
android:theme="@style/Theme.RunicGateway">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Native SSO callback (M9, PLAN.md §4.2). The bridge deep-links the
|
|
||||||
one-time authorization code back to this fixed, app-owned custom
|
|
||||||
scheme; it must match SsoAuthManager.REDIRECT_URI and the backend's
|
|
||||||
MOBILE_AUTH_REDIRECT_URIS allowlist exactly. This is the permanent
|
|
||||||
fallback on every build (docs/android/APP_LINKS.md). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data
|
|
||||||
android:scheme="runicgateway"
|
|
||||||
android:host="auth"
|
|
||||||
android:path="/callback" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- App Links hardening (docs/android/APP_LINKS.md): a verified https
|
|
||||||
callback that only the domain's real owner can claim. autoVerify
|
|
||||||
needs a literal host, so ${appLinkHost} is baked at build time
|
|
||||||
(build.gradle.kts). The generic build leaves it as the reserved
|
|
||||||
runic-gateway.invalid sentinel — the filter then matches no real
|
|
||||||
link and never verifies. A white-label build sets -PappLinkHost. -->
|
|
||||||
<intent-filter android:autoVerify="true">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data
|
|
||||||
android:scheme="https"
|
|
||||||
android:host="${appLinkHost}"
|
|
||||||
android:path="/mobile/callback" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- The embedded distributor's persistent ntfy connection (M7, PLAN.md §11).
|
|
||||||
dataSync foreground type; not exported — started only by PushManager. -->
|
|
||||||
<service
|
|
||||||
android:name=".core.push.PushService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -3,25 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app
|
package com.runicgateway.app
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.SystemBarStyle
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
|
||||||
import com.runicgateway.app.core.push.PushNotifier
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.runicgateway.app.ui.AppViewModel
|
import com.runicgateway.app.ui.AppViewModel
|
||||||
@@ -33,8 +26,6 @@ import com.runicgateway.app.ui.connect.ConnectScreen
|
|||||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||||
import com.runicgateway.app.ui.theme.parseBrandColor
|
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||||
@@ -44,23 +35,8 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
// Native SSO bridge — handles the runicgateway://auth/callback deep link (M9,
|
|
||||||
// §4.2). Field-injected because the callback can arrive independent of any
|
|
||||||
// ViewModel; a successful exchange flips the SessionManager the whole app
|
|
||||||
// observes, and the login screen consumes SsoAuthManager.outcome.
|
|
||||||
@Inject
|
|
||||||
lateinit var ssoAuthManager: SsoAuthManager
|
|
||||||
|
|
||||||
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
|
|
||||||
// Set from the launching intent and from onNewIntent (the activity is singleTop),
|
|
||||||
// consumed once by RunicApp which navigates to the stream's screen.
|
|
||||||
private var pendingStream by mutableStateOf<String?>(null)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
|
||||||
handleSsoCallback(intent)
|
|
||||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||||
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||||
@@ -82,47 +58,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
AppState.NeedsConnection ->
|
AppState.NeedsConnection ->
|
||||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||||
is AppState.Ready ->
|
is AppState.Ready ->
|
||||||
RunicApp(
|
RunicApp(brand = s.brand, onChangeServer = appViewModel::changeServer)
|
||||||
brand = s.brand,
|
|
||||||
onChangeServer = appViewModel::changeServer,
|
|
||||||
deepLinkStream = pendingStream,
|
|
||||||
onDeepLinkConsumed = { pendingStream = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A notification tap or an SSO callback arriving while the activity is already
|
|
||||||
* running (singleTop) — the common case, since the Custom Tab overlays the live
|
|
||||||
* app during sign-in.
|
|
||||||
*/
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
|
|
||||||
handleSsoCallback(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
|
|
||||||
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
|
|
||||||
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
|
|
||||||
* docs/android/APP_LINKS.md). Both feed the *same* exchange; the result surfaces
|
|
||||||
* on `SsoAuthManager.outcome` (success signs the session in; failure shows on the
|
|
||||||
* login screen). Non-callback intents are ignored.
|
|
||||||
*/
|
|
||||||
private fun handleSsoCallback(intent: Intent?) {
|
|
||||||
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
|
|
||||||
val isCallback = ssoAuthManager.matchesCallback(data.scheme, data.host, data.path) ||
|
|
||||||
ssoAuthManager.matchesAppLinkCallback(data.scheme, data.host, data.path)
|
|
||||||
if (!isCallback) return
|
|
||||||
val state = data.getQueryParameter("state")
|
|
||||||
val code = data.getQueryParameter("code")
|
|
||||||
val error = data.getQueryParameter("error")
|
|
||||||
lifecycleScope.launch { ssoAuthManager.complete(state = state, code = code, error = error) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.auth.sso
|
|
||||||
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.util.Base64
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PKCE + CSRF-state primitives for the Mobile SSO Authorization Bridge — "Layer B"
|
|
||||||
* of the two PKCE layers (app ↔ website; PLAN.md §4.2, BACKEND_DESIGN "Two PKCE
|
|
||||||
* layers"). The app proves at `/exchange` that it holds the verifier for the
|
|
||||||
* challenge it registered at `/start`, so an intercepted callback code is useless
|
|
||||||
* to anyone but this app.
|
|
||||||
*
|
|
||||||
* Pure JVM (no Android framework types) so it unit-tests on the plain test runner.
|
|
||||||
* The encoding mirrors the backend exactly (RFC 7636 S256): the challenge is
|
|
||||||
* `base64url(SHA-256(verifier))` with no padding, matching Node's
|
|
||||||
* `crypto.createHash('sha256').update(verifier).digest('base64url')`.
|
|
||||||
*/
|
|
||||||
object Pkce {
|
|
||||||
|
|
||||||
private val random = SecureRandom()
|
|
||||||
|
|
||||||
// RFC 4648 §5 URL-safe base64 without padding — the base64url the backend uses.
|
|
||||||
private val encoder = Base64.getUrlEncoder().withoutPadding()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fresh high-entropy `code_verifier`: 32 random bytes → 43 base64url chars,
|
|
||||||
* comfortably inside RFC 7636's 43–128 range and identical in form to the
|
|
||||||
* verifier the website generates for its own IdP layer.
|
|
||||||
*/
|
|
||||||
fun newVerifier(): String = randomToken()
|
|
||||||
|
|
||||||
/** A fresh opaque CSRF `state` (same entropy/shape as a verifier). */
|
|
||||||
fun newState(): String = randomToken()
|
|
||||||
|
|
||||||
/** `code_challenge` for [verifier] using the S256 method. */
|
|
||||||
fun challengeOf(verifier: String): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))
|
|
||||||
return encoder.encodeToString(digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun randomToken(): String {
|
|
||||||
val bytes = ByteArray(32)
|
|
||||||
random.nextBytes(bytes)
|
|
||||||
return encoder.encodeToString(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.auth.sso
|
|
||||||
|
|
||||||
import com.runicgateway.app.BuildConfig
|
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
|
||||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Orchestrates the native "Sign in with Google/Discord" flow — the app half of the
|
|
||||||
* Mobile SSO Authorization Bridge (PLAN.md §4.2, BACKEND_DESIGN "Mobile SSO
|
|
||||||
* Authorization Bridge"). It never adds a parallel auth path: a successful exchange
|
|
||||||
* drives the *same* [SessionManager.onSignedIn] the password login uses, so the
|
|
||||||
* menu, push registration, and re-validation all react identically.
|
|
||||||
*
|
|
||||||
* The flow:
|
|
||||||
* 1. [buildStartUrl] mints PKCE (Layer B) + a CSRF `state`, stashes them, and
|
|
||||||
* returns the `/auth/mobile/sso/start` URL the caller opens in a Custom Tab.
|
|
||||||
* 2. The website bounces through the IdP and deep-links back to
|
|
||||||
* [REDIRECT_URI] with `?code&state` (success) or `?error&state` (failure).
|
|
||||||
* 3. [complete] verifies `state`, exchanges the `code` with the stashed verifier,
|
|
||||||
* and signs the user in — publishing the result on [outcome]. `MainActivity`
|
|
||||||
* parses the callback `Uri` (the Android edge) and hands the raw params here,
|
|
||||||
* so this class stays free of framework types and unit-tests on the JVM.
|
|
||||||
*
|
|
||||||
* The pending `{state, verifier}` lives only in memory: if the process is killed
|
|
||||||
* while the Custom Tab is foreground it is lost and the exchange **fails closed**
|
|
||||||
* (the user simply retries) — never a security downgrade.
|
|
||||||
*
|
|
||||||
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
|
|
||||||
* activity's coroutine scope after a deep link. The pending holder is an
|
|
||||||
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation
|
|
||||||
* while the Custom Tab is open cannot drop a result.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class SsoAuthManager @Inject constructor(
|
|
||||||
private val ssoApi: SsoApi,
|
|
||||||
private val sessionManager: SessionManager,
|
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
|
||||||
) {
|
|
||||||
|
|
||||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
|
||||||
enum class Failure {
|
|
||||||
/** The user cancelled or the IdP/website refused (e.g. no linked account). */
|
|
||||||
DENIED,
|
|
||||||
|
|
||||||
/** The callback `state` didn't match — CSRF guard, or the pending flow was lost. */
|
|
||||||
STATE_MISMATCH,
|
|
||||||
|
|
||||||
/** The one-time code was unknown / expired / already used, or PKCE failed. */
|
|
||||||
EXPIRED_CODE,
|
|
||||||
|
|
||||||
/** Offline / DNS / TLS / timeout during the exchange. */
|
|
||||||
NETWORK,
|
|
||||||
|
|
||||||
/** Any other server failure, or a missing base URL / malformed callback. */
|
|
||||||
SERVER,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The observable result of the most recent flow; the login screen consumes it. */
|
|
||||||
sealed interface Outcome {
|
|
||||||
data object Idle : Outcome
|
|
||||||
data object Success : Outcome
|
|
||||||
data class Failed(val reason: Failure) : Outcome
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Pending(val state: String, val verifier: String)
|
|
||||||
|
|
||||||
private val pending = AtomicReference<Pending?>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
|
|
||||||
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
|
|
||||||
* `internal var` only so unit tests can exercise the App Link path without a build
|
|
||||||
* flavor; production never reassigns it.
|
|
||||||
*/
|
|
||||||
internal var appLinkHost: String = BuildConfig.APP_LINK_HOST
|
|
||||||
|
|
||||||
private val _outcome = MutableStateFlow<Outcome>(Outcome.Idle)
|
|
||||||
val outcome: StateFlow<Outcome> = _outcome.asStateFlow()
|
|
||||||
|
|
||||||
/** Ack a delivered [outcome] so it isn't re-handled after a recomposition. */
|
|
||||||
fun consumeOutcome() {
|
|
||||||
_outcome.value = Outcome.Idle
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
|
|
||||||
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet
|
|
||||||
* (the caller then keeps the website hand-off fallback). Also resets [outcome]
|
|
||||||
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt.
|
|
||||||
*/
|
|
||||||
fun buildStartUrl(providerId: String): String? {
|
|
||||||
val base = baseUrlHolder.current ?: return null
|
|
||||||
val verifier = Pkce.newVerifier()
|
|
||||||
val challenge = Pkce.challengeOf(verifier)
|
|
||||||
val state = Pkce.newState()
|
|
||||||
pending.set(Pending(state = state, verifier = verifier))
|
|
||||||
_outcome.value = Outcome.Idle
|
|
||||||
return base.newBuilder()
|
|
||||||
.addPathSegments("api/v1/auth/mobile/sso/start")
|
|
||||||
.addQueryParameter("provider", providerId)
|
|
||||||
.addQueryParameter("code_challenge", challenge)
|
|
||||||
.addQueryParameter("state", state)
|
|
||||||
.addQueryParameter("redirect_uri", redirectUriFor(base.host))
|
|
||||||
.build()
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `redirect_uri` to request for a shard on [pairedHost]: the verified https
|
|
||||||
* App Link callback **iff** this build baked an App Link host that matches the
|
|
||||||
* paired host (a white-label/first-party build for exactly this shard — which is
|
|
||||||
* also responsible for enabling `mobile_app_links_enabled` server-side); otherwise
|
|
||||||
* the fixed custom-scheme callback, which every build/shard always supports.
|
|
||||||
*/
|
|
||||||
private fun redirectUriFor(pairedHost: String): String =
|
|
||||||
if (appLinkHost.isNotBlank() && appLinkHost.equals(pairedHost, ignoreCase = true)) {
|
|
||||||
"https://$pairedHost$APP_LINK_CALLBACK_PATH"
|
|
||||||
} else {
|
|
||||||
REDIRECT_URI
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True if a deep link's scheme/host/path are our fixed custom-scheme SSO callback. */
|
|
||||||
fun matchesCallback(scheme: String?, host: String?, path: String?): Boolean =
|
|
||||||
scheme == CALLBACK_SCHEME && host == CALLBACK_HOST && path == CALLBACK_PATH
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True if a deep link is a verified https App Link callback for the shard we are
|
|
||||||
* **currently paired to**. The `host == pairedHost` check is defense-in-depth:
|
|
||||||
* `autoVerify` already means only a real, opted-in shard domain can route here,
|
|
||||||
* but the app still refuses an https callback whose host isn't the paired shard.
|
|
||||||
* Returns false before a shard is configured (no paired host to trust).
|
|
||||||
*/
|
|
||||||
fun matchesAppLinkCallback(scheme: String?, host: String?, path: String?): Boolean {
|
|
||||||
val pairedHost = baseUrlHolder.current?.host ?: return false
|
|
||||||
return scheme == "https" && path == APP_LINK_CALLBACK_PATH &&
|
|
||||||
host != null && host.equals(pairedHost, ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the parsed callback params from a returned [REDIRECT_URI] deep link:
|
|
||||||
* verify `state`, map an `error`, else exchange the `code` and sign in.
|
|
||||||
* Publishes the result on [outcome]. Idempotent-safe: the pending is cleared on
|
|
||||||
* entry, so a duplicate delivery of the same callback finds no pending and fails
|
|
||||||
* as [Failure.STATE_MISMATCH] rather than double-exchanging (the backend also
|
|
||||||
* single-uses the code).
|
|
||||||
*/
|
|
||||||
suspend fun complete(state: String?, code: String?, error: String?) {
|
|
||||||
val stashed = pending.getAndSet(null)
|
|
||||||
|
|
||||||
// CSRF: the callback must echo the exact state we generated at /start.
|
|
||||||
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
|
|
||||||
_outcome.value = Outcome.Failed(Failure.STATE_MISMATCH)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// A website/IdP-side failure comes back as ?error=… (never with a code).
|
|
||||||
if (!error.isNullOrEmpty()) {
|
|
||||||
_outcome.value = Outcome.Failed(mapError(error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code.isNullOrBlank()) {
|
|
||||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = try {
|
|
||||||
ssoApi.exchange(MobileSsoExchangeRequest(code = code, codeVerifier = stashed.verifier))
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (_: IOException) {
|
|
||||||
_outcome.value = Outcome.Failed(Failure.NETWORK)
|
|
||||||
return
|
|
||||||
} catch (_: Exception) {
|
|
||||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
val body = response.body()
|
|
||||||
if (body == null) {
|
|
||||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
|
||||||
_outcome.value = Outcome.Success
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_outcome.value = Outcome.Failed(if (response.code() == 401) Failure.EXPIRED_CODE else Failure.SERVER)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bridge's start + callback error codes → user-facing failure reasons.
|
|
||||||
// Start (mobileSso.controller): invalid_provider | provider_unavailable | server_error.
|
|
||||||
// Callback (sso.controller): not_linked | disabled | session_expired | error,
|
|
||||||
// plus a forwarded IdP access_denied.
|
|
||||||
private fun mapError(error: String): Failure = when (error) {
|
|
||||||
// Link-only policy refused, or the account is inactive, or the user declined.
|
|
||||||
"not_linked", "disabled", "access_denied" -> Failure.DENIED
|
|
||||||
// The bridge session aged out mid-flow — start over.
|
|
||||||
"session_expired" -> Failure.EXPIRED_CODE
|
|
||||||
// invalid_provider / provider_unavailable / server_error / error / anything else.
|
|
||||||
else -> Failure.SERVER
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CALLBACK_SCHEME = "runicgateway"
|
|
||||||
const val CALLBACK_HOST = "auth"
|
|
||||||
const val CALLBACK_PATH = "/callback"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one fixed, application-owned callback the bridge redirects to. Must
|
|
||||||
* match the `MOBILE_AUTH_REDIRECT_URIS` allowlist entry on the backend and
|
|
||||||
* the intent-filter in `AndroidManifest.xml` exactly (PLAN.md §4.2).
|
|
||||||
*/
|
|
||||||
const val REDIRECT_URI = "$CALLBACK_SCHEME://$CALLBACK_HOST$CALLBACK_PATH"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path of the verified https App Link callback (`https://<shard-host>/mobile/callback`).
|
|
||||||
* Must match the app's `autoVerify` intent-filter in `AndroidManifest.xml` and the
|
|
||||||
* backend's self-origin allowlist entry (docs/android/APP_LINKS.md §3.2/§4.2).
|
|
||||||
*/
|
|
||||||
const val APP_LINK_CALLBACK_PATH = "/mobile/callback"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.sse.EventSource
|
|
||||||
import okhttp3.sse.EventSourceListener
|
|
||||||
import okhttp3.sse.EventSources
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The embedded distributor's transport (PLAN.md §11, M7 Part 2 work item 1/3):
|
|
||||||
* a persistent connection to the shard's self-hosted ntfy that subscribes to the
|
|
||||||
* app's own topic and re-emits each content-free tickle. It reuses the same
|
|
||||||
* OkHttp-SSE + reconnect/backoff shape as [com.runicgateway.app.core.net.ShardStreamClient],
|
|
||||||
* but on a **bare** client — no host-retargeting or bearer interceptors — because it
|
|
||||||
* talks straight to ntfy (`<ntfy>/<topic>/sse`), not the website API. Held open by
|
|
||||||
* [PushService]'s foreground service so tickles arrive in the background without
|
|
||||||
* Google Play Services.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class NtfyStreamClient @Inject constructor(
|
|
||||||
private val json: Json,
|
|
||||||
) {
|
|
||||||
// A dedicated client with the read timeout disabled for the mostly-idle stream
|
|
||||||
// (ntfy sends keepalive frames); no interceptors so nothing rewrites the host or
|
|
||||||
// attaches a bearer to the relay.
|
|
||||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
|
||||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
||||||
.retryOnConnectionFailure(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val factory = EventSources.createFactory(client)
|
|
||||||
|
|
||||||
/** Connection lifecycle + decoded tickles for a subscribed topic. */
|
|
||||||
sealed interface Event {
|
|
||||||
data object Open : Event
|
|
||||||
data object Closed : Event
|
|
||||||
data class Message(val tickle: PushTickle) : Event
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A cold flow subscribing to `<ntfyBaseUrl>/<topic>/sse`, reconnecting with
|
|
||||||
* backoff until the collector cancels. A dropped relay simply reconnects; a bad
|
|
||||||
* config (null URL) idles rather than spinning.
|
|
||||||
*/
|
|
||||||
fun events(ntfyBaseUrl: String?, topic: String): Flow<Event> = channelFlow {
|
|
||||||
var backoffMs = INITIAL_BACKOFF_MS
|
|
||||||
while (isActive) {
|
|
||||||
val url = NtfyTopic.sseUrl(ntfyBaseUrl, topic)
|
|
||||||
if (url == null) {
|
|
||||||
trySend(Event.Closed)
|
|
||||||
delay(backoffMs)
|
|
||||||
backoffMs = grow(backoffMs)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.header("Accept", "text/event-stream")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val opened = AtomicBoolean(false)
|
|
||||||
val ended = CompletableDeferred<Unit>()
|
|
||||||
val listener = object : EventSourceListener() {
|
|
||||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
|
||||||
opened.set(true)
|
|
||||||
trySend(Event.Open)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
|
|
||||||
parseNtfyTickle(json, data)?.let { trySend(Event.Message(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(eventSource: EventSource) {
|
|
||||||
trySend(Event.Closed)
|
|
||||||
ended.complete(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
|
|
||||||
trySend(Event.Closed)
|
|
||||||
ended.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val source = factory.newEventSource(request, listener)
|
|
||||||
try {
|
|
||||||
ended.await()
|
|
||||||
} finally {
|
|
||||||
source.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
|
|
||||||
delay(backoffMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val INITIAL_BACKOFF_MS = 2_000L
|
|
||||||
const val MAX_BACKOFF_MS = 30_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import java.security.SecureRandom
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's own ntfy topic — the heart of the embedded-distributor design
|
|
||||||
* (PLAN.md §11, M7 Part 2 work item 1). The app mints a **random, unguessable**
|
|
||||||
* topic and registers its public URL (`https://<ntfy-host>/<topic>`) as the device
|
|
||||||
* endpoint the backend POSTs tickles to; the app subscribes to the same topic's SSE
|
|
||||||
* stream to receive them. Security rests on the topic being unguessable plus the
|
|
||||||
* content-free tickle — a leaked topic name reveals nothing.
|
|
||||||
*/
|
|
||||||
object NtfyTopic {
|
|
||||||
|
|
||||||
// ntfy topic names allow [A-Za-z0-9_-]; keep to that set. The "up" prefix mirrors
|
|
||||||
// the UnifiedPush convention and makes topics recognizable in logs/relay.
|
|
||||||
private const val ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
private const val TOPIC_LEN = 24
|
|
||||||
private const val PREFIX = "up"
|
|
||||||
|
|
||||||
private val secureRandom by lazy { SecureRandom() }
|
|
||||||
|
|
||||||
/** Mint a fresh unguessable topic, e.g. "up7Qk3…" (≈143 bits of entropy). */
|
|
||||||
fun generate(random: java.util.Random = secureRandom): String {
|
|
||||||
val sb = StringBuilder(PREFIX.length + TOPIC_LEN)
|
|
||||||
sb.append(PREFIX)
|
|
||||||
repeat(TOPIC_LEN) { sb.append(ALPHABET[random.nextInt(ALPHABET.length)]) }
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The endpoint URL the backend publishes to: `<ntfyBaseUrl>/<topic>`. [ntfyBaseUrl]
|
|
||||||
* is the client-facing base from `/public/settings.push.ntfyUrl`; a trailing slash
|
|
||||||
* is tolerated. Returns null for a blank base or topic.
|
|
||||||
*/
|
|
||||||
fun endpointUrl(ntfyBaseUrl: String?, topic: String): String? {
|
|
||||||
val base = ntfyBaseUrl?.trim()?.trimEnd('/').orEmpty()
|
|
||||||
if (base.isEmpty() || topic.isBlank()) return null
|
|
||||||
return "$base/$topic"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The SSE subscribe URL the app connects to: `<ntfyBaseUrl>/<topic>/sse`. */
|
|
||||||
fun sseUrl(ntfyBaseUrl: String?, topic: String): String? =
|
|
||||||
endpointUrl(ntfyBaseUrl, topic)?.let { "$it/sse" }
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.runicgateway.app.core.auth.Session
|
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
|
||||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Orchestrates the app's opt-in push lifecycle (PLAN.md §11, M7 Part 2 work item 5):
|
|
||||||
* mint/keep the ntfy topic, register/unregister the device endpoint with the backend,
|
|
||||||
* and start/stop the foreground [PushService] — all keyed to the user's opt-in and
|
|
||||||
* the session. The endpoint the app registers is its own topic URL on the shard's
|
|
||||||
* ntfy (the embedded-distributor design, work item 1).
|
|
||||||
*
|
|
||||||
* Lifecycle rules:
|
|
||||||
* - register only when **signed in** and the shard advertises a relay (`ntfyUrl`);
|
|
||||||
* - a **sign-out** stops the service and forgets the ephemeral registration but keeps
|
|
||||||
* the opt-in intent, so push re-registers on the next sign-in (mirrors the M3 token
|
|
||||||
* teardown, and covers logout / dead-refresh / server switch uniformly via the
|
|
||||||
* session-state observer);
|
|
||||||
* - a **relay/base-URL change** re-registers on the new host with a fresh topic.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class PushManager @Inject constructor(
|
|
||||||
@param:ApplicationContext private val context: Context,
|
|
||||||
private val prefs: PushPreferences,
|
|
||||||
private val notifications: NotificationsRepository,
|
|
||||||
private val sessionManager: SessionManager,
|
|
||||||
) {
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
/** Whether the user has push turned on (drives the Notifications screen). */
|
|
||||||
val enabled: Flow<Boolean> = prefs.enabled
|
|
||||||
|
|
||||||
/** Whether this shard advertises a push relay at all (null ntfyUrl → unsupported). */
|
|
||||||
val supported: Flow<Boolean> = prefs.ntfyUrl.map { !it.isNullOrBlank() }
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Uniform teardown/resume across every auth transition: logout, dead-refresh
|
|
||||||
// sign-out, and server switch all land on SignedOut; a fresh login re-asserts.
|
|
||||||
scope.launch {
|
|
||||||
sessionManager.state.collect { s ->
|
|
||||||
when (s) {
|
|
||||||
is Session.SignedOut -> localTeardown()
|
|
||||||
is Session.SignedIn -> maybeResume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Record the shard's client-facing ntfy base URL (from `/public/settings`). */
|
|
||||||
suspend fun setNtfyUrl(url: String?) {
|
|
||||||
val previous = prefs.snapshot().ntfyUrl
|
|
||||||
prefs.setNtfyUrl(url)
|
|
||||||
// The relay host arriving (or changing) is what unblocks a pending resume.
|
|
||||||
if (!url.isNullOrBlank() && url != previous) maybeResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turn push on (idempotent): ensure a topic on the current relay, register its
|
|
||||||
* endpoint with the backend, persist, and start the foreground service. Called
|
|
||||||
* when the user opts into ≥1 stream.
|
|
||||||
*/
|
|
||||||
suspend fun enable(): PushResult = register(setIntent = true)
|
|
||||||
|
|
||||||
/** Turn push off (user opted out of every stream): clear intent + deregister. */
|
|
||||||
suspend fun disable() {
|
|
||||||
prefs.setEnabled(false)
|
|
||||||
deregisterDevice()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deregister this device on an explicit sign-out / server switch, while the bearer
|
|
||||||
* is still valid, so no orphan device row is left behind. Keeps the opt-in intent
|
|
||||||
* (and ntfyUrl) so push re-registers on the next sign-in. Call this *before* the
|
|
||||||
* session is torn down.
|
|
||||||
*/
|
|
||||||
suspend fun deregisterDevice() {
|
|
||||||
val snap = prefs.snapshot()
|
|
||||||
snap.deviceId?.let { notifications.deleteDevice(it) } // best-effort
|
|
||||||
stopService()
|
|
||||||
prefs.clearRegistration()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-assert registration if the user is opted in and the shard supports push. */
|
|
||||||
private suspend fun maybeResume() {
|
|
||||||
val snap = prefs.snapshot()
|
|
||||||
if (snap.enabled && sessionManager.isSignedIn && !snap.ntfyUrl.isNullOrBlank()) {
|
|
||||||
register(setIntent = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun register(setIntent: Boolean): PushResult {
|
|
||||||
if (!sessionManager.isSignedIn) return PushResult.NotSignedIn
|
|
||||||
val snap = prefs.snapshot()
|
|
||||||
val ntfyUrl = snap.ntfyUrl
|
|
||||||
if (ntfyUrl.isNullOrBlank()) return PushResult.Unsupported
|
|
||||||
|
|
||||||
// Reuse an existing topic only if its endpoint still sits on the current relay
|
|
||||||
// origin; otherwise (first run, or a server switch) mint a fresh unguessable one.
|
|
||||||
val base = ntfyUrl.trimEnd('/')
|
|
||||||
val topic = snap.topic?.takeIf { snap.endpoint?.startsWith("$base/") == true }
|
|
||||||
?: NtfyTopic.generate()
|
|
||||||
val endpoint = NtfyTopic.endpointUrl(ntfyUrl, topic) ?: return PushResult.Unsupported
|
|
||||||
|
|
||||||
return when (val res = notifications.registerDevice(endpoint, PLATFORM)) {
|
|
||||||
is ApiResult.Ok -> {
|
|
||||||
prefs.setRegistration(topic, endpoint, res.data.id)
|
|
||||||
if (setIntent) prefs.setEnabled(true)
|
|
||||||
startService()
|
|
||||||
PushResult.Enabled
|
|
||||||
}
|
|
||||||
// 400 = endpoint origin isn't on the shard's ntfy allow-set (misconfigured relay).
|
|
||||||
is ApiResult.HttpError -> PushResult.Failed(res.status)
|
|
||||||
is ApiResult.NetworkError -> PushResult.Failed(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local-only teardown on sign-out — no backend DELETE (the bearer may be dead). */
|
|
||||||
private suspend fun localTeardown() {
|
|
||||||
stopService()
|
|
||||||
prefs.clearRegistration()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startService() = runCatching { PushService.start(context) }
|
|
||||||
private fun stopService() = runCatching { PushService.stop(context) }
|
|
||||||
|
|
||||||
/** The outcome of enabling push, surfaced to the Notifications screen. */
|
|
||||||
sealed interface PushResult {
|
|
||||||
data object Enabled : PushResult
|
|
||||||
|
|
||||||
/** This shard advertises no push relay (`/public/settings.push.ntfyUrl` is null). */
|
|
||||||
data object Unsupported : PushResult
|
|
||||||
data object NotSignedIn : PushResult
|
|
||||||
|
|
||||||
/** Registration failed — [status] 400 = relay off the allow-set; null = network. */
|
|
||||||
data class Failed(val status: Int?) : PushResult
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val PLATFORM = "android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import com.runicgateway.app.MainActivity
|
|
||||||
import com.runicgateway.app.R
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the notification channels and posts a notification for a received tickle
|
|
||||||
* (PLAN.md §11, M7 Part 2 work items 2/3/7). v1 shows a **generic per-stream**
|
|
||||||
* notification titled from the fixed [PushStreams] catalog — the content-free tickle
|
|
||||||
* carries nothing to render, so nothing is fetched to display the notification; tapping
|
|
||||||
* deep-links into [MainActivity] (which fetches fresh over the authenticated API).
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class PushNotifier @Inject constructor(
|
|
||||||
@param:ApplicationContext private val context: Context,
|
|
||||||
) {
|
|
||||||
private val manager = NotificationManagerCompat.from(context)
|
|
||||||
private val nextId = AtomicInteger(1)
|
|
||||||
|
|
||||||
/** Create both channels; safe to call repeatedly (creation is idempotent). */
|
|
||||||
fun ensureChannels() {
|
|
||||||
val system = context.getSystemService(NotificationManager::class.java) ?: return
|
|
||||||
system.createNotificationChannel(
|
|
||||||
NotificationChannel(
|
|
||||||
CHANNEL_MESSAGES,
|
|
||||||
context.getString(R.string.push_channel_messages),
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
|
||||||
).apply { description = context.getString(R.string.push_channel_messages_desc) },
|
|
||||||
)
|
|
||||||
system.createNotificationChannel(
|
|
||||||
NotificationChannel(
|
|
||||||
CHANNEL_SERVICE,
|
|
||||||
context.getString(R.string.push_channel_service),
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
).apply {
|
|
||||||
description = context.getString(R.string.push_channel_service_desc)
|
|
||||||
setShowBadge(false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The persistent low-importance notification the foreground service runs under. */
|
|
||||||
fun serviceNotification(): Notification =
|
|
||||||
NotificationCompat.Builder(context, CHANNEL_SERVICE)
|
|
||||||
.setContentTitle(context.getString(R.string.push_service_title))
|
|
||||||
.setContentText(context.getString(R.string.push_service_text))
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_name)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setContentIntent(deepLinkIntent(stream = null, ref = null))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/** Post a notification for a tickle, deep-linking to the stream's screen on tap. */
|
|
||||||
fun notify(tickle: PushTickle) {
|
|
||||||
if (!manager.areNotificationsEnabled()) return // POST_NOTIFICATIONS not granted
|
|
||||||
val title = context.getString(PushStreams.titleRes(tickle.stream))
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_MESSAGES)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_name)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setContentIntent(deepLinkIntent(tickle.stream, tickle.ref))
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
manager.notify(nextId.getAndIncrement(), notification)
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
// Racing a permission revoke — drop silently rather than crash.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deepLinkIntent(stream: String?, ref: String?): PendingIntent {
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
if (stream != null) putExtra(EXTRA_STREAM, stream)
|
|
||||||
if (ref != null) putExtra(EXTRA_REF, ref)
|
|
||||||
}
|
|
||||||
// A distinct request code per stream so PendingIntents don't collapse into one.
|
|
||||||
val requestCode = stream?.hashCode() ?: 0
|
|
||||||
return PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
requestCode,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CHANNEL_MESSAGES = "push_messages"
|
|
||||||
const val CHANNEL_SERVICE = "push_service"
|
|
||||||
|
|
||||||
/** Intent extras a tapped notification carries into [MainActivity] (§7 deep-links). */
|
|
||||||
const val EXTRA_STREAM = "com.runicgateway.app.push.STREAM"
|
|
||||||
const val EXTRA_REF = "com.runicgateway.app.push.REF"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.core.DataStore
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.edit
|
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private val Context.pushDataStore: DataStore<Preferences> by preferencesDataStore(name = "push")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists the app's push state (PLAN.md §11, M7 Part 2 work item 5). None of it is
|
|
||||||
* secret — the ntfy topic/endpoint's protection is being unguessable plus the
|
|
||||||
* content-free tickle — so plain DataStore is fine (tokens stay in the encrypted
|
|
||||||
* store). Holds the shard's ntfy base URL (from `/public/settings`), the minted
|
|
||||||
* topic + its endpoint URL, the backend-assigned device id (to unregister), and the
|
|
||||||
* user's opt-in flag (the source of truth for "push should be running").
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class PushPreferences @Inject constructor(
|
|
||||||
@param:ApplicationContext private val context: Context,
|
|
||||||
) {
|
|
||||||
private val store = context.pushDataStore
|
|
||||||
|
|
||||||
val enabled: Flow<Boolean> = store.data.map { it[KEY_ENABLED] ?: false }
|
|
||||||
val ntfyUrl: Flow<String?> = store.data.map { it[KEY_NTFY_URL] }
|
|
||||||
|
|
||||||
suspend fun snapshot(): Snapshot {
|
|
||||||
val p = store.data.first()
|
|
||||||
return Snapshot(
|
|
||||||
enabled = p[KEY_ENABLED] ?: false,
|
|
||||||
ntfyUrl = p[KEY_NTFY_URL],
|
|
||||||
topic = p[KEY_TOPIC],
|
|
||||||
endpoint = p[KEY_ENDPOINT],
|
|
||||||
deviceId = p[KEY_DEVICE_ID],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setNtfyUrl(url: String?) = store.edit {
|
|
||||||
if (url.isNullOrBlank()) it.remove(KEY_NTFY_URL) else it[KEY_NTFY_URL] = url
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setEnabled(value: Boolean) = store.edit { it[KEY_ENABLED] = value }
|
|
||||||
|
|
||||||
/** Record the minted topic + its endpoint URL and the assigned device id together. */
|
|
||||||
suspend fun setRegistration(topic: String, endpoint: String, deviceId: Long) = store.edit {
|
|
||||||
it[KEY_TOPIC] = topic
|
|
||||||
it[KEY_ENDPOINT] = endpoint
|
|
||||||
it[KEY_DEVICE_ID] = deviceId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forget the ephemeral device registration (topic/endpoint/device id) — used on
|
|
||||||
* sign-out and on an explicit disable. Deliberately leaves [KEY_ENABLED] and
|
|
||||||
* [KEY_NTFY_URL] intact so the user's opt-in intent survives a sign-out and push
|
|
||||||
* re-registers on the next sign-in; an explicit disable also calls [setEnabled]`(false)`.
|
|
||||||
*/
|
|
||||||
suspend fun clearRegistration() = store.edit {
|
|
||||||
it.remove(KEY_TOPIC)
|
|
||||||
it.remove(KEY_ENDPOINT)
|
|
||||||
it.remove(KEY_DEVICE_ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Snapshot(
|
|
||||||
val enabled: Boolean,
|
|
||||||
val ntfyUrl: String?,
|
|
||||||
val topic: String?,
|
|
||||||
val endpoint: String?,
|
|
||||||
val deviceId: Long?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val KEY_ENABLED = booleanPreferencesKey("enabled")
|
|
||||||
val KEY_NTFY_URL = stringPreferencesKey("ntfy_url")
|
|
||||||
val KEY_TOPIC = stringPreferencesKey("topic")
|
|
||||||
val KEY_ENDPOINT = stringPreferencesKey("endpoint")
|
|
||||||
val KEY_DEVICE_ID = longPreferencesKey("device_id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The always-connected foreground service that IS the embedded distributor
|
|
||||||
* (PLAN.md §11, M7 Part 2 work item 1/3). It holds [NtfyStreamClient]'s persistent
|
|
||||||
* connection to the shard's ntfy open in the background — the price of Google-free,
|
|
||||||
* self-contained instant delivery — and posts a notification for each tickle. It runs
|
|
||||||
* under a low-importance ongoing notification and restarts sticky; [PushManager] starts
|
|
||||||
* and stops it as the user opts in/out or signs out.
|
|
||||||
*/
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class PushService : Service() {
|
|
||||||
|
|
||||||
@Inject lateinit var streamClient: NtfyStreamClient
|
|
||||||
@Inject lateinit var notifier: PushNotifier
|
|
||||||
@Inject lateinit var prefs: PushPreferences
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private var connectionJob: Job? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
notifier.ensureChannels()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
startAsForeground()
|
|
||||||
if (connectionJob == null) connectionJob = scope.launch { run() }
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startAsForeground() {
|
|
||||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notifier.serviceNotification(), type)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun run() {
|
|
||||||
val snapshot = prefs.snapshot()
|
|
||||||
val topic = snapshot.topic
|
|
||||||
if (topic.isNullOrBlank() || snapshot.ntfyUrl.isNullOrBlank()) {
|
|
||||||
// Nothing to subscribe to (should not happen — PushManager starts us only
|
|
||||||
// once a topic exists) — stop rather than hold a dead connection open.
|
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
streamClient.events(snapshot.ntfyUrl, topic).collectLatest { event ->
|
|
||||||
if (event is NtfyStreamClient.Event.Message) notifier.notify(event.tickle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
connectionJob?.cancel()
|
|
||||||
scope.cancel()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val NOTIFICATION_ID = 42
|
|
||||||
|
|
||||||
fun start(context: Context) {
|
|
||||||
val intent = Intent(context, PushService::class.java)
|
|
||||||
context.startForegroundService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop(context: Context) {
|
|
||||||
context.stopService(Intent(context, PushService::class.java))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.runicgateway.app.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The known push stream ids (mirrors the backend catalog in
|
|
||||||
* `config/notificationStreams.js`) and their localized notification titles.
|
|
||||||
* The subscribable catalog itself is fetched from
|
|
||||||
* `GET /auth/me/notifications/streams`; this fixed set is only what the receiver
|
|
||||||
* needs to title a content-free tickle without a network round-trip (§11).
|
|
||||||
*/
|
|
||||||
object PushStreams {
|
|
||||||
const val NEWS_POST = "news.post"
|
|
||||||
const val SERVER_STATUS = "server.status"
|
|
||||||
const val IDOC_WARNING = "idoc.warning"
|
|
||||||
const val CHAMP_START = "champ.start"
|
|
||||||
const val GOVERNOR_ELECTION = "governor.election"
|
|
||||||
const val VENDOR_SALE = "vendor.sale"
|
|
||||||
const val HOUSE_IDOC = "house.idoc"
|
|
||||||
const val ACCOUNT_LOGIN = "account.login"
|
|
||||||
|
|
||||||
/** A short, localized notification title for [streamId]; a generic fallback otherwise. */
|
|
||||||
@StringRes
|
|
||||||
fun titleRes(streamId: String): Int = when (streamId) {
|
|
||||||
NEWS_POST -> R.string.push_stream_news_post
|
|
||||||
SERVER_STATUS -> R.string.push_stream_server_status
|
|
||||||
IDOC_WARNING -> R.string.push_stream_idoc_warning
|
|
||||||
CHAMP_START -> R.string.push_stream_champ_start
|
|
||||||
GOVERNOR_ELECTION -> R.string.push_stream_governor_election
|
|
||||||
VENDOR_SALE -> R.string.push_stream_vendor_sale
|
|
||||||
HOUSE_IDOC -> R.string.push_stream_house_idoc
|
|
||||||
ACCOUNT_LOGIN -> R.string.push_stream_account_login
|
|
||||||
else -> R.string.push_stream_generic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonNull
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The content-free push tickle the backend publishes (PLAN.md §11): `{ stream, ref }`
|
|
||||||
* and nothing sensitive. [ref] is an opaque hint (a serial / city / timestamp) the
|
|
||||||
* app *could* use to pull real content over the authenticated API; v1 just deep-links
|
|
||||||
* to the stream's screen, so it is carried but not otherwise interpreted.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PushTickle(
|
|
||||||
val stream: String,
|
|
||||||
val ref: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a tickle out of an ntfy SSE `data:` frame. ntfy wraps our published body in
|
|
||||||
* its own envelope — `{ event, topic, message, … }` — where `message` is the exact
|
|
||||||
* string we POSTed (our `{ stream, ref }` JSON). Only `event == "message"` frames
|
|
||||||
* carry a payload; `open` / `keepalive` frames return null, as does any malformed or
|
|
||||||
* unrecognized body (dropped, never thrown — §7). Pure + `internal` for unit testing.
|
|
||||||
*/
|
|
||||||
internal fun parseNtfyTickle(json: Json, data: String): PushTickle? {
|
|
||||||
val trimmed = data.trim()
|
|
||||||
if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
|
|
||||||
return try {
|
|
||||||
val envelope = json.parseToJsonElement(trimmed) as? JsonObject ?: return null
|
|
||||||
val event = envelope["event"]?.jsonPrimitive?.content
|
|
||||||
// ntfy lifecycle frames ("open", "keepalive", "poll_request") carry no message.
|
|
||||||
if (event != null && event != "message") return null
|
|
||||||
val messageEl = envelope["message"] ?: return null
|
|
||||||
if (messageEl is JsonNull) return null
|
|
||||||
val message = messageEl.jsonPrimitive.content
|
|
||||||
decodeTickle(json, message)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode our own `{ stream, ref }` body; a blank/missing stream is not a tickle. */
|
|
||||||
internal fun decodeTickle(json: Json, body: String): PushTickle? = try {
|
|
||||||
val tickle = json.decodeFromString(PushTickle.serializer(), body.trim())
|
|
||||||
tickle.takeIf { it.stream.isNotBlank() }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.data.api
|
|
||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
|
||||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
|
||||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.DELETE
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.PUT
|
|
||||||
import retrofit2.http.Path
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
|
|
||||||
* (endpoint) registration and per-user stream subscriptions. Every call rides the
|
|
||||||
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
|
||||||
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
|
||||||
* registration only ever succeeds while signed in.
|
|
||||||
*/
|
|
||||||
interface NotificationsApi {
|
|
||||||
|
|
||||||
@POST("api/v1/auth/me/devices")
|
|
||||||
suspend fun registerDevice(@Body body: RegisterDeviceRequest): PushDeviceDto
|
|
||||||
|
|
||||||
@GET("api/v1/auth/me/devices")
|
|
||||||
suspend fun listDevices(): List<PushDeviceDto>
|
|
||||||
|
|
||||||
@DELETE("api/v1/auth/me/devices/{id}")
|
|
||||||
suspend fun deleteDevice(@Path("id") id: Long): Unit
|
|
||||||
|
|
||||||
@GET("api/v1/auth/me/notifications/streams")
|
|
||||||
suspend fun streams(): NotificationStreamsDto
|
|
||||||
|
|
||||||
@GET("api/v1/auth/me/notifications/subscriptions")
|
|
||||||
suspend fun subscriptions(): NotificationSubscriptionsDto
|
|
||||||
|
|
||||||
@PUT("api/v1/auth/me/notifications/subscriptions")
|
|
||||||
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.data.api
|
|
||||||
|
|
||||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
|
||||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
|
||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
|
||||||
import retrofit2.Response
|
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
|
||||||
import retrofit2.http.Headers
|
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The native SSO bridge surface (PLAN.md §4.2, M9). Discovery lists the shard's
|
|
||||||
* enabled providers; exchange trades a callback authorization code (+ its PKCE
|
|
||||||
* verifier) for the same bearer pair as `/auth/mobile/login`.
|
|
||||||
*
|
|
||||||
* The redirect leg (`/auth/mobile/sso/start`) is **not** here — it is opened in a
|
|
||||||
* Custom Tab as a URL (the browser follows the 302 through the IdP), not called as
|
|
||||||
* an XHR. See [com.runicgateway.app.core.auth.sso.SsoAuthManager].
|
|
||||||
*
|
|
||||||
* Exchange is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER]: it
|
|
||||||
* carries no bearer (the user isn't signed in yet) and a `401` (bad/expired code or
|
|
||||||
* PKCE mismatch) must never be misread as an expired session or trip the refresh
|
|
||||||
* [com.runicgateway.app.core.net.TokenAuthenticator]. It returns a raw [Response]
|
|
||||||
* so the caller can distinguish `401` from other failures.
|
|
||||||
*/
|
|
||||||
interface SsoApi {
|
|
||||||
|
|
||||||
/** Public discovery — the enabled providers to render login buttons for. */
|
|
||||||
@GET("api/v1/auth/providers")
|
|
||||||
suspend fun providers(): List<SsoProviderDto>
|
|
||||||
|
|
||||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
|
||||||
@Headers("X-Runic-No-Session: 1")
|
|
||||||
@POST("api/v1/auth/mobile/sso/exchange")
|
|
||||||
suspend fun exchange(@Body body: MobileSsoExchangeRequest): Response<MobileTokenResponse>
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire shapes for the opt-in push surface under `/auth/me` (PLAN.md §11, M7
|
|
||||||
* Part 2). Field names match the backend's `notifications.controller` /
|
|
||||||
* `pushDevices.model` exactly; every DTO ignores unknown keys (NetworkModule's
|
|
||||||
* lenient Json), so additive backend fields are safe (recorded for M1).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /auth/me/devices` body. [endpoint] is the ntfy topic URL the app's
|
|
||||||
* embedded distributor owns (`https://<ntfy-host>/<topic>`); the backend
|
|
||||||
* SSRF-validates it is HTTPS on the shard's allow-set before storing. [transport]
|
|
||||||
* is `unifiedpush` for the direct-ntfy relay (fcm reserved for a future flavor).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class RegisterDeviceRequest(
|
|
||||||
val endpoint: String,
|
|
||||||
val transport: String = "unifiedpush",
|
|
||||||
val platform: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `POST/GET /auth/me/devices` — one registered device (endpoint) for this user. */
|
|
||||||
@Serializable
|
|
||||||
data class PushDeviceDto(
|
|
||||||
val id: Long = 0,
|
|
||||||
val transport: String = "",
|
|
||||||
val endpoint: String = "",
|
|
||||||
val platform: String? = null,
|
|
||||||
val createdAt: String? = null,
|
|
||||||
val lastSeenAt: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One subscribable stream from `GET /auth/me/notifications/streams`. A [personal]
|
|
||||||
* stream is delivered only to the owning user and [requiresLinkedAccount] — the app
|
|
||||||
* greys its toggle until a game account is linked (§11).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class NotificationStreamDto(
|
|
||||||
val id: String = "",
|
|
||||||
val label: String = "",
|
|
||||||
val description: String = "",
|
|
||||||
val personal: Boolean = false,
|
|
||||||
val requiresLinkedAccount: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** `GET /auth/me/notifications/streams` — the catalog. */
|
|
||||||
@Serializable
|
|
||||||
data class NotificationStreamsDto(
|
|
||||||
val streams: List<NotificationStreamDto> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
|
|
||||||
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
|
|
||||||
* echoed back.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class NotificationSubscriptionsDto(
|
|
||||||
val streams: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
@@ -55,17 +55,6 @@ data class RegistrationFlagsDto(
|
|||||||
val sso: Boolean = false,
|
val sso: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Push-notification relay config (M7). [ntfyUrl] is the client-facing ntfy base
|
|
||||||
* URL the app's embedded distributor registers its device topic against; null (or
|
|
||||||
* absent, on an older backend) means push isn't configured for this shard and the
|
|
||||||
* Notifications screen shows it as unavailable.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PushConfigDto(
|
|
||||||
val ntfyUrl: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
|
* `GET /public/settings` — whitelisted settings + branding. Only the keys the
|
||||||
* app consumes are modeled; other whitelisted keys are ignored.
|
* app consumes are modeled; other whitelisted keys are ignored.
|
||||||
@@ -78,6 +67,4 @@ data class SettingsDto(
|
|||||||
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
|
val registration: RegistrationFlagsDto = RegistrationFlagsDto(),
|
||||||
val gameAccountSignup: Boolean = false,
|
val gameAccountSignup: Boolean = false,
|
||||||
val brand: BrandDto = BrandDto(),
|
val brand: BrandDto = BrandDto(),
|
||||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
|
||||||
val push: PushConfigDto = PushConfigDto(),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire shapes for the Mobile SSO Authorization Bridge (PLAN.md §4.2, M9). The
|
|
||||||
* success payload of `/auth/mobile/sso/exchange` is the shared [MobileTokenResponse]
|
|
||||||
* (same pair as `/auth/mobile/login`) — this file only adds the two shapes unique
|
|
||||||
* to the bridge. Every DTO ignores unknown keys (NetworkModule's lenient Json), so
|
|
||||||
* additive backend fields stay safe (§8).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One entry of `GET /auth/providers` — public discovery, never secrets. [icon] is
|
|
||||||
* the provider kind (`google` | `discord` | `oidc` | `oauth2`); the app renders a
|
|
||||||
* button per provider from this list rather than hardcoding a set. [loginUrl] is
|
|
||||||
* the *website* start path (unused by the app, which builds its own
|
|
||||||
* `/auth/mobile/sso/start` URL); kept so the shape matches the backend exactly.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class SsoProviderDto(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val icon: String? = null,
|
|
||||||
val loginUrl: String? = null,
|
|
||||||
val priority: Int? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `POST /auth/mobile/sso/exchange` body — the one-time authorization code from the
|
|
||||||
* callback deep link plus the PKCE verifier stashed at `/start` (Layer B). Wire
|
|
||||||
* name is snake_case to match the backend's `{ code, code_verifier }`.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class MobileSsoExchangeRequest(
|
|
||||||
val code: String,
|
|
||||||
@SerialName("code_verifier") val codeVerifier: String,
|
|
||||||
)
|
|
||||||
@@ -4,13 +4,10 @@
|
|||||||
package com.runicgateway.app.data.repository
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.core.push.PushManager
|
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
|
||||||
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
import com.runicgateway.app.data.api.dto.MobileLoginRequest
|
||||||
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
|
||||||
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -28,25 +25,10 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class AuthRepository @Inject constructor(
|
class AuthRepository @Inject constructor(
|
||||||
private val authApi: AuthApi,
|
private val authApi: AuthApi,
|
||||||
private val ssoApi: SsoApi,
|
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val pushManager: PushManager,
|
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
|
||||||
* The shard's enabled SSO providers for the native login buttons (§4.2). Public
|
|
||||||
* discovery, never secrets. Returns an empty list on any failure — the login
|
|
||||||
* screen then keeps the website hand-off fallback rather than showing nothing.
|
|
||||||
*/
|
|
||||||
suspend fun ssoProviders(): List<SsoProviderDto> = try {
|
|
||||||
ssoApi.providers()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (_: Exception) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Outcome of a login attempt (§4.1). */
|
/** Outcome of a login attempt (§4.1). */
|
||||||
sealed interface LoginResult {
|
sealed interface LoginResult {
|
||||||
data object Success : LoginResult
|
data object Success : LoginResult
|
||||||
@@ -93,16 +75,6 @@ class AuthRepository @Inject constructor(
|
|||||||
* network call fails, so the user is always signed out locally.
|
* network call fails, so the user is always signed out locally.
|
||||||
*/
|
*/
|
||||||
suspend fun logout(allDevices: Boolean = false) {
|
suspend fun logout(allDevices: Boolean = false) {
|
||||||
// Deregister this device's push endpoint while the bearer is still valid, so
|
|
||||||
// no orphan device row is left behind (§11). Keeps the opt-in intent so push
|
|
||||||
// resumes on the next sign-in; best-effort, never blocks the logout.
|
|
||||||
try {
|
|
||||||
pushManager.deregisterDevice()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Ignore — local session teardown proceeds regardless.
|
|
||||||
}
|
|
||||||
val refreshToken = sessionManager.currentRefreshToken()
|
val refreshToken = sessionManager.currentRefreshToken()
|
||||||
try {
|
try {
|
||||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class ConnectionRepository @Inject constructor(
|
|||||||
private val prefs: ServerPreferences,
|
private val prefs: ServerPreferences,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
|
||||||
private val config: com.runicgateway.app.core.AppConfig,
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -97,14 +96,6 @@ class ConnectionRepository @Inject constructor(
|
|||||||
* signed-out state against the new host.
|
* signed-out state against the new host.
|
||||||
*/
|
*/
|
||||||
suspend fun disconnect() {
|
suspend fun disconnect() {
|
||||||
// Deregister the push endpoint on the current (old) host while still authed,
|
|
||||||
// then clear the shard's ntfy URL — the new host advertises its own (§11).
|
|
||||||
try {
|
|
||||||
pushManager.deregisterDevice()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Best-effort; the reset proceeds regardless.
|
|
||||||
}
|
|
||||||
pushManager.setNtfyUrl(null)
|
|
||||||
sessionManager.onSignedOut()
|
sessionManager.onSignedOut()
|
||||||
prefs.clear()
|
prefs.clear()
|
||||||
baseUrlHolder.set(null)
|
baseUrlHolder.set(null)
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.data.repository
|
|
||||||
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
|
||||||
import com.runicgateway.app.core.result.safeApiCall
|
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
|
||||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
|
||||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device registration + per-user stream subscriptions over the opt-in push surface
|
|
||||||
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
|
|
||||||
* and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
|
|
||||||
* (endpoint off the shard's allow-set) or a down backend never throws (§7).
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class NotificationsRepository @Inject constructor(
|
|
||||||
private val api: NotificationsApi,
|
|
||||||
) {
|
|
||||||
suspend fun registerDevice(endpoint: String, platform: String?): ApiResult<PushDeviceDto> =
|
|
||||||
safeApiCall { api.registerDevice(RegisterDeviceRequest(endpoint = endpoint, platform = platform)) }
|
|
||||||
|
|
||||||
suspend fun listDevices(): ApiResult<List<PushDeviceDto>> = safeApiCall { api.listDevices() }
|
|
||||||
|
|
||||||
suspend fun deleteDevice(id: Long): ApiResult<Unit> = safeApiCall { api.deleteDevice(id) }
|
|
||||||
|
|
||||||
suspend fun streams(): ApiResult<NotificationStreamsDto> = safeApiCall { api.streams() }
|
|
||||||
|
|
||||||
suspend fun subscriptions(): ApiResult<NotificationSubscriptionsDto> =
|
|
||||||
safeApiCall { api.subscriptions() }
|
|
||||||
|
|
||||||
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
|
|
||||||
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
|
|
||||||
}
|
|
||||||
@@ -14,10 +14,8 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
|
|||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
|
||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -97,11 +95,6 @@ object NetworkModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||||
|
|
||||||
/** Native SSO discovery + code exchange (§4.2, M9) — on the main client. */
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideSsoApi(retrofit: Retrofit): SsoApi = retrofit.create(SsoApi::class.java)
|
|
||||||
|
|
||||||
/** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */
|
/** Role-agnostic self-service (§6.4) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -113,12 +106,6 @@ object NetworkModule {
|
|||||||
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
||||||
retrofit.create(PlayerShardApi::class.java)
|
retrofit.create(PlayerShardApi::class.java)
|
||||||
|
|
||||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
|
||||||
retrofit.create(NotificationsApi::class.java)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package com.runicgateway.app.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
import com.runicgateway.app.core.push.PushManager
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
import com.runicgateway.app.data.api.dto.BrandDto
|
import com.runicgateway.app.data.api.dto.BrandDto
|
||||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||||
@@ -28,7 +27,6 @@ class AppViewModel @Inject constructor(
|
|||||||
private val connectionRepository: ConnectionRepository,
|
private val connectionRepository: ConnectionRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val pushManager: PushManager,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed interface AppState {
|
sealed interface AppState {
|
||||||
@@ -68,16 +66,8 @@ class AppViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private suspend fun loadBrand(): BrandDto? =
|
||||||
* Load public settings for branding and feed the shard's push relay URL into the
|
(settingsRepository.getSettings() as? ApiResult.Ok)?.data?.brand
|
||||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
|
||||||
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
|
|
||||||
*/
|
|
||||||
private suspend fun loadBrand(): BrandDto? {
|
|
||||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
|
||||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
|
||||||
return settings?.brand
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a possibly site-relative asset path (branding logos, post images)
|
* Resolve a possibly site-relative asset path (branding logos, post images)
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import com.runicgateway.app.ui.navigation.Routes
|
|||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||||
import com.runicgateway.app.ui.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
|
||||||
import com.runicgateway.app.ui.page.PageScreen
|
import com.runicgateway.app.ui.page.PageScreen
|
||||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||||
import com.runicgateway.app.ui.player.CharactersScreen
|
import com.runicgateway.app.ui.player.CharactersScreen
|
||||||
@@ -76,7 +75,6 @@ import kotlinx.coroutines.launch
|
|||||||
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
/** Destinations that show the drawer (hamburger); others show a back arrow. */
|
||||||
private val TOP_LEVEL_ROUTES = setOf(
|
private val TOP_LEVEL_ROUTES = setOf(
|
||||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||||
Routes.NOTIFICATIONS,
|
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,8 +91,6 @@ fun RunicApp(
|
|||||||
brand: BrandDto?,
|
brand: BrandDto?,
|
||||||
onChangeServer: () -> Unit,
|
onChangeServer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
deepLinkStream: String? = null,
|
|
||||||
onDeepLinkConsumed: () -> Unit = {},
|
|
||||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
@@ -109,16 +105,6 @@ fun RunicApp(
|
|||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
|
||||||
LaunchedEffect(deepLinkStream) {
|
|
||||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
|
||||||
navController.navigate(Routes.forStream(stream)) {
|
|
||||||
popUpTo(Routes.HOME) { saveState = true }
|
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
onDeepLinkConsumed()
|
|
||||||
}
|
|
||||||
|
|
||||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry?.destination?.route
|
val currentRoute = backStackEntry?.destination?.route
|
||||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
@@ -323,14 +309,6 @@ private fun RunicNavHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composable(Routes.NOTIFICATIONS) {
|
|
||||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
|
||||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
|
||||||
when (session) {
|
|
||||||
is Session.SignedIn -> NotificationsScreen()
|
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Player game data (§6.3) — reached from the player-only menu groups.
|
// ── Player game data (§6.3) — reached from the player-only menu groups.
|
||||||
// The server enforces the player gate on every call; these screens simply
|
// The server enforces the player gate on every call; these screens simply
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -57,13 +56,6 @@ fun LoginScreen(
|
|||||||
if (state.signedIn) onSignedIn()
|
if (state.signedIn) onSignedIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open a freshly-minted SSO /start URL in a Custom Tab, exactly once (§4.2).
|
|
||||||
LaunchedEffect(state.ssoLaunchUrl) {
|
|
||||||
val url = state.ssoLaunchUrl ?: return@LaunchedEffect
|
|
||||||
WebHandoff.open(context, url)
|
|
||||||
viewModel.onSsoLaunchConsumed()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -163,29 +155,6 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the
|
|
||||||
// Custom-Tab bridge and returns the user signed in. Falls back to the
|
|
||||||
// website login hand-off when the shard exposes no providers.
|
|
||||||
if (state.ssoProviders.isNotEmpty()) {
|
|
||||||
state.ssoProviders.forEach { provider ->
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { viewModel.onSsoProviderClick(provider) },
|
|
||||||
enabled = !state.submitting,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.login_sso_provider, provider.name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewModel.ssoLoginUrl?.let { url ->
|
|
||||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
|
||||||
Text(stringResource(R.string.login_sso))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
||||||
viewModel.registerUrl?.let { url ->
|
viewModel.registerUrl?.let { url ->
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -198,6 +167,11 @@ fun LoginScreen(
|
|||||||
Text(stringResource(R.string.login_forgot))
|
Text(stringResource(R.string.login_forgot))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModel.ssoLoginUrl?.let { url ->
|
||||||
|
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
||||||
|
Text(stringResource(R.string.login_sso))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,5 +181,4 @@ private fun loginErrorRes(error: LoginError): Int = when (error) {
|
|||||||
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
|
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
|
||||||
LoginError.SERVER -> R.string.login_error_server
|
LoginError.SERVER -> R.string.login_error_server
|
||||||
LoginError.NETWORK -> R.string.login_error_network
|
LoginError.NETWORK -> R.string.login_error_network
|
||||||
LoginError.SSO -> R.string.login_error_sso
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ package com.runicgateway.app.ui.auth
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
|
||||||
import com.runicgateway.app.core.web.WebsiteUrls
|
import com.runicgateway.app.core.web.WebsiteUrls
|
||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
|
||||||
import com.runicgateway.app.data.repository.AuthRepository
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -27,12 +25,11 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LoginViewModel @Inject constructor(
|
class LoginViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val ssoAuthManager: SsoAuthManager,
|
|
||||||
private val websiteUrls: WebsiteUrls,
|
private val websiteUrls: WebsiteUrls,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
/** The transient error surfaced under the form after a failed attempt. */
|
/** The transient error surfaced under the form after a failed attempt. */
|
||||||
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK, SSO }
|
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK }
|
||||||
|
|
||||||
data class UiState(
|
data class UiState(
|
||||||
val username: String = "",
|
val username: String = "",
|
||||||
@@ -43,40 +40,11 @@ class LoginViewModel @Inject constructor(
|
|||||||
val submitting: Boolean = false,
|
val submitting: Boolean = false,
|
||||||
val error: LoginError? = null,
|
val error: LoginError? = null,
|
||||||
val signedIn: Boolean = false,
|
val signedIn: Boolean = false,
|
||||||
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */
|
|
||||||
val ssoProviders: List<SsoProviderDto> = emptyList(),
|
|
||||||
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
|
|
||||||
val ssoLaunchUrl: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val _state = MutableStateFlow(UiState())
|
private val _state = MutableStateFlow(UiState())
|
||||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
|
||||||
// Discover the native SSO providers to render buttons for (§4.2).
|
|
||||||
viewModelScope.launch {
|
|
||||||
val providers = authRepository.ssoProviders()
|
|
||||||
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
|
|
||||||
}
|
|
||||||
// Consume the SSO bridge outcome: a returned callback completes here even if
|
|
||||||
// this ViewModel was recreated while the Custom Tab was foreground (§4.2).
|
|
||||||
viewModelScope.launch {
|
|
||||||
ssoAuthManager.outcome.collect { outcome ->
|
|
||||||
when (outcome) {
|
|
||||||
SsoAuthManager.Outcome.Success -> {
|
|
||||||
ssoAuthManager.consumeOutcome()
|
|
||||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
|
||||||
}
|
|
||||||
is SsoAuthManager.Outcome.Failed -> {
|
|
||||||
ssoAuthManager.consumeOutcome()
|
|
||||||
_state.update { it.copy(submitting = false, error = mapSsoError(outcome.reason)) }
|
|
||||||
}
|
|
||||||
SsoAuthManager.Outcome.Idle -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
|
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
|
||||||
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
|
||||||
fun onCodeChange(value: String) =
|
fun onCodeChange(value: String) =
|
||||||
@@ -84,33 +52,8 @@ class LoginViewModel @Inject constructor(
|
|||||||
|
|
||||||
val registerUrl: String? get() = websiteUrls.register()
|
val registerUrl: String? get() = websiteUrls.register()
|
||||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||||
|
|
||||||
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */
|
|
||||||
val ssoLoginUrl: String? get() = websiteUrls.login()
|
val ssoLoginUrl: String? get() = websiteUrls.login()
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
|
|
||||||
* `/start` URL for the screen to open in a Custom Tab. No-op (leaves a SERVER
|
|
||||||
* error) if the base URL isn't set yet — the website fallback still shows.
|
|
||||||
*/
|
|
||||||
fun onSsoProviderClick(provider: SsoProviderDto) {
|
|
||||||
if (_state.value.submitting) return
|
|
||||||
val url = ssoAuthManager.buildStartUrl(provider.id)
|
|
||||||
if (url == null) {
|
|
||||||
_state.update { it.copy(error = LoginError.SSO) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_state.update { it.copy(error = null, ssoLaunchUrl = url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The screen has opened the Custom Tab; clear so it isn't re-launched on recompose. */
|
|
||||||
fun onSsoLaunchConsumed() = _state.update { it.copy(ssoLaunchUrl = null) }
|
|
||||||
|
|
||||||
private fun mapSsoError(reason: SsoAuthManager.Failure): LoginError = when (reason) {
|
|
||||||
SsoAuthManager.Failure.NETWORK -> LoginError.NETWORK
|
|
||||||
else -> LoginError.SSO
|
|
||||||
}
|
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
val s = _state.value
|
val s = _state.value
|
||||||
if (s.submitting) return
|
if (s.submitting) return
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||||
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
|
||||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
||||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
||||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ object Routes {
|
|||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
const val ACCOUNT = "account"
|
const val ACCOUNT = "account"
|
||||||
|
|
||||||
/** Opt-in push notification settings (§11, signed-in). */
|
|
||||||
const val NOTIFICATIONS = "notifications"
|
|
||||||
|
|
||||||
/** Public shard hub (§6.2). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
|
|
||||||
@@ -60,23 +57,4 @@ object Routes {
|
|||||||
|
|
||||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||||
fun playerChar(serial: String) = "player/char/$serial"
|
fun playerChar(serial: String) = "player/char/$serial"
|
||||||
|
|
||||||
/**
|
|
||||||
* The in-app destination a tapped push notification deep-links to (§11, M7
|
|
||||||
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
|
||||||
* unknown streams land on Home. Personal streams route to the player groups
|
|
||||||
* (a signed-out/demoted tap is caught by [com.runicgateway.app.ui.PlayerGate]).
|
|
||||||
*/
|
|
||||||
fun forStream(streamId: String): String = when (streamId) {
|
|
||||||
com.runicgateway.app.core.push.PushStreams.NEWS_POST -> NEWS
|
|
||||||
com.runicgateway.app.core.push.PushStreams.SERVER_STATUS,
|
|
||||||
com.runicgateway.app.core.push.PushStreams.CHAMP_START,
|
|
||||||
com.runicgateway.app.core.push.PushStreams.IDOC_WARNING,
|
|
||||||
com.runicgateway.app.core.push.PushStreams.GOVERNOR_ELECTION,
|
|
||||||
-> SHARD
|
|
||||||
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
|
|
||||||
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
|
|
||||||
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
|
||||||
else -> HOME
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.ui.notifications
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.runicgateway.app.R
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
|
||||||
import com.runicgateway.app.ui.UiState
|
|
||||||
import com.runicgateway.app.ui.components.EmptyView
|
|
||||||
import com.runicgateway.app.ui.components.ErrorView
|
|
||||||
import com.runicgateway.app.ui.components.LoadingView
|
|
||||||
import com.runicgateway.app.ui.components.SectionLabel
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6): the
|
|
||||||
* subscribable catalog with per-stream toggles. Personal streams are greyed until a
|
|
||||||
* game account is linked; turning a stream on requests the POST_NOTIFICATIONS
|
|
||||||
* permission (API 33+) and registers the device, turning them all off unregisters it.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun NotificationsScreen(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
viewModel: NotificationsViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
// Ask once for POST_NOTIFICATIONS when the user first enables a stream (API 33+).
|
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { /* granted or not, the subscription is already saved server-side */ }
|
|
||||||
|
|
||||||
fun ensureNotificationPermission() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.notifications_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.notifications_subtitle),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
if (!state.supported) {
|
|
||||||
EmptyView(message = stringResource(R.string.notifications_unsupported))
|
|
||||||
return@Column
|
|
||||||
}
|
|
||||||
|
|
||||||
state.feedback?.let { fb ->
|
|
||||||
Text(
|
|
||||||
text = stringResource(fb.messageRes),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(bottom = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (val catalog = state.catalog) {
|
|
||||||
is UiState.Loading -> LoadingView()
|
|
||||||
is UiState.Error -> ErrorView(kind = catalog.kind, onRetry = viewModel::load)
|
|
||||||
is UiState.Success -> StreamList(
|
|
||||||
streams = catalog.data,
|
|
||||||
subscribed = state.subscribed,
|
|
||||||
hasLinkedAccount = state.hasLinkedAccount,
|
|
||||||
busy = state.busy,
|
|
||||||
onToggle = { stream, on ->
|
|
||||||
if (on) ensureNotificationPermission()
|
|
||||||
viewModel.setSubscribed(stream, on)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StreamList(
|
|
||||||
streams: List<NotificationStreamDto>,
|
|
||||||
subscribed: Set<String>,
|
|
||||||
hasLinkedAccount: Boolean,
|
|
||||||
busy: Boolean,
|
|
||||||
onToggle: (NotificationStreamDto, Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
if (streams.isEmpty()) {
|
|
||||||
EmptyView(message = stringResource(R.string.notifications_empty))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val (personal, general) = streams.partition { it.personal }
|
|
||||||
|
|
||||||
if (general.isNotEmpty()) {
|
|
||||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
general.forEach { stream ->
|
|
||||||
StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
|
|
||||||
onToggle(stream, on)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (personal.isNotEmpty()) {
|
|
||||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
personal.forEach { stream ->
|
|
||||||
val selectable = streamSelectable(stream, hasLinkedAccount)
|
|
||||||
val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
|
|
||||||
StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
|
|
||||||
onToggle(stream, on)
|
|
||||||
}
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun StreamRow(
|
|
||||||
stream: NotificationStreamDto,
|
|
||||||
checked: Boolean,
|
|
||||||
enabled: Boolean,
|
|
||||||
hint: String?,
|
|
||||||
onToggle: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stream.label,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = hint ?: stream.description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.ui.notifications
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.runicgateway.app.R
|
|
||||||
import com.runicgateway.app.core.push.PushManager
|
|
||||||
import com.runicgateway.app.core.result.ApiResult
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
|
||||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
|
||||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
|
||||||
import com.runicgateway.app.ui.UiState
|
|
||||||
import com.runicgateway.app.ui.toUiState
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6):
|
|
||||||
* the stream catalog with per-stream toggles bound to
|
|
||||||
* `GET/PUT /auth/me/notifications/subscriptions`. A **personal** stream is greyed
|
|
||||||
* until the user has a linked game account (§11), and turning the opt-in set
|
|
||||||
* non-empty/empty drives the [PushManager] to register/unregister the device.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class NotificationsViewModel @Inject constructor(
|
|
||||||
private val notifications: NotificationsRepository,
|
|
||||||
private val playerShard: PlayerShardRepository,
|
|
||||||
private val pushManager: PushManager,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val catalog: UiState<List<NotificationStreamDto>> = UiState.Loading,
|
|
||||||
val subscribed: Set<String> = emptySet(),
|
|
||||||
/** Whether the user has ≥1 linked game account — personal streams need it. */
|
|
||||||
val hasLinkedAccount: Boolean = false,
|
|
||||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
|
||||||
val supported: Boolean = true,
|
|
||||||
val busy: Boolean = false,
|
|
||||||
val feedback: Feedback? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(State())
|
|
||||||
val state: StateFlow<State> = _state.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
|
|
||||||
}
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load() {
|
|
||||||
_state.update { it.copy(catalog = UiState.Loading) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
val catalog = notifications.streams().let { result ->
|
|
||||||
when (result) {
|
|
||||||
is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
|
|
||||||
is ApiResult.HttpError -> result
|
|
||||||
is ApiResult.NetworkError -> result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_state.update { it.copy(catalog = catalog.toUiState()) }
|
|
||||||
|
|
||||||
when (val subs = notifications.subscriptions()) {
|
|
||||||
is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
// A linked game account gates the personal streams; failure → treat as none.
|
|
||||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
|
||||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
|
||||||
|
|
||||||
/** Toggle [stream]; refuses a personal stream with no linked account. */
|
|
||||||
fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.busy) return
|
|
||||||
if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
|
|
||||||
val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
|
|
||||||
|
|
||||||
_state.update { it.copy(busy = true, feedback = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val result = notifications.setSubscriptions(next.toList())) {
|
|
||||||
is ApiResult.Ok -> {
|
|
||||||
val stored = result.data.streams.toSet()
|
|
||||||
_state.update { it.copy(subscribed = stored) }
|
|
||||||
reconcilePush(stored)
|
|
||||||
}
|
|
||||||
is ApiResult.NetworkError -> finish(false, R.string.error_network)
|
|
||||||
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register or unregister the device to match the opted-in set (PLAN.md §11:
|
|
||||||
* register when signed-in + subscribed, unregister when the set empties).
|
|
||||||
*/
|
|
||||||
private suspend fun reconcilePush(subscribed: Set<String>) {
|
|
||||||
if (subscribed.isEmpty()) {
|
|
||||||
pushManager.disable()
|
|
||||||
finish(true, R.string.notifications_all_off)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when (val res = pushManager.enable()) {
|
|
||||||
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
|
|
||||||
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
|
|
||||||
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
|
|
||||||
is PushManager.PushResult.Failed ->
|
|
||||||
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
|
||||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a stream's toggle is selectable for a user: a personal stream needs a
|
|
||||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
|
||||||
*/
|
|
||||||
fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
|
|
||||||
!stream.requiresLinkedAccount || hasLinkedAccount
|
|
||||||
@@ -61,14 +61,11 @@
|
|||||||
<string name="login_register">Create an account</string>
|
<string name="login_register">Create an account</string>
|
||||||
<string name="login_forgot">Forgot your password?</string>
|
<string name="login_forgot">Forgot your password?</string>
|
||||||
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
|
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
|
||||||
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). -->
|
|
||||||
<string name="login_sso_provider">Sign in with %1$s</string>
|
|
||||||
<string name="login_error_credentials">Incorrect username or password.</string>
|
<string name="login_error_credentials">Incorrect username or password.</string>
|
||||||
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
||||||
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
||||||
<string name="login_error_server">Something went wrong. Please try again.</string>
|
<string name="login_error_server">Something went wrong. Please try again.</string>
|
||||||
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
|
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
|
||||||
<string name="login_error_sso">Couldn\'t complete that sign-in. Please try again.</string>
|
|
||||||
|
|
||||||
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
|
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
|
||||||
<string name="account_title">My account</string>
|
<string name="account_title">My account</string>
|
||||||
@@ -260,37 +257,4 @@
|
|||||||
<string name="houses_empty">No houses are in danger right now.</string>
|
<string name="houses_empty">No houses are in danger right now.</string>
|
||||||
<string name="houses_fallback_name">A house</string>
|
<string name="houses_fallback_name">A house</string>
|
||||||
<string name="houses_idoc_badge">IDOC</string>
|
<string name="houses_idoc_badge">IDOC</string>
|
||||||
|
|
||||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
|
||||||
<string name="menu_notifications">Notifications</string>
|
|
||||||
<string name="notifications_title">Notifications</string>
|
|
||||||
<string name="notifications_subtitle">Choose what this shard notifies you about. Nothing is sent unless you turn it on.</string>
|
|
||||||
<string name="notifications_section_general">General</string>
|
|
||||||
<string name="notifications_section_personal">Your game account</string>
|
|
||||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
|
||||||
<string name="notifications_empty">This shard offers no notification streams yet.</string>
|
|
||||||
<string name="notifications_unsupported">This shard hasn\'t set up push notifications yet.</string>
|
|
||||||
<string name="notifications_saved">Notification settings saved.</string>
|
|
||||||
<string name="notifications_all_off">Notifications turned off.</string>
|
|
||||||
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
|
|
||||||
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
|
|
||||||
|
|
||||||
<!-- Notification channels + the ongoing foreground-service notification. -->
|
|
||||||
<string name="push_channel_messages">Shard notifications</string>
|
|
||||||
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
|
||||||
<string name="push_channel_service">Background connection</string>
|
|
||||||
<string name="push_channel_service_desc">Keeps the connection open to deliver notifications.</string>
|
|
||||||
<string name="push_service_title">Notifications active</string>
|
|
||||||
<string name="push_service_text">Listening for shard notifications.</string>
|
|
||||||
|
|
||||||
<!-- Per-stream notification titles (content-free tickle → generic title, §11). -->
|
|
||||||
<string name="push_stream_news_post">New post</string>
|
|
||||||
<string name="push_stream_server_status">Shard status changed</string>
|
|
||||||
<string name="push_stream_idoc_warning">A house is falling (IDOC)</string>
|
|
||||||
<string name="push_stream_champ_start">Champion spawn started</string>
|
|
||||||
<string name="push_stream_governor_election">New governor elected</string>
|
|
||||||
<string name="push_stream_vendor_sale">Your vendor made a sale</string>
|
|
||||||
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
|
||||||
<string name="push_stream_account_login">Login to your account</string>
|
|
||||||
<string name="push_stream_generic">New notification</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.auth.sso
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PKCE Layer B primitives (PLAN.md §4.2). The challenge encoding must match the
|
|
||||||
* backend byte-for-byte (`base64url(SHA-256(verifier))`, no padding) or `/exchange`
|
|
||||||
* rejects every code — so it is pinned against the RFC 7636 test vector.
|
|
||||||
*/
|
|
||||||
class PkceTest {
|
|
||||||
|
|
||||||
// RFC 4648 §5 URL-safe base64 alphabet, no padding.
|
|
||||||
private val base64UrlNoPad = Regex("^[A-Za-z0-9_-]+$")
|
|
||||||
|
|
||||||
@Test fun `challenge matches the RFC 7636 vector`() {
|
|
||||||
// RFC 7636 Appendix B.
|
|
||||||
val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
|
||||||
assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", Pkce.challengeOf(verifier))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `verifier is url-safe base64 without padding`() {
|
|
||||||
val verifier = Pkce.newVerifier()
|
|
||||||
assertTrue("verifier charset: $verifier", base64UrlNoPad.matches(verifier))
|
|
||||||
// 32 random bytes → 43 base64 chars (no padding), inside RFC 7636's 43–128.
|
|
||||||
assertEquals(43, verifier.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `challenge is url-safe base64 without padding`() {
|
|
||||||
val challenge = Pkce.challengeOf(Pkce.newVerifier())
|
|
||||||
assertTrue("challenge charset: $challenge", base64UrlNoPad.matches(challenge))
|
|
||||||
// SHA-256 (32 bytes) → 43 base64 chars, no '=' padding.
|
|
||||||
assertEquals(43, challenge.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `verifiers and states are unique per call`() {
|
|
||||||
assertNotEquals(Pkce.newVerifier(), Pkce.newVerifier())
|
|
||||||
assertNotEquals(Pkce.newState(), Pkce.newState())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `state is url-safe base64 without padding`() {
|
|
||||||
assertTrue(base64UrlNoPad.matches(Pkce.newState()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.auth.sso
|
|
||||||
|
|
||||||
import com.runicgateway.app.core.auth.Session
|
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
|
||||||
import com.runicgateway.app.core.auth.StoredSession
|
|
||||||
import com.runicgateway.app.core.auth.TokenStore
|
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
|
||||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
|
||||||
import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
|
||||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
|
||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import retrofit2.Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The native SSO bridge orchestration (PLAN.md §4.2, M9): start-URL building, the
|
|
||||||
* CSRF/state guard, the code→token exchange, and that a success drives the *same*
|
|
||||||
* [SessionManager] the password login uses. Runs over a fake [SsoApi] + a real
|
|
||||||
* [SessionManager] on a fake [TokenStore]; no Android framework types are touched.
|
|
||||||
*/
|
|
||||||
class SsoAuthManagerTest {
|
|
||||||
|
|
||||||
private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
|
|
||||||
override fun load(): StoredSession? = stored
|
|
||||||
override fun save(session: StoredSession) { stored = session }
|
|
||||||
override fun clear() { stored = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Records the exchange it was called with and returns a scripted response. */
|
|
||||||
private class FakeSsoApi(
|
|
||||||
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
|
||||||
) : SsoApi {
|
|
||||||
var exchangeCalls = 0
|
|
||||||
var lastRequest: MobileSsoExchangeRequest? = null
|
|
||||||
|
|
||||||
override suspend fun providers(): List<SsoProviderDto> = emptyList()
|
|
||||||
|
|
||||||
override suspend fun exchange(body: MobileSsoExchangeRequest): Response<MobileTokenResponse> {
|
|
||||||
exchangeCalls++
|
|
||||||
lastRequest = body
|
|
||||||
return exchangeResult()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tokenPair() = MobileTokenResponse(
|
|
||||||
accessToken = "access-A",
|
|
||||||
refreshToken = "refresh-A",
|
|
||||||
expiresIn = "15m",
|
|
||||||
user = SafeUserDto(id = 7, username = "alice", role = "player"),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun error(code: Int): Response<MobileTokenResponse> =
|
|
||||||
Response.error(code, "".toResponseBody("application/json".toMediaTypeOrNull()))
|
|
||||||
|
|
||||||
private fun managerWith(
|
|
||||||
api: SsoApi,
|
|
||||||
session: SessionManager,
|
|
||||||
base: String? = "https://shard.example.com/",
|
|
||||||
): SsoAuthManager {
|
|
||||||
val holder = BaseUrlHolder()
|
|
||||||
if (base != null) holder.set(base.toHttpUrl())
|
|
||||||
return SsoAuthManager(api, session, holder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build a start URL and pull the generated `state` back out of it. */
|
|
||||||
private fun startAndState(mgr: SsoAuthManager, provider: String = "google"): String {
|
|
||||||
val url = mgr.buildStartUrl(SsoProviderDto(id = provider, name = "Google").id)!!
|
|
||||||
return url.toHttpUrl().queryParameter("state")!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `buildStartUrl carries provider, challenge, state and the fixed redirect`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { tokenPair().let { Response.success(it) } }, SessionManager(FakeTokenStore()))
|
|
||||||
val url = mgr.buildStartUrl("google")!!
|
|
||||||
val http = url.toHttpUrl()
|
|
||||||
|
|
||||||
assertTrue(url.startsWith("https://shard.example.com/api/v1/auth/mobile/sso/start"))
|
|
||||||
assertEquals("google", http.queryParameter("provider"))
|
|
||||||
assertEquals(SsoAuthManager.REDIRECT_URI, http.queryParameter("redirect_uri"))
|
|
||||||
assertTrue(!http.queryParameter("code_challenge").isNullOrBlank())
|
|
||||||
assertTrue(!http.queryParameter("state").isNullOrBlank())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `buildStartUrl returns null when no base url is set`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
|
|
||||||
assertNull(mgr.buildStartUrl("google"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `successful callback exchanges and signs in`() = runTest {
|
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
|
||||||
val session = SessionManager(FakeTokenStore())
|
|
||||||
val mgr = managerWith(api, session)
|
|
||||||
|
|
||||||
val state = startAndState(mgr)
|
|
||||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
|
||||||
|
|
||||||
assertEquals(1, api.exchangeCalls)
|
|
||||||
assertEquals("auth-code-1", api.lastRequest?.code)
|
|
||||||
assertTrue(session.state.value is Session.SignedIn)
|
|
||||||
assertEquals("access-A", session.currentAccessToken())
|
|
||||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `state mismatch fails without exchanging`() = runTest {
|
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
|
||||||
val session = SessionManager(FakeTokenStore())
|
|
||||||
val mgr = managerWith(api, session)
|
|
||||||
|
|
||||||
startAndState(mgr) // establishes a pending with a different state
|
|
||||||
mgr.complete(state = "not-the-state", code = "auth-code-1", error = null)
|
|
||||||
|
|
||||||
assertEquals(0, api.exchangeCalls)
|
|
||||||
assertTrue(session.state.value is Session.SignedOut)
|
|
||||||
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `missing pending flow (process death) fails closed`() = runTest {
|
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
|
||||||
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
|
|
||||||
|
|
||||||
// No buildStartUrl → nothing stashed; a callback can't be trusted.
|
|
||||||
mgr.complete(state = "anything", code = "auth-code-1", error = null)
|
|
||||||
|
|
||||||
assertEquals(0, api.exchangeCalls)
|
|
||||||
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
|
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
|
||||||
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
|
|
||||||
|
|
||||||
val state = startAndState(mgr)
|
|
||||||
mgr.complete(state = state, code = null, error = "access_denied")
|
|
||||||
|
|
||||||
assertEquals(0, api.exchangeCalls)
|
|
||||||
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.DENIED), mgr.outcome.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `401 exchange maps to expired code`() = runTest {
|
|
||||||
val api = FakeSsoApi { error(401) }
|
|
||||||
val session = SessionManager(FakeTokenStore())
|
|
||||||
val mgr = managerWith(api, session)
|
|
||||||
|
|
||||||
val state = startAndState(mgr)
|
|
||||||
mgr.complete(state = state, code = "stale-code", error = null)
|
|
||||||
|
|
||||||
assertEquals(1, api.exchangeCalls)
|
|
||||||
assertTrue(session.state.value is Session.SignedOut)
|
|
||||||
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.EXPIRED_CODE), mgr.outcome.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `a second delivery of the same callback finds no pending`() = runTest {
|
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
|
||||||
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
|
|
||||||
|
|
||||||
val state = startAndState(mgr)
|
|
||||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
|
||||||
mgr.complete(state = state, code = "auth-code-1", error = null) // replay
|
|
||||||
|
|
||||||
// Only the first delivery exchanged; the replay fails the state guard.
|
|
||||||
assertEquals(1, api.exchangeCalls)
|
|
||||||
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `matchesCallback only accepts the fixed scheme host and path`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
|
|
||||||
assertTrue(mgr.matchesCallback("runicgateway", "auth", "/callback"))
|
|
||||||
assertTrue(!mgr.matchesCallback("https", "auth", "/callback"))
|
|
||||||
assertTrue(!mgr.matchesCallback("runicgateway", "auth", "/other"))
|
|
||||||
assertTrue(!mgr.matchesCallback("runicgateway", "evil", "/callback"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App Links (docs/android/APP_LINKS.md) ────────────────────────────────
|
|
||||||
|
|
||||||
@Test fun `matchesAppLinkCallback accepts only https, the app-link path, and the paired host`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
|
|
||||||
// Paired to shard.example.com (managerWith default base).
|
|
||||||
assertTrue(mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
|
|
||||||
// Host-trust: a foreign host is refused even over https + right path.
|
|
||||||
assertTrue(!mgr.matchesAppLinkCallback("https", "evil.example.com", "/mobile/callback"))
|
|
||||||
// Wrong scheme / wrong path.
|
|
||||||
assertTrue(!mgr.matchesAppLinkCallback("http", "shard.example.com", "/mobile/callback"))
|
|
||||||
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/callback"))
|
|
||||||
// Host match is case-insensitive.
|
|
||||||
assertTrue(mgr.matchesAppLinkCallback("https", "SHARD.EXAMPLE.COM", "/mobile/callback"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `matchesAppLinkCallback is false before a shard is paired`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()), base = null)
|
|
||||||
assertTrue(!mgr.matchesAppLinkCallback("https", "shard.example.com", "/mobile/callback"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `buildStartUrl requests the custom scheme when no app-link host is baked`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
|
|
||||||
// Generic build: appLinkHost defaults to BuildConfig.APP_LINK_HOST ("" in tests).
|
|
||||||
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
|
|
||||||
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `buildStartUrl requests the https app-link callback when the baked host matches the paired host`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
|
|
||||||
mgr.appLinkHost = "shard.example.com" // white-label build baked this shard's host
|
|
||||||
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
|
|
||||||
assertEquals("https://shard.example.com/mobile/callback", redirect)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun `buildStartUrl falls back to the custom scheme when the baked host does not match the paired shard`() {
|
|
||||||
val mgr = managerWith(FakeSsoApi { Response.success(tokenPair()) }, SessionManager(FakeTokenStore()))
|
|
||||||
mgr.appLinkHost = "other-shard.example.com" // built for a different shard than the paired one
|
|
||||||
val redirect = mgr.buildStartUrl("google")!!.toHttpUrl().queryParameter("redirect_uri")
|
|
||||||
assertEquals(SsoAuthManager.REDIRECT_URI, redirect)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for the app's own ntfy topic + endpoint URL building (PLAN.md §11, work
|
|
||||||
* item 1) — the heart of the embedded-distributor design.
|
|
||||||
*/
|
|
||||||
class NtfyTopicTest {
|
|
||||||
|
|
||||||
@Test fun generatesUnguessableTopicsInTheAllowedCharset() {
|
|
||||||
val a = NtfyTopic.generate()
|
|
||||||
val b = NtfyTopic.generate()
|
|
||||||
assertNotEquals(a, b)
|
|
||||||
assertTrue("prefixed", a.startsWith("up"))
|
|
||||||
assertTrue("length", a.length >= 24)
|
|
||||||
assertTrue("charset", a.all { it.isLetterOrDigit() })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun buildsEndpointAndSseUrls() {
|
|
||||||
assertEquals("https://ntfy.tld/up7", NtfyTopic.endpointUrl("https://ntfy.tld", "up7"))
|
|
||||||
assertEquals("https://ntfy.tld/up7/sse", NtfyTopic.sseUrl("https://ntfy.tld", "up7"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun toleratesTrailingSlashOnBase() {
|
|
||||||
assertEquals("https://ntfy.tld/up7", NtfyTopic.endpointUrl("https://ntfy.tld/", "up7"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun nullOrBlankInputsYieldNull() {
|
|
||||||
assertNull(NtfyTopic.endpointUrl(null, "up7"))
|
|
||||||
assertNull(NtfyTopic.endpointUrl("", "up7"))
|
|
||||||
assertNull(NtfyTopic.endpointUrl("https://ntfy.tld", " "))
|
|
||||||
assertNull(NtfyTopic.sseUrl(null, "up7"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.core.push
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parsing tests for the content-free push tickle over ntfy's SSE envelope
|
|
||||||
* (PLAN.md §11). A `message` frame yields `{ stream, ref }`; lifecycle frames and
|
|
||||||
* malformed bodies are dropped (never thrown, §7).
|
|
||||||
*/
|
|
||||||
class PushTickleTest {
|
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
@Test fun parsesMessageFrame() {
|
|
||||||
// ntfy wraps our POSTed body in { event:"message", message:"<our json>" }.
|
|
||||||
val data = """{"id":"x","time":1,"event":"message","topic":"up1","message":"{\"stream\":\"vendor.sale\",\"ref\":\"0x40001\"}"}"""
|
|
||||||
val tickle = parseNtfyTickle(json, data)
|
|
||||||
assertEquals(PushTickle("vendor.sale", "0x40001"), tickle)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun parsesMessageWithoutRef() {
|
|
||||||
val data = """{"event":"message","message":"{\"stream\":\"server.status\"}"}"""
|
|
||||||
val tickle = parseNtfyTickle(json, data)
|
|
||||||
assertEquals("server.status", tickle?.stream)
|
|
||||||
assertNull(tickle?.ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun dropsOpenAndKeepaliveFrames() {
|
|
||||||
assertNull(parseNtfyTickle(json, """{"event":"open","topic":"up1"}"""))
|
|
||||||
assertNull(parseNtfyTickle(json, """{"event":"keepalive","topic":"up1"}"""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun dropsMalformedOrEmpty() {
|
|
||||||
assertNull(parseNtfyTickle(json, ""))
|
|
||||||
assertNull(parseNtfyTickle(json, ": keepalive comment"))
|
|
||||||
assertNull(parseNtfyTickle(json, "not json"))
|
|
||||||
// A message whose inner body isn't our shape → no stream → dropped.
|
|
||||||
assertNull(parseNtfyTickle(json, """{"event":"message","message":"{}"}"""))
|
|
||||||
assertNull(parseNtfyTickle(json, """{"event":"message","message":"garbage"}"""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun decodeTickleRejectsBlankStream() {
|
|
||||||
assertNull(decodeTickle(json, """{"stream":"","ref":"x"}"""))
|
|
||||||
assertEquals(PushTickle("news.post"), decodeTickle(json, """{"stream":"news.post"}"""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.data.api.dto
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decoding tests for the opt-in push DTOs (PLAN.md §11, M7 Part 2). Shapes come
|
|
||||||
* from the merged backend (`notifications.controller` / `pushDevices.model`);
|
|
||||||
* unknown keys are ignored (additive fields, §8).
|
|
||||||
*/
|
|
||||||
class NotificationsDtoTest {
|
|
||||||
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
explicitNulls = false
|
|
||||||
coerceInputValues = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun pushDeviceDecodes() {
|
|
||||||
val dto = json.decodeFromString<PushDeviceDto>(
|
|
||||||
"""{"id":9,"transport":"unifiedpush","endpoint":"https://ntfy.example.com/up123",
|
|
||||||
"platform":"android","createdAt":"2026-07-20T00:00:00Z","lastSeenAt":null}""",
|
|
||||||
)
|
|
||||||
assertEquals(9L, dto.id)
|
|
||||||
assertEquals("unifiedpush", dto.transport)
|
|
||||||
assertEquals("https://ntfy.example.com/up123", dto.endpoint)
|
|
||||||
assertEquals("android", dto.platform)
|
|
||||||
assertNull(dto.lastSeenAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun streamCatalogDecodesPersonalFlags() {
|
|
||||||
val dto = json.decodeFromString<NotificationStreamsDto>(
|
|
||||||
"""{"streams":[
|
|
||||||
{"id":"news.post","label":"News posts","description":"New posts.","personal":false,"requiresLinkedAccount":false},
|
|
||||||
{"id":"vendor.sale","label":"Your vendor sold","description":"A sale.","personal":true,"requiresLinkedAccount":true}
|
|
||||||
]}""",
|
|
||||||
)
|
|
||||||
assertEquals(2, dto.streams.size)
|
|
||||||
val news = dto.streams.first { it.id == "news.post" }
|
|
||||||
assertFalse(news.personal)
|
|
||||||
assertFalse(news.requiresLinkedAccount)
|
|
||||||
val vendor = dto.streams.first { it.id == "vendor.sale" }
|
|
||||||
assertTrue(vendor.personal)
|
|
||||||
assertTrue(vendor.requiresLinkedAccount)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun subscriptionsDecode() {
|
|
||||||
val dto = json.decodeFromString<NotificationSubscriptionsDto>(
|
|
||||||
"""{"streams":["news.post","champ.start"]}""",
|
|
||||||
)
|
|
||||||
assertEquals(listOf("news.post", "champ.start"), dto.streams)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun settingsPushBlockDecodes() {
|
|
||||||
val dto = json.decodeFromString<SettingsDto>(
|
|
||||||
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",
|
|
||||||
)
|
|
||||||
assertEquals("https://ntfy.shard.tld", dto.push.ntfyUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun settingsPushDefaultsNullOnOlderBackend() {
|
|
||||||
// A backend predating M7 omits `push` entirely — the app must still decode.
|
|
||||||
val dto = json.decodeFromString<SettingsDto>(
|
|
||||||
"""{"site_title":"Shard","brand":{"name":"Shard"}}""",
|
|
||||||
)
|
|
||||||
assertNull(dto.push.ntfyUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
package com.runicgateway.app.ui.notifications
|
|
||||||
|
|
||||||
import com.runicgateway.app.core.push.PushStreams
|
|
||||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
|
||||||
import com.runicgateway.app.ui.navigation.Routes
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests the pure push helpers: the stream → deep-link route map (PLAN.md §11 work
|
|
||||||
* item 7) and the personal-stream gating (a personal stream needs a linked account).
|
|
||||||
*/
|
|
||||||
class NotificationRoutingTest {
|
|
||||||
|
|
||||||
@Test fun deepLinkRoutesMapEachStreamToItsScreen() {
|
|
||||||
assertEquals(Routes.NEWS, Routes.forStream(PushStreams.NEWS_POST))
|
|
||||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.SERVER_STATUS))
|
|
||||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.CHAMP_START))
|
|
||||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.IDOC_WARNING))
|
|
||||||
assertEquals(Routes.SHARD, Routes.forStream(PushStreams.GOVERNOR_ELECTION))
|
|
||||||
assertEquals(Routes.PLAYER_VENDORS, Routes.forStream(PushStreams.VENDOR_SALE))
|
|
||||||
assertEquals(Routes.PLAYER_HOUSES, Routes.forStream(PushStreams.HOUSE_IDOC))
|
|
||||||
assertEquals(Routes.ACCOUNT, Routes.forStream(PushStreams.ACCOUNT_LOGIN))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun unknownStreamFallsBackToHome() {
|
|
||||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun personalStreamNeedsLinkedAccount() {
|
|
||||||
val personal = NotificationStreamDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
|
||||||
assertFalse(streamSelectable(personal, hasLinkedAccount = false))
|
|
||||||
assertTrue(streamSelectable(personal, hasLinkedAccount = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun generalStreamIsAlwaysSelectable() {
|
|
||||||
val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
|
||||||
assertTrue(streamSelectable(general, hasLinkedAccount = false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# SonarQube analysis config for the Android-app repo.
|
|
||||||
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
|
||||||
# The project key must match the one created in SonarQube (dashboard URL
|
|
||||||
# ?id=runic-gateway-android-app).
|
|
||||||
|
|
||||||
sonar.projectKey=runic-gateway-android-app
|
|
||||||
sonar.projectName=runic gateway android app
|
|
||||||
|
|
||||||
# Analysed application code. The single :app module's Kotlin sources.
|
|
||||||
# SonarQube's Kotlin analyzer works on source directly, so no compiled classes
|
|
||||||
# or Gradle build are required for the scan.
|
|
||||||
sonar.sources=app/src/main
|
|
||||||
|
|
||||||
# Local unit tests (app/src/test). Instrumented tests (app/src/androidTest) can
|
|
||||||
# be added here once that source set exists.
|
|
||||||
sonar.tests=app/src/test
|
|
||||||
|
|
||||||
# Never analyse build output, Gradle internals, or generated code.
|
|
||||||
sonar.exclusions=**/build/**,**/.gradle/**,**/generated/**
|
|
||||||
|
|
||||||
sonar.sourceEncoding=UTF-8
|
|
||||||
|
|
||||||
# ── Optional enrichment (enable once the reports are produced in CI) ──
|
|
||||||
# For richer Kotlin/Android results, run the reporters in sonarqube.yml and point
|
|
||||||
# SonarQube at their output:
|
|
||||||
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
|
|
||||||
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.xml
|
|
||||||
# • JaCoCo coverage (needs a coverage-enabled test run):
|
|
||||||
# sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/.../*.xml
|
|
||||||
# The alternative to the CLI scanner used here is the SonarQube Gradle plugin
|
|
||||||
# (org.sonarqube), which auto-discovers these reports; the CLI + properties file
|
|
||||||
# is used instead to keep this repo's setup identical to website/ and link/.
|
|
||||||
Reference in New Issue
Block a user