Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+5
-19
@@ -6,20 +6,9 @@
|
||||
**/build
|
||||
web/build
|
||||
|
||||
# The Android client — built by its own job, never from this context. The APK
|
||||
# reaches the image through client/, downloaded as a CI artifact, so nothing
|
||||
# here reads android/ sources.
|
||||
#
|
||||
# This block named `flutter_client/` until 2026-09-10 and lost its PATTERN when
|
||||
# that tree was deleted, leaving a comment describing an exclusion that was no
|
||||
# longer happening. android/ never took its place, so 4.1 MB of Gradle project
|
||||
# has been entering the context and busting the `COPY . .` layer on every
|
||||
# Android-only change.
|
||||
android/
|
||||
|
||||
# Local `make build` output — an 18 MB binary the image never uses, since the
|
||||
# builder stage compiles its own.
|
||||
bin/
|
||||
# Flutter mobile client — built separately on developer machines / Flutter CI.
|
||||
# Including it in the Go build context wastes ~70 files and invalidates the
|
||||
# `COPY . .` layer cache on every Flutter-only change.
|
||||
|
||||
# Docs and IDE noise
|
||||
docs/
|
||||
@@ -37,8 +26,5 @@ docs/
|
||||
!.env.example
|
||||
|
||||
# CI workflow files don't need to ship in the image.
|
||||
#
|
||||
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea
|
||||
# Actions reads `.gitea/`, so the one directory that actually exists was the
|
||||
# one not excluded, and every workflow edit invalidated the context.
|
||||
.gitea/
|
||||
.forgejo/
|
||||
.github/
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
name: android
|
||||
|
||||
# Native Android (Kotlin/Compose/Media3) — M8 rewrite, now the only client.
|
||||
# This workflow is testing only — lint + detekt + unit tests on every push
|
||||
# to dev/main, plus a debug APK artifact for main. The signed-release
|
||||
# build + asset attach + image-bundling lives in release.yml under a
|
||||
# `needs:` chain so the docker image cannot ship without the APK.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'android/**'
|
||||
- '.gitea/workflows/android.yml'
|
||||
|
||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Silences the JDK 22+ "restricted method in java.lang.System has been
|
||||
# called" warning that Gradle 9.1's bundled native-platform jar trips
|
||||
# at launch (System.load for native primitives). Affects the LAUNCHER
|
||||
# JVM, not the daemon — that's why org.gradle.jvmargs in
|
||||
# gradle.properties isn't enough. Future-compat: required opt-in once
|
||||
# JDK 25 promotes the warning to an error.
|
||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build + lint + test
|
||||
# Using flutter-ci runner label because it's the only proven-working
|
||||
# label with docker that can pull our container.image. Switch to
|
||||
# android-ci once the operator registers that runner label.
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Gradle dirs
|
||||
# Resolved deps + Gradle distribution + Kotlin daemon caches.
|
||||
# Saves ~3 min per CI run after the first warm-up.
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.kotlin
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Gradle wrapper validation
|
||||
run: ./gradlew --version
|
||||
|
||||
- name: ktlint
|
||||
run: ./gradlew ktlintCheck
|
||||
|
||||
- name: detekt
|
||||
run: ./gradlew detekt
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload debug APK
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# serves artifacts back only through the v4 API, so the upload is stored
|
||||
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||
# See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
+67
-500
@@ -2,31 +2,11 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :vYYYY.MM.DD.HHMM and :latest (fresh APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# That is the whole tag map, and it is family rule 145 + 147 as written.
|
||||
#
|
||||
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable
|
||||
# without a release ceremony. It is minted only on main, where rollback is
|
||||
# actually worth having: merges are gated (rule 2) so they number in the dozens
|
||||
# per year, while on dev they would be one per push, forever, for a channel
|
||||
# whose entire contract is that it moves.
|
||||
#
|
||||
# There are NO :<version> image tags. This repo published :vYYYY.MM.DD.HHMM
|
||||
# until 2026-09-10 and it was the inverse of the rule on both counts — minting
|
||||
# a version tag nobody pinned while the rollback unit the rule names did not
|
||||
# exist here at all. Git and the build's own self-reported version answer
|
||||
# "which build is this"; a third name for the same thing is upkeep for a model
|
||||
# we do not run. Operator, 2026-09-10: "only things like the APK need that kind
|
||||
# of versioning for their update process."
|
||||
#
|
||||
# There is no :main either. :latest tracks main's tip with no gate between them
|
||||
# (rule 147), so a second name for the same image sends readers looking for a
|
||||
# distinction that does not exist.
|
||||
#
|
||||
# The dev channel exists so testing a build does not require shipping one.
|
||||
# Before it, the only way to get an APK onto a phone was to cut a release,
|
||||
# which made `main` the staging area by default. `:dev` carries its own
|
||||
@@ -72,8 +52,9 @@ name: release
|
||||
# Android APK and uploads it as a workflow artifact. The image-release
|
||||
# job declares `needs: android-release`, so the docker image cannot
|
||||
# start building until the APK is guaranteed-ready — no polling, no
|
||||
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||
# its own job (release-assets), behind the test gate below.
|
||||
# race, no silent-failure mode. Asset attachment to the gitea Release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
@@ -83,33 +64,8 @@ name: release
|
||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||
# keep bundling their own freshly-built APK.
|
||||
#
|
||||
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file —
|
||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||
# (npm audit, svelte-check, vitest), Android (ktlint, detekt, unit tests) and
|
||||
# govulncheck — and every job that publishes something names each of them in
|
||||
# `needs:` and requires `success` from each, by name. Nothing publishes on red.
|
||||
#
|
||||
# They used to be three separate workflows (test-go, test-web, android) on the
|
||||
# same push trigger as this one. Separate workflows cannot see each other's
|
||||
# verdict, so :dev meant "it built", never "it passed": a red test run and a
|
||||
# fresh :dev could carry the same timestamp. One graph is the only place the
|
||||
# edge can be written.
|
||||
#
|
||||
# A skipped lane is NOT a pass. The publishing conditions check
|
||||
# `result == 'success'` per lane rather than `!failure()`, so a lane that
|
||||
# never started blocks the publish exactly as a red one does. Lanes carry no
|
||||
# path filters for the same reason: a web-only push still runs the Go suite,
|
||||
# because "not run" must never read as "passed".
|
||||
#
|
||||
# What publishes, and is therefore gated: the image tags (image-release) and
|
||||
# the APK + version sidecar attached to a tag's Release (release-assets).
|
||||
# android-release only BUILDS the signed APK into a workflow artifact, which
|
||||
# nobody outside this run can pull, so it runs in parallel with the lanes
|
||||
# instead of after them; attaching it to the Release is the publishing half,
|
||||
# and that half waits for the gate.
|
||||
#
|
||||
# To watch the gate refuse: dispatch this workflow with force_red=true. The go
|
||||
# lane fails on purpose, and both publishing jobs must report skipped.
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -119,11 +75,6 @@ on:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_red:
|
||||
description: Fail the go lane on purpose, to check that nothing publishes on red
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# A rapid re-push to main should supersede the in-flight build — the
|
||||
# operator explicitly wants the later commit to win. Tags no longer enter
|
||||
@@ -134,269 +85,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------- lanes --
|
||||
# Verifying jobs. Each one is named in the `needs:` of every publishing job
|
||||
# below; add a lane here and it must be added there in the same commit.
|
||||
|
||||
go:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Forced failure (gate check)
|
||||
if: github.event.inputs.force_red == 'true'
|
||||
run: |
|
||||
echo "::error::force_red dispatch: failing on purpose so the publishing jobs must skip"
|
||||
exit 1
|
||||
|
||||
- name: Toolchain versions
|
||||
run: |
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: Generated code matches queries (sqlc)
|
||||
run: make verify-generate
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run ./...
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
|
||||
# Full `go test -race` against an ephemeral Postgres.
|
||||
#
|
||||
# DB wiring follows the act_runner shared-daemon pattern: the runner's Docker
|
||||
# daemon also runs the operator's dev compose stack, so service containers
|
||||
# get NO published ports (collision) and no service-name DNS. We discover the
|
||||
# service container through the mounted docker socket and reach it by bridge
|
||||
# IP. The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||
#
|
||||
# The key stays `integration` with no `name:` (rule 80): act_runner derives
|
||||
# the service container's name from the job's display name.
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without the SPA being built first.
|
||||
integration:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel_test
|
||||
# No `ports:` — the runner shares the operator's dev compose
|
||||
# Docker daemon; publishing a fixed host port collides.
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
# Discover THIS job's Postgres service container via the
|
||||
# mounted docker socket. act_runner attaches the job
|
||||
# container and its service container(s) to a shared per-job
|
||||
# network, so scope discovery to a postgres that sits on a
|
||||
# network THIS job container is also on. The old
|
||||
# `--filter name=integration` matched EVERY concurrent
|
||||
# integration run's postgres (a dev push + the main-merge run
|
||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||
# this job's network; skip it explicitly as belt-and-suspenders
|
||||
# (a wrong target would truncate real data).
|
||||
SELF=$(cat /etc/hostname)
|
||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||
test -n "$SELF_NETS"
|
||||
echo "self ($SELF) networks: $SELF_NETS"
|
||||
PG_ID=""
|
||||
PG_NAME=""
|
||||
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||
done
|
||||
done
|
||||
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||
echo "selected postgres: $PG_ID $PG_NAME"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||
test -n "$PG_IP"
|
||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||
|
||||
# Wait for Postgres to accept connections. Asked of the service
|
||||
# container itself: the run: shell is dash (rule 81), where the
|
||||
# old `/dev/tcp` probe never connects and the loop silently
|
||||
# burned its full two minutes on every run.
|
||||
ready=""
|
||||
for i in $(seq 1 60); do
|
||||
if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi
|
||||
sleep 2
|
||||
done
|
||||
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
|
||||
|
||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||
# via docker exec because:
|
||||
# - The act_runner `services:` block can't override the container
|
||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||
# auto-commits each statement, which is what we need.
|
||||
# - fsync / full_page_writes are sighup GUCs and
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()" \
|
||||
|| echo "WARN: durability relax failed; continuing"
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
# -timeout 20m: internal/api alone takes ~6.5 min under -race on
|
||||
# an idle runner, and a dev and a main run sharing the runner
|
||||
# pushed it past go test's default 10m (run 8368, 600.016s, the
|
||||
# running test 2s old — nothing hung).
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race -timeout 20m ./...
|
||||
|
||||
web:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
# What ships to browsers: `dependencies` and the runtime they pull in
|
||||
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
||||
# kit's dev server) is left out because none of it reaches a user, and
|
||||
# its open advisories need major-version upgrades tracked separately.
|
||||
- name: npm audit (shipped dependencies)
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
|
||||
- name: Type-check + svelte-check
|
||||
run: npm run check
|
||||
|
||||
- name: Vitest
|
||||
run: npm test
|
||||
|
||||
android:
|
||||
# Using flutter-ci runner label because it's the only proven-working
|
||||
# label with docker that can pull our container.image.
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android
|
||||
|
||||
env:
|
||||
# Silences the JDK 22+ "restricted method in java.lang.System has been
|
||||
# called" warning that Gradle's bundled native-platform jar trips at
|
||||
# launch. Affects the LAUNCHER JVM, not the daemon — that's why
|
||||
# org.gradle.jvmargs in gradle.properties isn't enough.
|
||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Gradle dirs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
~/.kotlin
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Gradle wrapper validation
|
||||
run: ./gradlew --version
|
||||
|
||||
- name: ktlint
|
||||
run: ./gradlew ktlintCheck
|
||||
|
||||
- name: detekt
|
||||
run: ./gradlew detekt
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
# No debug APK is built or uploaded here. Main used to upload a
|
||||
# debug-signed app-debug.apk: a build signed by a key regenerated in
|
||||
# every container, which no install can update (family idea #5103,
|
||||
# practice 2). Phones get builds from android-release, signed with
|
||||
# the one release key, on dev and on tags.
|
||||
|
||||
# Known vulnerabilities in the Go code and the standard library it is built
|
||||
# with. Runs in the SAME image the Dockerfile's builder stage uses, so the
|
||||
# standard library it checks is the one that ends up in the shipped binary;
|
||||
# the ci-go image carries its own Go and would be checking a different
|
||||
# toolchain. Keep this image and the Dockerfile's builder in step.
|
||||
#
|
||||
# govulncheck is fetched at CI time, unpinned (rule 154): the vulnerability
|
||||
# database and the tool that reads it should both be current.
|
||||
govulncheck:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: golang:1.26-bookworm
|
||||
|
||||
steps:
|
||||
# Plain git, not actions/checkout: that action runs on node, which the
|
||||
# golang image does not carry. This step is dash (rule 81).
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)
|
||||
git init -q .
|
||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||
git -c http.extraHeader="Authorization: Basic ${auth}" fetch -q --depth 1 origin "${{ github.sha }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
git log -1 --format='%H %s'
|
||||
|
||||
- name: govulncheck
|
||||
run: |
|
||||
go version
|
||||
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
|
||||
# ------------------------------------------------------------ artifacts --
|
||||
|
||||
android-release:
|
||||
name: Build signed APK (releases and dev)
|
||||
# Also builds on `dev`, which is what makes a test channel possible at
|
||||
@@ -523,49 +211,14 @@ jobs:
|
||||
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
|
||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||
|
||||
# The APK every phone updates from must carry THE release key: Android
|
||||
# updates an app in place only when the signer matches, so an APK
|
||||
# signed by any other key (debug, a regenerated keystore, a swapped
|
||||
# secret) reaches no installed phone. The certificate's digest is
|
||||
# pinned below; it is public, not a secret. Gradle signs with the
|
||||
# release key or leaves the APK unsigned, and an unsigned build fails
|
||||
# here too, as there is no app-release.apk to verify (family idea
|
||||
# #5103, practice 3). apksigner, not keytool: keytool prints nothing
|
||||
# for a v2-only APK.
|
||||
#
|
||||
# Rotating the key on purpose means every install must be removed and
|
||||
# reinstalled; change the digest here in the same commit.
|
||||
- name: The APK carries the release key
|
||||
shell: bash
|
||||
env:
|
||||
# CN=Minstrel, O=FabledSword. Read from run 8446 (#5116).
|
||||
RELEASE_CERT_SHA256: 43d183307bc46b821789d90444a960b137f78f2166ff431efb2406d0fceaf612
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
signer="$(ls "$sdk"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1 || true)"
|
||||
test -n "$signer" || { echo "::error::no apksigner under '$sdk/build-tools'"; exit 1; }
|
||||
certs="$("$signer" verify --print-certs app/build/outputs/apk/release/app-release.apk)"
|
||||
printf '%s\n' "$certs" | grep -E '^Signer #[0-9]+ certificate (DN|SHA-256 digest)'
|
||||
# One signer, and it is ours. A second signer would be a lineage or
|
||||
# a mistake; either way not something to ship unexamined.
|
||||
digests="$(printf '%s\n' "$certs" | sed -n 's/^Signer #[0-9]* certificate SHA-256 digest: //p')"
|
||||
if [ "$digests" != "$RELEASE_CERT_SHA256" ]; then
|
||||
if printf '%s' "$certs" | grep -q 'CN=Android Debug'; then
|
||||
echo "::error::the release APK is signed with a debug key"
|
||||
else
|
||||
echo "::error::the release APK is not signed by the release key: got '${digests//$'\n'/ }', want ${RELEASE_CERT_SHA256}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# Stock action (snippet #2271) — never @v3, which uploads something Gitea
|
||||
# will never serve back. This is the producing half of a pair:
|
||||
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with
|
||||
# any download v4+ on this forge (every combination tested 2026-09-10,
|
||||
# Scribe spike #3843), so the two pins need not move together.
|
||||
uses: actions/upload-artifact@v7
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||
# below with the matching download-artifact mirror. Both must stay on
|
||||
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: android/app/build/outputs/apk/release/app-release.apk
|
||||
@@ -573,47 +226,21 @@ jobs:
|
||||
# artifact existing, so an empty upload must fail here, not there.
|
||||
if-no-files-found: error
|
||||
|
||||
# Publishes the signed APK and its version sidecar on the tag's Release.
|
||||
# Split out of android-release so the APK can be BUILT in parallel with the
|
||||
# lanes while being PUBLISHED only once they have all passed.
|
||||
release-assets:
|
||||
name: Attach APK to the Release (tag releases only)
|
||||
needs: [go, integration, web, android, govulncheck, android-release]
|
||||
if: >-
|
||||
${{
|
||||
!cancelled()
|
||||
&& needs.go.result == 'success'
|
||||
&& needs.integration.result == 'success'
|
||||
&& needs.web.result == 'success'
|
||||
&& needs.android.result == 'success'
|
||||
&& needs.govulncheck.result == 'success'
|
||||
&& needs.android-release.result == 'success'
|
||||
&& startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Download signed APK artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: release-apk/
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
# Tag releases only. A dev build has no Release to hang assets on and
|
||||
# does not need one — the :dev image bundles the APK, and the server
|
||||
# serves it from /api/client/apk like any other.
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
|
||||
VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
|
||||
VERSION_NAME: ${{ steps.ver.outputs.name }}
|
||||
VERSION_CODE: ${{ steps.ver.outputs.code }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
APK_PATH="release-apk/app-release.apk"
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
ls -lh "${APK_PATH}"
|
||||
|
||||
# Publish the version sidecar as a release asset next to the APK.
|
||||
@@ -667,21 +294,13 @@ jobs:
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# Every lane must have SUCCEEDED, each named here (rule 177). Then the
|
||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||
# pushes skip android-release and bundle the latest release's APK
|
||||
# instead, so for main alone a skipped android-release is expected.
|
||||
needs: [go, integration, web, android, govulncheck, android-release]
|
||||
if: >-
|
||||
${{
|
||||
!cancelled()
|
||||
&& needs.go.result == 'success'
|
||||
&& needs.integration.result == 'success'
|
||||
&& needs.web.result == 'success'
|
||||
&& needs.android.result == 'success'
|
||||
&& needs.govulncheck.result == 'success'
|
||||
&& (needs.android-release.result == 'success'
|
||||
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
||||
# `needs:` waits for android-release. For tag pushes android-release
|
||||
# runs and must succeed before this job starts — guaranteeing the
|
||||
# APK artifact is present. For main pushes android-release is
|
||||
# skipped; the `if: ...` below lets this job run anyway and the
|
||||
# download/copy steps gate themselves on the tag context.
|
||||
needs: [android-release]
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
@@ -693,16 +312,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history, and rule 149 names this specifically: any job that
|
||||
# DERIVES the version name needs it, because a shallow clone changes
|
||||
# what git-derived values resolve to WITHOUT failing — a too-low
|
||||
# value, silently, with every lane green.
|
||||
#
|
||||
# This job was depth-1 while it took the version from GITHUB_REF. It
|
||||
# now runs ci/version.sh itself, because with :<version> image tags
|
||||
# gone the server's self-reported version is the only thing that says
|
||||
# which build an image is.
|
||||
fetch-depth: 0
|
||||
# Shallow is fine here. This job used to need full history + tags to
|
||||
# re-derive the bundled APK's version from the tagged commit; it now
|
||||
# downloads the sidecar the release recorded, and touches git for
|
||||
# nothing. MINSTREL_VERSION comes from GITHUB_REF, not from git.
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -720,68 +334,30 @@ jobs:
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# THE VERSION, and it is derived the same way on every ref — the
|
||||
# branch decides the CHANNEL, never the version (family rule 149).
|
||||
#
|
||||
# This used to be three different things: the literal string "main"
|
||||
# on main, "dev" on dev, and the tag name on a tag. None of them
|
||||
# ordered, and the first two were the same string forever — two dev
|
||||
# images eight weeks apart were indistinguishable in the UI. That
|
||||
# mattered little while :vYYYY.MM.DD.HHMM existed to identify a
|
||||
# build; with version image tags gone, this IS how an operator tells
|
||||
# which build a container is running.
|
||||
#
|
||||
# `sed -n s///p` rather than `grep`: it exits 0 when nothing matches,
|
||||
# so the empty check below is actually reachable. A grep here would
|
||||
# kill the step at the assignment under the runner's pipefail — the
|
||||
# exact bug that took down the first main build after the version
|
||||
# rework.
|
||||
VERSION="$(ci/version.sh HEAD | sed -n 's/^name=//p')"
|
||||
if [ -z "${VERSION}" ]; then
|
||||
echo "::error::could not derive a build version from ci/version.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||
# A release refreshes the CHANNEL and mints nothing else.
|
||||
#
|
||||
# The tag build exists to produce the signed APK and attach it to
|
||||
# the release; the image it rebuilds is the SAME SOURCE as the main
|
||||
# build minutes earlier, differing only in which APK is baked in.
|
||||
# Rule 145 is explicit about that case: when the same source is
|
||||
# rebuilt with different contents, publish the moving channel tag
|
||||
# and never a commit-addressable one.
|
||||
#
|
||||
# :latest must move here rather than waiting for the next main
|
||||
# push, or the channel would carry the PREVIOUS release's APK
|
||||
# indefinitely — a channel that cannot refresh itself (rule 146).
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build: ${VERSION} + latest"
|
||||
elif [[ "${GITHUB_REF}" == "refs/heads/dev" ]]; then
|
||||
# The rolling test channel, and :dev ALONE — deliberately no
|
||||
# per-commit tag. A rolling channel is rolling by definition, so a
|
||||
# commit-addressable image here would be a rollback target nobody
|
||||
# has ever pulled, accumulating in the registry forever. Recovery
|
||||
# on dev is to fix forward.
|
||||
CHANNEL=dev
|
||||
echo "args=-t ${IMAGE}:dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build ${VERSION}: :dev"
|
||||
echo "version=dev" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Dev-branch build: :dev"
|
||||
else
|
||||
# The production line: :latest tracks main's tip (rule 147) and
|
||||
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching
|
||||
# the family's other repos, so a rollback target is addressable
|
||||
# straight from the commit anyone is reading.
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}"
|
||||
# Main is the protected, post-PR-merge branch. Treat it as the
|
||||
# rolling stable channel — every main push moves :latest.
|
||||
# Pinned consumers can target :vYYYY.MM.DD.HHMM, which never
|
||||
# moves; everyone else gets the newest main.
|
||||
echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "version=main" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Main-branch build: :main + :latest"
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Registry login
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
@@ -796,11 +372,20 @@ jobs:
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||
# out of the bundle; snippet #2271). v8 runs on node24, which every
|
||||
# CI-runner image carries — the runner uses the image's own node.
|
||||
uses: actions/download-artifact@v8
|
||||
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||
# same reason: upstream's client-side GHES check rejects this hostname
|
||||
# before it connects. bvandeusen/download-artifact mirrors
|
||||
# code.forgejo.org/forgejo/download-artifact.
|
||||
#
|
||||
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||
# the action's own version number — the two actions release on unrelated
|
||||
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||
# the same one proven against this instance by the upload side.
|
||||
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||
with:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
@@ -890,15 +475,9 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||
# debian:bookworm-slim). Without it the runner's daemon reuses
|
||||
# whatever it cached, and the shipped binary can sit on a Go patch
|
||||
# release govulncheck already flagged while the lane, which pulls
|
||||
# fresh, reports clean.
|
||||
run: |
|
||||
docker buildx build --pull \
|
||||
docker buildx build \
|
||||
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
|
||||
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
|
||||
--push ${{ steps.tags.outputs.args }} .
|
||||
|
||||
# Verifies a tag release actually ended up complete, and names the specific
|
||||
@@ -909,8 +488,8 @@ jobs:
|
||||
# `failure` with none executed and image-release showed `skipped`. The run was
|
||||
# red, but the *release page rendered fine*, and `main`'s own push build had
|
||||
# already moved `:latest`, so the code was deployable and nothing looked
|
||||
# obviously wrong. The release was simply missing its APK and its image,
|
||||
# which is easy to skim past.
|
||||
# obviously wrong. The release was simply missing its APK and its immutable
|
||||
# `:vYYYY.MM.DD` image, which is easy to skim past.
|
||||
#
|
||||
# This job cannot prevent that (the cause was a runner failing to launch, not
|
||||
# anything in this file). What it does is turn an incomplete release into an
|
||||
@@ -921,7 +500,7 @@ jobs:
|
||||
# above did NOT succeed.
|
||||
verify-release:
|
||||
name: Verify release artifacts (tag releases only)
|
||||
needs: [android-release, release-assets, image-release]
|
||||
needs: [android-release, image-release]
|
||||
if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
@@ -961,30 +540,18 @@ jobs:
|
||||
# missing when v2026.08.07 had to be re-cut. `always()` on this job means
|
||||
# it runs even when image-release failed, so without this the guard would
|
||||
# cheerfully verify an incomplete release.
|
||||
#
|
||||
# This asserted `:${TAG}` — the :vYYYY.MM.DD.HHMM image — until
|
||||
# 2026-09-10. Version image tags are no longer published (rule 145), so
|
||||
# that assertion would now fail every release for a tag nothing mints.
|
||||
# The rollback target it was really protecting is the :<sha> image, which
|
||||
# main's own build published for this same commit before the tag was cut.
|
||||
#
|
||||
# Checking it here earns its keep twice over: it still catches an image
|
||||
# push that silently did not happen, and it additionally proves the
|
||||
# ORDERING — a tag cut on a commit whose main build never completed has
|
||||
# no rollback target, and that is worth failing on rather than
|
||||
# discovering during an incident.
|
||||
- name: Rollback image must exist for the tagged commit
|
||||
- name: Immutable image tag must exist
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
IMAGE="git.fabledsword.com/bvandeusen/minstrel"
|
||||
|
||||
echo "${{ secrets.CI_TOKEN }}" \
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target."
|
||||
echo "::error::That image is published by the MAIN build of this commit, not by the tag build. If main's build never ran or failed, fix that first; a release whose commit cannot be rolled back to is the thing this check exists to refuse."
|
||||
if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
|
||||
echo "::error::image ${IMAGE}:${TAG} was never pushed — the release tag has no immutable image, so there is nothing to pin or roll back to. Re-run this workflow run."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::rollback target verified: ${IMAGE}:${GITHUB_SHA}"
|
||||
echo "::notice::image verified: ${IMAGE}:${TAG}"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
name: test-go
|
||||
|
||||
# Go server: vet + golangci-lint + short race tests. Runs on push to
|
||||
# dev/main and PRs to main, scoped to Go-side files only — web-only or
|
||||
# Flutter-only diffs don't trigger this workflow.
|
||||
#
|
||||
# Two jobs: `test` (fast — vet + lint + `go test -short -race`, no DB) and
|
||||
# `integration` (full `go test -race` against an ephemeral Postgres).
|
||||
#
|
||||
# Integration-job DB wiring follows the act_runner shared-daemon pattern:
|
||||
# the runner's Docker daemon also runs the operator's dev compose stack,
|
||||
# so service containers get NO published ports (collision) and no
|
||||
# service-name DNS. We discover the service container by the job-scoped
|
||||
# name filter via the mounted docker socket and reach it by bridge IP.
|
||||
# The exactly-one assertion is a hard guard — pointing tests at the dev
|
||||
# Postgres would truncate it (the disaster Fable #339 exists to prevent).
|
||||
#
|
||||
# `web/build/` has a committed placeholder index.html so go:embed succeeds
|
||||
# without needing the SPA to be freshly built. Real builds happen in
|
||||
# release.yml (container) and locally during dev.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'Makefile'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
- '.gitea/workflows/test-go.yml'
|
||||
# The release lane's own trigger is `main` + tags, so nothing it
|
||||
# contains is exercised until a release is already running. These two
|
||||
# entries are what let internal/server/release_version_test.go guard
|
||||
# the version derivation on ordinary dev pushes instead.
|
||||
- 'ci/**'
|
||||
- '.gitea/workflows/release.yml'
|
||||
|
||||
# pull_request trigger intentionally omitted — see test-web.yml for
|
||||
# the rationale (single-author repo, push covers PR-merge equivalent).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Toolchain versions
|
||||
run: |
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: Generated code matches queries (sqlc)
|
||||
run: make verify-generate
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run ./...
|
||||
|
||||
- name: go test (short, race)
|
||||
run: go test -short -race ./...
|
||||
|
||||
integration:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel_test
|
||||
# No `ports:` — the runner shares the operator's dev compose
|
||||
# Docker daemon; publishing a fixed host port collides.
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Integration suite (discover service by bridge IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
# Discover THIS job's Postgres service container via the
|
||||
# mounted docker socket. act_runner attaches the job
|
||||
# container and its service container(s) to a shared per-job
|
||||
# network, so scope discovery to a postgres that sits on a
|
||||
# network THIS job container is also on. The old
|
||||
# `--filter name=integration` matched EVERY concurrent
|
||||
# integration run's postgres (a dev push + the main-merge run
|
||||
# overlap → 2 candidates → false "expected exactly 1" abort).
|
||||
# The operator's dev compose `minstrel-postgres-*` is never on
|
||||
# this job's network; skip it explicitly as belt-and-suspenders
|
||||
# (a wrong target would truncate real data).
|
||||
SELF=$(cat /etc/hostname)
|
||||
SELF_NETS=$(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$SELF")
|
||||
test -n "$SELF_NETS"
|
||||
echo "self ($SELF) networks: $SELF_NETS"
|
||||
PG_ID=""
|
||||
PG_NAME=""
|
||||
for cid in $(docker ps --filter "ancestor=postgres:16-alpine" -q); do
|
||||
nm=$(docker inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
|
||||
case "$nm" in *minstrel-postgres*|*_postgres_*) continue ;; esac
|
||||
for net in $(docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$cid"); do
|
||||
case " $SELF_NETS " in *" $net "*) PG_ID="$cid"; PG_NAME="$nm"; break 2 ;; esac
|
||||
done
|
||||
done
|
||||
test -n "$PG_ID" || { echo "FATAL: no postgres service container on this job's network (self nets: $SELF_NETS)"; exit 1; }
|
||||
echo "selected postgres: $PG_ID $PG_NAME"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG_ID")
|
||||
test -n "$PG_IP"
|
||||
export MINSTREL_TEST_DATABASE_URL="postgres://minstrel:minstrel@${PG_IP}:5432/minstrel_test?sslmode=disable"
|
||||
|
||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||
|
||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||
# via docker exec because:
|
||||
# - The act_runner `services:` block can't override the container
|
||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||
# auto-commits each statement, which is what we need.
|
||||
# - fsync / full_page_writes are sighup GUCs and
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()" \
|
||||
|| echo "WARN: durability relax failed; continuing"
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
# concurrent package binaries → TRUNCATE deadlocks. Serialize
|
||||
# package execution (the documented local invocation too).
|
||||
MINSTREL_DATABASE_URL="$MINSTREL_TEST_DATABASE_URL" go run ./cmd/minstrel migrate
|
||||
go test -p 1 -race ./...
|
||||
@@ -0,0 +1,44 @@
|
||||
name: test-web
|
||||
|
||||
# Web SPA: vitest + svelte-check. Runs on push to dev/main only —
|
||||
# the `pull_request` trigger is intentionally omitted because every
|
||||
# branch on this repo is local-only (no fork PRs), so the dev push
|
||||
# fully covers what a PR run would re-execute. Keeping both events
|
||||
# doubled CI cost on every commit.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- 'web/**'
|
||||
- '.gitea/workflows/test-web.yml'
|
||||
|
||||
# Cancel an earlier in-flight run for the same ref when a newer
|
||||
# commit arrives. With cancel-in-progress, rapid re-pushes don't
|
||||
# pile up zombie runs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check + svelte-check
|
||||
run: npm run check
|
||||
|
||||
- name: Vitest
|
||||
run: npm test
|
||||
@@ -12,11 +12,6 @@
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# `make build` output. bin/minstrel was tracked until 2026-09-10 — an 18 MB
|
||||
# binary committed by accident, last refreshed by a commit about web test
|
||||
# mocks, and re-dirtied by every local build since.
|
||||
bin/
|
||||
|
||||
# Bundled Android APK + version sidecar (#397). Populated by CI for
|
||||
# tag releases; never committed. README in client/ explains the flow.
|
||||
client/minstrel.apk
|
||||
|
||||
+6
-21
@@ -7,7 +7,7 @@ RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.26-bookworm AS builder
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
@@ -15,32 +15,17 @@ COPY . .
|
||||
# Overwrite the committed placeholder with the freshly-built SPA assets.
|
||||
COPY --from=web /web/build ./web/build
|
||||
ENV CGO_ENABLED=0
|
||||
# Version stamping. release.yml passes the DERIVED version name
|
||||
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back
|
||||
# to "dev"/"local". Both are surfaced at /healthz.
|
||||
#
|
||||
# These are two values on purpose (family rule 149): the same commit built on
|
||||
# dev and on main reports the same NAME and differs only in CHANNEL. Folding
|
||||
# the channel into the version string is what the rule forbids — the version
|
||||
# used to BE the channel word here ("main"/"dev"), which meant two dev images
|
||||
# eight weeks apart were indistinguishable.
|
||||
# Version stamping: release.yml passes the git tag via MINSTREL_VERSION
|
||||
# build-arg; local `docker build` falls back to "dev". Surfaced at
|
||||
# /healthz for operator-side image-version verification.
|
||||
ARG MINSTREL_VERSION=dev
|
||||
ARG MINSTREL_CHANNEL=local
|
||||
RUN go build -trimpath \
|
||||
-ldflags="-s -w \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
|
||||
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
|
||||
-ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
|
||||
-o /out/minstrel ./cmd/minstrel
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
# ffmpeg: duration probes and the exact-tier audio hash (a SHA-256 of the
|
||||
# encoded audio packets, so no decode). libchromaprint-tools: fpcalc, the
|
||||
# acoustic fingerprint that tells the same recording at two bitrates apart
|
||||
# from two different recordings (M400). Both are baked in at build time so a
|
||||
# deployed instance never fetches either (rule 164); fpcalc is shelled out
|
||||
# rather than bound because CGO_ENABLED=0 above rules out cgo.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg libchromaprint-tools \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system --gid 1000 minstrel \
|
||||
|
||||
@@ -34,18 +34,11 @@ Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz
|
||||
services:
|
||||
minstrel:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
# Reachable from your LAN at http://<host>:4533. If this host faces the
|
||||
# internet, bind it to 127.0.0.1 and put an HTTPS proxy in front instead:
|
||||
# see docs/hosting.md.
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Writable, because Minstrel deletes a file when an admin asks
|
||||
# it to (for example, quarantine's "Delete file"). It never moves,
|
||||
# renames or retags anything. The container runs as uid 1000, so that
|
||||
# user needs write access to the folders. Mount it :ro to forbid even
|
||||
# deletes: those actions then refuse, say why, and delete nothing.
|
||||
- ./music:/music
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
@@ -54,7 +47,7 @@ services:
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the music mount above (/music here).
|
||||
# path of the read-only music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -79,9 +72,9 @@ docker compose up -d
|
||||
|
||||
## First run
|
||||
|
||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address. Plain `http://` is fine on a network you trust; a server reachable from the internet belongs behind HTTPS, which [docs/hosting.md](docs/hosting.md) walks through.
|
||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
||||
|
||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance becomes the administrator, and creating it asks for the **setup token** the server prints in its log (`docker compose logs minstrel | grep setup_token`), so nobody else can claim a newly exposed server first. Later users join through the same form or an invite token (step 5).
|
||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
||||
|
||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||
|
||||
@@ -103,8 +96,6 @@ With the stack up, a handful of in-app steps get you to a working library. Use y
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
Hosting Minstrel on the internet: see [docs/hosting.md](docs/hosting.md). What Minstrel does to protect accounts, and why: [docs/security.md](docs/security.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||
@@ -121,21 +112,18 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — production. Tracks `main`'s tip and moves on every `main` push and every release. What most operators should run.
|
||||
- `:<commit-sha>` — the rollback unit. Every `main` push publishes one, so any production commit is addressable without a release ceremony. Immutable: a given SHA tag is never re-pushed. Pin one if you need a deployment that cannot change under you, and use it to roll back.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this to try something before it ships. It moves constantly, has no per-commit tag, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD.HHMM` — immutable release tags, never moved or deleted. Pin one for a deployment you don't want changing under you. The tag is the build's own version name with a `v` in front, derived from the tagged commit's UTC timestamp, so two releases can never collide and a re-cut is simply a new tag.
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
- `:dev` — the rolling test channel, rebuilt on every push to `dev` and carrying its own freshly-built Android APK. Run this when you want to try something before it ships. It moves constantly, has no per-commit tag to pin, and its only recovery path is forward — if a `:dev` image is broken, the fix is the next push, not a rollback.
|
||||
|
||||
That is the whole tag map. **There are no version-numbered image tags**, and no `:main`. Git and the build's own self-reported version answer "which build is this" — the Settings page shows it, and so does `/healthz`. Release *tags* in git are still `vYYYY.MM.DD.HHMM`; they name a changelog entry and the APK attached to it, not an image.
|
||||
|
||||
Rolling back to `:<commit-sha>` pins the **server code** at that commit — not the server-and-app pair. The Android APK is baked in at image build time, so a SHA image carries whichever app was current when that commit was built, which may be older than what `:latest` bundles now. If both halves matter, check what the image bundles rather than trusting the tag's name.
|
||||
|
||||
Every `:latest`, `:<commit-sha>` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||
Every `:latest`, `:vYYYY.MM.DD.HHMM` and `:dev` bundles a signed Android APK, so the in-app update channel is always live. All of them are signed with the same key, so a phone can move between the stable and dev channels without uninstalling — point it at a `:dev` server and the in-app updater offers that channel's build.
|
||||
|
||||
The app reports which channel it is on alongside its version, and decides whether an update is available using the build's ordering key rather than its displayed name — the same value Android installs by, so an offer it makes is one the platform will accept.
|
||||
|
||||
Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
Releases up to 2026-09-10 also published a `:vYYYY.MM.DD[.HHMM]` image tag. Those images still exist and still work — they are simply not extended.
|
||||
Releases before 2026-09-10 use the older per-day `:vYYYY.MM.DD` shape. Those tags still exist and still work — they are simply not extended.
|
||||
|
||||
## Specs
|
||||
|
||||
@@ -159,8 +147,7 @@ Two concurrent dev processes:
|
||||
truncates your dev `minstrel` data (admin user, library, likes). It
|
||||
brings up the compose Postgres and creates the test DB if missing.
|
||||
- CI runs both: a fast `go test -short -race` gate plus an integration
|
||||
job with its own ephemeral Postgres (the `integration` lane in
|
||||
`.gitea/workflows/release.yml`, which also gates every image publish).
|
||||
job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
|
||||
|
||||
### Production build
|
||||
|
||||
@@ -170,7 +157,7 @@ Two concurrent dev processes:
|
||||
|
||||
- Day-to-day work happens on `dev` (or feature branches merged into `dev`).
|
||||
- `main` is **protected** — changes land via PR from `dev`.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds the signed APK, attaches it to the release, and refreshes `:latest` around it.
|
||||
- Releases are cut by tagging `v*` off `main`; the release workflow builds and pushes the container image to the Gitea registry.
|
||||
|
||||
Task and milestone tracking: Fable (`Minstrel` project, id 12).
|
||||
|
||||
|
||||
@@ -72,13 +72,9 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Signed with the release key or not at all. Falling back to the
|
||||
// debug key made a missing secret into a published APK that no
|
||||
// install could ever update (family idea #5103, practice 2). An
|
||||
// unsigned build installs nowhere, so the gap shows at once.
|
||||
signingConfig =
|
||||
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
|
||||
null
|
||||
signingConfigs.getByName("debug")
|
||||
} else {
|
||||
signingConfigs.getByName("release")
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Notifications when the app is closed (M489 #5347): the delivery
|
||||
service, and starting it again after a reboot or an update. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||
is what lets that install happen with NO confirm dialog. The platform
|
||||
@@ -61,29 +57,6 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Keeps the process alive so notifications arrive with the app
|
||||
closed. specialUse: dataSync is stopped after six hours on
|
||||
Android 15, and shortService after three minutes. -->
|
||||
<service
|
||||
android:name=".notifications.delivery.DeliveryService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the connection to the user's own Minstrel server that delivers their notifications, in place of a third-party push service." />
|
||||
</service>
|
||||
|
||||
<!-- Starts delivery after a reboot or an update; both broadcasts may
|
||||
start a foreground service from the background. -->
|
||||
<receiver
|
||||
android:name=".notifications.delivery.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- The FileProvider that used to live here existed solely to expose the
|
||||
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||
install intent. A PackageInstaller session takes a stream instead,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package com.fabledsword.minstrel
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -20,12 +16,9 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
@@ -34,10 +27,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
import com.fabledsword.minstrel.nav.Notifications
|
||||
import com.fabledsword.minstrel.nav.NowPlaying
|
||||
import com.fabledsword.minstrel.notifications.delivery.deliveryWanted
|
||||
import com.fabledsword.minstrel.notifications.ui.routeForLink
|
||||
import com.fabledsword.minstrel.shared.widgets.LocalCachedTrackIds
|
||||
import com.fabledsword.minstrel.theme.MinstrelTheme
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
@@ -45,10 +35,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -56,72 +42,41 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
@Inject lateinit var authStore: AuthStore
|
||||
|
||||
// Set when the user taps a notification: the media one asks for the full
|
||||
// player, a Minstrel notice for what it is about. The App composable
|
||||
// navigates there once the NavHost is ready, then calls back to clear it
|
||||
// so the navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingRoute = MutableStateFlow<Any?>(null)
|
||||
|
||||
// The answer needs no handling: the system remembers it, and the
|
||||
// notification settings screen reads it on every resume.
|
||||
private val askToNotify = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
// composable observes this, navigates to NowPlaying once the
|
||||
// NavHost is ready, then calls back to reset the flag so the
|
||||
// navigation doesn't re-fire on the next recomposition.
|
||||
private val pendingOpenNowPlaying = MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
consumeRouteIntent(intent)
|
||||
askToNotifyOnceWanted()
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
setContent {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
serverHealth = serverHealth,
|
||||
pendingRoute = pendingRoute.asStateFlow(),
|
||||
onOpenedRoute = { pendingRoute.value = null },
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
consumeRouteIntent(intent)
|
||||
consumeOpenNowPlayingIntent(intent)
|
||||
}
|
||||
|
||||
private fun consumeRouteIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) {
|
||||
pendingRoute.value = NowPlaying
|
||||
private fun consumeOpenNowPlayingIntent(intent: Intent?) {
|
||||
if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
|
||||
pendingOpenNowPlaying.value = true
|
||||
// Strip the extra so a subsequent config-change recreation
|
||||
// doesn't re-trigger the navigation.
|
||||
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING)
|
||||
}
|
||||
intent.getStringExtra(EXTRA_NOTIFICATION_LINK)?.let { link ->
|
||||
// A notice the app has no screen for, or a pile of them, opens
|
||||
// the inbox.
|
||||
pendingRoute.value = routeForLink(link) ?: Notifications
|
||||
intent.removeExtra(EXTRA_NOTIFICATION_LINK)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android 13+ asks before an app may post notifications (M489 #5347).
|
||||
* Asked once delivery is wanted (signed in, background delivery on),
|
||||
* each launch until answered: after a second "no" the system stops
|
||||
* showing the prompt by itself, and Settings → Notifications links to
|
||||
* the system page.
|
||||
*/
|
||||
private fun askToNotifyOnceWanted() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
lifecycleScope.launch {
|
||||
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }
|
||||
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted).first { it }
|
||||
val permission = Manifest.permission.POST_NOTIFICATIONS
|
||||
val granted = ContextCompat.checkSelfPermission(this@MainActivity, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) askToNotify.launch(permission)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -129,10 +84,6 @@ class MainActivity : ComponentActivity() {
|
||||
* so a media-notification tap lands on the full NowPlaying screen
|
||||
* instead of whatever shell route MainActivity last rendered. */
|
||||
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING"
|
||||
|
||||
/** PendingIntent extra on a Minstrel notice: the web path it links to,
|
||||
* or empty for a pile, which opens the inbox. */
|
||||
const val EXTRA_NOTIFICATION_LINK = "com.fabledsword.minstrel.action.NOTIFICATION_LINK"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,15 +92,15 @@ private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingRoute: StateFlow<Any?>,
|
||||
onOpenedRoute: () -> Unit,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
gate: AuthGateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||
val pending by pendingRoute.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
@@ -168,14 +119,16 @@ private fun App(
|
||||
// Queue / unauthenticated) bypass the shell entirely.
|
||||
val navController = rememberNavController()
|
||||
// Honour a pending notification-tap once the NavHost is
|
||||
// mounted. launchSingleTop avoids stacking copies of a
|
||||
// screen if the user taps the notification while already
|
||||
// on it; the callback clears it so a later recomposition
|
||||
// (config change, theme switch) doesn't re-navigate.
|
||||
// mounted. launchSingleTop avoids stacking copies of
|
||||
// NowPlaying if the user taps the notification while
|
||||
// already on it; the callback clears the flag so a later
|
||||
// recomposition (config change, theme switch) doesn't
|
||||
// re-navigate.
|
||||
LaunchedEffect(pending, navController) {
|
||||
val route = pending ?: return@LaunchedEffect
|
||||
navController.navigate(route) { launchSingleTop = true }
|
||||
onOpenedRoute()
|
||||
if (pending) {
|
||||
navController.navigate(NowPlaying) { launchSingleTop = true }
|
||||
onOpenedNowPlaying()
|
||||
}
|
||||
}
|
||||
MinstrelNavGraph(
|
||||
navController = navController,
|
||||
|
||||
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.events.LiveEventsDispatcher
|
||||
import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
@@ -76,14 +75,6 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
|
||||
* the background-delivery service and runs the notification catch-up on
|
||||
* every nudge and reconnect (M489 #5347). Without this @Inject no phone
|
||||
* notification would ever be posted.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — PlayEventsReporter's init
|
||||
* block subscribes to PlayerController.uiState and reports the
|
||||
|
||||
@@ -15,14 +15,10 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -46,12 +42,6 @@ fun AdminQuarantineScreen(
|
||||
viewModel: AdminQuarantineViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.transientMessages.collect { msg ->
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -63,7 +53,6 @@ fun AdminQuarantineScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { inner ->
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
|
||||
+1
-14
@@ -10,13 +10,10 @@ import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -37,15 +34,6 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow()
|
||||
|
||||
/**
|
||||
* One-shot messages for the screen's snackbar. A failed action has to say
|
||||
* why: the row quietly reappearing reads as a glitch, and for a Delete
|
||||
* file refused by a read-only library it hides the one thing the
|
||||
* operator can fix (#3918).
|
||||
*/
|
||||
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
|
||||
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
viewModelScope.launch {
|
||||
@@ -98,9 +86,8 @@ class AdminQuarantineViewModel @Inject constructor(
|
||||
try {
|
||||
action(trackId)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,14 +50,7 @@ class BaseUrlInterceptor @Inject constructor(
|
||||
.port(baseUrl.port)
|
||||
.build()
|
||||
} ?: original.url
|
||||
return chain.proceed(
|
||||
original.newBuilder()
|
||||
.url(rewritten)
|
||||
// Lets CleartextGuardInterceptor tell server requests from
|
||||
// external fetches once the placeholder host is gone.
|
||||
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||
.build(),
|
||||
)
|
||||
return chain.proceed(original.newBuilder().url(rewritten).build())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
|
||||
/**
|
||||
* Marks a request as bound for the Minstrel server, set by
|
||||
* [BaseUrlInterceptor] when it retargets the placeholder host. Those are the
|
||||
* requests that carry the session cookie and the password.
|
||||
*/
|
||||
object MinstrelServerRequest
|
||||
|
||||
/**
|
||||
* Plain `http://` to the Minstrel server is allowed only when the connection
|
||||
* actually lands on a private address (family security baseline #5105,
|
||||
* practice 13).
|
||||
*
|
||||
* Cleartext stays permitted app-wide for LAN servers and UPnP
|
||||
* (network_security_config.xml, #2439), but a password or session cookie sent
|
||||
* over plain HTTP to a public address can be read by anyone on the path.
|
||||
*
|
||||
* Checked per connection, on the address the socket really reached, not on
|
||||
* the URL when it was typed: a name that resolved to the home network when it
|
||||
* was entered resolves to a public address once the phone leaves home, and
|
||||
* that is exactly when the password would go out in the clear. A network
|
||||
* interceptor runs after the connection is made and before any request byte
|
||||
* is written, so nothing is sent.
|
||||
*/
|
||||
class CleartextGuardInterceptor(
|
||||
// The policy is a parameter so a test can refuse loopback, the only
|
||||
// address a test server can listen on.
|
||||
private val allows: (InetAddress) -> Boolean = CleartextPolicy::allows,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.isHttps || request.tag(MinstrelServerRequest::class.java) == null) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
val address = chain.connection()?.route()?.socketAddress?.address
|
||||
if (address != null && !allows(address)) {
|
||||
throw CleartextToPublicHostException(request.url.host)
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
|
||||
/** The server was reached over plain HTTP at a public address, and refused. */
|
||||
class CleartextToPublicHostException(host: String) :
|
||||
IOException("refusing plain http:// to $host: it is a public address")
|
||||
|
||||
/** Which addresses plain HTTP may reach: the home network, never the internet. */
|
||||
object CleartextPolicy {
|
||||
private const val CGNAT_FIRST_OCTET = 100
|
||||
private const val CGNAT_SECOND_MASK = 0xC0
|
||||
private const val CGNAT_SECOND_PREFIX = 64
|
||||
private const val ULA_MASK = 0xFE
|
||||
private const val ULA_PREFIX = 0xFC
|
||||
private const val BYTE = 0xFF
|
||||
|
||||
fun allows(address: InetAddress): Boolean =
|
||||
address.isLoopbackAddress ||
|
||||
address.isSiteLocalAddress || // 10/8, 172.16/12, 192.168/16
|
||||
address.isLinkLocalAddress || // 169.254/16, fe80::/10
|
||||
address.isAnyLocalAddress ||
|
||||
isCarrierGradeNat(address) ||
|
||||
isUniqueLocal(address)
|
||||
|
||||
// 100.64/10. Tailscale and other overlay VPNs hand these out; the overlay
|
||||
// encrypts the traffic itself.
|
||||
private fun isCarrierGradeNat(address: InetAddress): Boolean {
|
||||
if (address !is Inet4Address) return false
|
||||
val b = address.address
|
||||
return (b[0].toInt() and BYTE) == CGNAT_FIRST_OCTET &&
|
||||
(b[1].toInt() and CGNAT_SECOND_MASK) == CGNAT_SECOND_PREFIX
|
||||
}
|
||||
|
||||
// fc00::/7, IPv6's private range.
|
||||
private fun isUniqueLocal(address: InetAddress): Boolean =
|
||||
address is Inet6Address && (address.address[0].toInt() and ULA_MASK) == ULA_PREFIX
|
||||
}
|
||||
@@ -37,36 +37,18 @@ object ErrorCopy {
|
||||
* as connection failures.
|
||||
*/
|
||||
fun fromThrowable(t: Throwable): String = when (t) {
|
||||
is HttpException -> fromHttp(t)
|
||||
is CleartextToPublicHostException -> messageFor("cleartext_public")
|
||||
is HttpException -> messageFor(codeFromHttp(t))
|
||||
is IOException -> messageFor("connection_refused")
|
||||
else -> TABLE.getValue("unknown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes whose server message carries specifics the operator needs in
|
||||
* order to act — which directory, which uid — that fixed copy cannot say.
|
||||
* For these the message follows the copy (#3918). Kept to a named set on
|
||||
* purpose: most server messages are internal detail. Mirrors web's
|
||||
* errors.ts.
|
||||
*/
|
||||
private val DETAIL_CODES = setOf("library_not_writable", "file_delete_failed")
|
||||
|
||||
private fun fromHttp(e: HttpException): String {
|
||||
val body = bodyFromHttp(e)
|
||||
val copy = messageFor(body.code.ifEmpty { "unknown" })
|
||||
return if (body.code in DETAIL_CODES && body.message.isNotBlank()) {
|
||||
"$copy ${body.message}"
|
||||
} else {
|
||||
copy
|
||||
}
|
||||
}
|
||||
|
||||
private fun bodyFromHttp(e: HttpException): Body {
|
||||
private fun codeFromHttp(e: HttpException): String {
|
||||
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
|
||||
?: return Body()
|
||||
return runCatching { json.decodeFromString<Envelope>(raw).error }
|
||||
.getOrNull() ?: Body()
|
||||
?: return "unknown"
|
||||
val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
return code.ifEmpty { "unknown" }
|
||||
}
|
||||
|
||||
private val TABLE: Map<String, String> = mapOf(
|
||||
@@ -76,7 +58,6 @@ object ErrorCopy {
|
||||
"forbidden" to "You don't have permission to do that.",
|
||||
"not_authorized" to "You don't have permission to do that.",
|
||||
"invalid_credentials" to "Wrong username or password.",
|
||||
"rate_limited" to "Too many attempts. Wait a few minutes and try again.",
|
||||
"wrong_password" to "Current password is incorrect.",
|
||||
"password_too_short" to "Password must be at least 8 characters.",
|
||||
"username_invalid" to "That username isn't valid.",
|
||||
@@ -102,9 +83,6 @@ object ErrorCopy {
|
||||
"mbid_required" to "An MBID is required for this lookup.",
|
||||
"system_playlist_readonly" to "System playlists can't be edited directly.",
|
||||
"connection_refused" to "Couldn't reach the server. Check the URL and try again.",
|
||||
"cleartext_public" to
|
||||
"This server is on the internet, so its URL must start with https://. " +
|
||||
"Plain http:// only works on your home network.",
|
||||
"lidarr_unreachable" to
|
||||
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
|
||||
"lidarr_disabled" to "Lidarr integration is not enabled.",
|
||||
@@ -121,8 +99,6 @@ object ErrorCopy {
|
||||
"request_not_pending" to "This request is no longer pending.",
|
||||
"request_not_found" to "That request no longer exists.",
|
||||
"track_not_found" to "That track no longer exists.",
|
||||
"library_not_writable" to "The music library isn't writable by the server.",
|
||||
"file_delete_failed" to "The file couldn't be deleted.",
|
||||
"album_not_found" to "That album no longer exists.",
|
||||
"artist_not_found" to "That artist no longer exists.",
|
||||
"playlist_not_found" to "That playlist no longer exists.",
|
||||
|
||||
@@ -70,9 +70,6 @@ object NetworkModule {
|
||||
.addInterceptor(auth)
|
||||
.addInterceptor(baseUrl)
|
||||
.addInterceptor(logging)
|
||||
// A network interceptor, so it sees the address the connection
|
||||
// really reached and runs before any request byte is written.
|
||||
.addNetworkInterceptor(CleartextGuardInterceptor())
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@@ -29,20 +29,11 @@ interface CastApi {
|
||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*
|
||||
* [level] asks for the leveled stream (M464 #5001): the track rendered at
|
||||
* the user's loudness gain, which the server works out from their setting.
|
||||
* [asAlbum] says the track plays among its album in order, which picks
|
||||
* album gain in auto mode. [prerender] says the speaker will fetch it
|
||||
* soon, so the server renders it ahead.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenRequest(
|
||||
val trackId: String,
|
||||
val expSeconds: Int = 21_600,
|
||||
val level: Boolean = false,
|
||||
val asAlbum: Boolean = false,
|
||||
val prerender: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -62,6 +53,4 @@ data class StreamTokenResponse(
|
||||
val url: String,
|
||||
val mime: String = "audio/mpeg",
|
||||
val title: String = "",
|
||||
/** [url] is the leveled stream; false when leveling is off or changes nothing. */
|
||||
val leveled: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
@@ -60,14 +59,6 @@ interface MeApi {
|
||||
*/
|
||||
@PUT("api/me/listenbrainz")
|
||||
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
||||
|
||||
/** The caller's loudness-normalization preference, or the defaults if never set. */
|
||||
@GET("api/me/normalization")
|
||||
suspend fun getNormalization(): NormalizationPrefs
|
||||
|
||||
/** Replaces the whole preference; returns what the server stored. */
|
||||
@PUT("api/me/normalization")
|
||||
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* The notifications inbox and its per-user settings (M489). The server
|
||||
* renders each notice's title, body and link, so the app shows them as given.
|
||||
*/
|
||||
interface NotificationsApi {
|
||||
@GET("api/me/notifications")
|
||||
suspend fun list(@Query("limit") limit: Int): NotificationsPageWire
|
||||
|
||||
/** 204; 404 when the notice is gone, which a replay treats as done. */
|
||||
@POST("api/me/notifications/{id}/read")
|
||||
suspend fun markRead(@Path("id") id: String)
|
||||
|
||||
@POST("api/me/notifications/read-all")
|
||||
suspend fun readAll(@Body body: ReadAllBody)
|
||||
|
||||
@GET("api/me/notification-settings")
|
||||
suspend fun getSettings(): NotificationSettingsWire
|
||||
|
||||
@PUT("api/me/notification-settings")
|
||||
suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NotificationWire(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val link: String,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("read_at") val readAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationsPageWire(
|
||||
val items: List<NotificationWire>,
|
||||
@SerialName("unread_count") val unreadCount: Long,
|
||||
@SerialName("next_before") val nextBefore: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `upTo` limits "mark all read" to what existed when the user asked, so a
|
||||
* replay landing later leaves newer notices unread. No default on purpose:
|
||||
* the app's Json drops default-valued fields.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReadAllBody(@SerialName("up_to") val upTo: String?)
|
||||
|
||||
@Serializable
|
||||
data class NotificationKindSettingWire(
|
||||
val kind: String,
|
||||
@SerialName("admin_only") val adminOnly: Boolean,
|
||||
val inbox: Boolean,
|
||||
val phone: Boolean,
|
||||
val email: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationSettingsWire(
|
||||
val kinds: List<NotificationKindSettingWire>,
|
||||
@SerialName("email_available") val emailAvailable: Boolean,
|
||||
@SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One kind's change. Untouched channels stay null and, being equal to their
|
||||
* default, are left out of the JSON, so the server changes only the channel
|
||||
* the user touched.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationSettingChangeWire(
|
||||
val kind: String,
|
||||
val inbox: Boolean? = null,
|
||||
val phone: Boolean? = null,
|
||||
val email: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PutNotificationSettingsBody(val kinds: List<NotificationSettingChangeWire>)
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
|
||||
interface ReplayGainApi {
|
||||
/**
|
||||
* ReplayGain values for up to 200 comma-separated track ids. An id
|
||||
* missing from `items` has not been measured yet.
|
||||
*/
|
||||
@GET("api/tracks/replay-gain")
|
||||
suspend fun getReplayGain(@Query("ids") ids: String): ReplayGainResponseWire
|
||||
}
|
||||
@@ -4,19 +4,12 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -34,14 +27,6 @@ import javax.inject.Singleton
|
||||
* in-memory state changes synchronously so the next interceptor read
|
||||
* sees the new value immediately; the DAO write coroutine catches up
|
||||
* shortly after.
|
||||
*
|
||||
* **The session cookie is the exception** (M462 #4985): it is persisted
|
||||
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
|
||||
* first launch after the upgrade, a cookie still in the row is moved into
|
||||
* the vault and the column cleared, so nobody is signed out by the change.
|
||||
* If the Keystore cannot be used on a device, the cookie stays in the row
|
||||
* as before rather than being lost. [awaitSessionHydrated] lets a caller
|
||||
* that needs a definitive answer (the auth gate) wait for this.
|
||||
*/
|
||||
// AuthStore is the single-row facade over auth_session (de-facto
|
||||
// app_preferences — see entity comment). It legitimately owns one
|
||||
@@ -54,7 +39,6 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AuthStore @Inject constructor(
|
||||
private val dao: AuthSessionDao,
|
||||
private val vault: SessionVault,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val sessionCookieState = MutableStateFlow<String?>(null)
|
||||
@@ -78,39 +62,18 @@ class AuthStore @Inject constructor(
|
||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||
|
||||
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||
|
||||
// Background delivery (M489 #5347): the device's choice, on by default.
|
||||
// The shade's high-water mark lives in the same row but is read and
|
||||
// written through the DAO by NotificationSync, awaited, never cached here.
|
||||
private val backgroundDeliveryState = MutableStateFlow(true)
|
||||
val backgroundDelivery: StateFlow<Boolean> = backgroundDeliveryState.asStateFlow()
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Serialises every cookie persist with the one-time hydration, so a
|
||||
// sign-in or a 401 that lands while hydration runs is never overwritten
|
||||
// by the stale value hydration read.
|
||||
private val cookieLock = Mutex()
|
||||
|
||||
// Set by setSessionCookie. Once something has written the cookie this
|
||||
// process, that value wins over whatever hydration finds on disk.
|
||||
@Volatile private var cookieTouched = false
|
||||
|
||||
private val cookieHydration: Deferred<Unit> = scope.async { hydrateSessionCookie() }
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
dao.observe().collect { row ->
|
||||
sessionCookieState.value = row?.sessionCookie
|
||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||
userJsonState.value = row?.userJson
|
||||
themeModeState.value = row?.themeMode
|
||||
clientIdState.value = row?.clientId
|
||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||
backgroundDeliveryState.value = row?.backgroundDelivery ?: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,57 +85,9 @@ class AuthStore @Inject constructor(
|
||||
}.getOrDefault(CacheSettings.DEFAULT)
|
||||
}
|
||||
|
||||
private fun decodeNormalization(raw: String?): NormalizationPrefs {
|
||||
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
|
||||
return runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), raw)
|
||||
}.getOrDefault(NormalizationPrefs.DEFAULT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until the stored session cookie has been loaded into
|
||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||
* Keystore must not leave the start screen spinning). Returns false on
|
||||
* the deadline; the caller then decides from whatever has loaded, and a
|
||||
* late hydration still lands in [sessionCookie].
|
||||
*/
|
||||
suspend fun awaitSessionHydrated(): Boolean {
|
||||
val done = withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } != null
|
||||
if (!done) Timber.w("auth store: session hydration passed its deadline; deciding without it")
|
||||
return done
|
||||
}
|
||||
|
||||
fun setSessionCookie(value: String?) {
|
||||
cookieTouched = true
|
||||
sessionCookieState.value = value
|
||||
scope.launch { cookieLock.withLock { storeCookie(value) } }
|
||||
}
|
||||
|
||||
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
|
||||
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
|
||||
if (cookieTouched) return@withLock
|
||||
// A cookie in the row is the newer one when both exist: the row is
|
||||
// only written when the vault failed, and an install upgrading from
|
||||
// before the vault has nothing in the vault yet.
|
||||
val cookie = legacy ?: vault.read()
|
||||
// Best-effort: if moving it fails, the session still loads this time
|
||||
// and the move is retried on the next launch. Hydration must never
|
||||
// throw, or awaitSessionHydrated would leave the auth gate stuck.
|
||||
if (legacy != null) {
|
||||
runCatching { storeCookie(legacy) }
|
||||
.onFailure { Timber.w(it, "auth store: could not move the session cookie into the vault") }
|
||||
}
|
||||
sessionCookieState.value = cookie
|
||||
}
|
||||
|
||||
// Vault first; the Room row only when the Keystore is unusable, so a
|
||||
// broken Keystore degrades to the old storage rather than a sign-out.
|
||||
private suspend fun storeCookie(value: String?) {
|
||||
if (vault.write(value)) {
|
||||
if (dao.get()?.sessionCookie != null) dao.setSessionCookie(null)
|
||||
} else {
|
||||
persistLegacyCookie(value)
|
||||
}
|
||||
scope.launch { persistCookie(value) }
|
||||
}
|
||||
|
||||
fun setBaseUrl(value: String) {
|
||||
@@ -206,24 +121,7 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
fun setNormalization(value: NormalizationPrefs) {
|
||||
normalizationState.value = value
|
||||
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
|
||||
scope.launch { persistNormalization(encoded) }
|
||||
}
|
||||
|
||||
fun setBackgroundDelivery(value: Boolean) {
|
||||
backgroundDeliveryState.value = value
|
||||
scope.launch {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(backgroundDelivery = value))
|
||||
} else {
|
||||
dao.setBackgroundDelivery(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistLegacyCookie(value: String?) {
|
||||
private suspend fun persistCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
} else {
|
||||
@@ -279,19 +177,9 @@ class AuthStore @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistNormalization(json: String) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(normalizationJson = json))
|
||||
} else {
|
||||
dao.setNormalizationJson(json)
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||
id = ROW_ID,
|
||||
// Never copied into the row: the cookie lives in the vault, and
|
||||
// persistLegacyCookie sets it explicitly on the fallback path.
|
||||
sessionCookie = null,
|
||||
sessionCookie = sessionCookieState.value,
|
||||
baseUrl = baseUrlState.value,
|
||||
userJson = userJsonState.value,
|
||||
themeMode = themeModeState.value,
|
||||
@@ -301,19 +189,10 @@ class AuthStore @Inject constructor(
|
||||
cacheSettingsState.value,
|
||||
),
|
||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||
normalizationJson = json.encodeToString(
|
||||
NormalizationPrefs.serializer(),
|
||||
normalizationState.value,
|
||||
),
|
||||
backgroundDelivery = backgroundDeliveryState.value,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
||||
|
||||
// Generous on purpose: hydration is one local row read and one
|
||||
// Keystore decrypt, normally milliseconds. This only bounds "never".
|
||||
const val HYDRATION_DEADLINE_MS: Long = 10_000
|
||||
private const val ROW_ID = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Where the session cookie lives at rest (M462 #4985).
|
||||
*
|
||||
* The cookie is a bearer credential: anyone holding it is signed in as the
|
||||
* user until the server expires or revokes it. It used to sit in plain text
|
||||
* in the Room `auth_session` row, readable from any copy of the app's data
|
||||
* directory (a rooted device, an adb backup of a debuggable build, a
|
||||
* forensic image). Now only ciphertext is stored, under an AES key that
|
||||
* lives in the Android Keystore and never leaves it, so a copy of the
|
||||
* files alone yields nothing usable.
|
||||
*/
|
||||
interface SessionVault {
|
||||
/** The stored cookie, or null when none is stored or it can't be decrypted. */
|
||||
fun read(): String?
|
||||
|
||||
/**
|
||||
* Stores [value], or clears the stored cookie when null. Returns false
|
||||
* when the Keystore could not be used, so the caller can fall back
|
||||
* rather than lose the session.
|
||||
*/
|
||||
fun write(value: String?): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM sealing of a short string, framed as base64(iv || ciphertext+tag).
|
||||
* Kept apart from the Keystore so the framing can be unit-tested on the JVM
|
||||
* with an ordinary key; the Android Keystore has no JVM implementation.
|
||||
*/
|
||||
internal object SealedBox {
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val TAG_BITS = 128
|
||||
private const val IV_BYTES = 12
|
||||
|
||||
// Binds a sealed value to its purpose: a blob sealed for something else
|
||||
// under the same key will not open as a session cookie.
|
||||
private val AAD = "minstrel-session-cookie-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun seal(key: SecretKey, plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
// No IV passed: the provider generates a fresh random one. Keystore
|
||||
// keys refuse a caller-chosen IV for encryption by default.
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
cipher.updateAAD(AAD)
|
||||
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return Base64.getEncoder().encodeToString(sealed)
|
||||
}
|
||||
|
||||
fun open(key: SecretKey, sealed: String): String {
|
||||
val bytes = Base64.getDecoder().decode(sealed)
|
||||
require(bytes.size > IV_BYTES) { "sealed value too short" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
|
||||
cipher.updateAAD(AAD)
|
||||
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [SessionVault] backed by a Keystore AES key and a private prefs file that
|
||||
* holds only the sealed value.
|
||||
*
|
||||
* A value that will not open (the key was wiped by a factory-reset of the
|
||||
* Keystore, or the file was restored onto another device; app backup is off,
|
||||
* but a vendor transfer tool may still copy files) is discarded and reported
|
||||
* as absent. The user signs in again, which is the right outcome for a
|
||||
* credential that no longer verifies.
|
||||
*/
|
||||
@Singleton
|
||||
class KeystoreSessionVault @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : SessionVault {
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun read(): String? {
|
||||
val sealed = prefs.getString(KEY_COOKIE, null) ?: return null
|
||||
return runCatching { SealedBox.open(key(), sealed) }
|
||||
.onFailure {
|
||||
Timber.w(it, "session vault: stored cookie would not decrypt; discarding it")
|
||||
prefs.edit().remove(KEY_COOKIE).commit()
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
override fun write(value: String?): Boolean = runCatching {
|
||||
val editor = prefs.edit()
|
||||
if (value == null) {
|
||||
editor.remove(KEY_COOKIE)
|
||||
} else {
|
||||
editor.putString(KEY_COOKIE, SealedBox.seal(key(), value))
|
||||
}
|
||||
editor.commit()
|
||||
}.onFailure {
|
||||
Timber.w(it, "session vault: Keystore unavailable; cookie not stored in the vault")
|
||||
}.getOrDefault(false)
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(KEY_BITS)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
const val KEY_ALIAS = "minstrel_session_cookie"
|
||||
const val KEY_BITS = 256
|
||||
const val PREFS_NAME = "session_vault"
|
||||
const val KEY_COOKIE = "sealed_cookie"
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class SessionVaultModule {
|
||||
@Binds
|
||||
abstract fun bindSessionVault(impl: KeystoreSessionVault): SessionVault
|
||||
}
|
||||
@@ -16,11 +16,10 @@ import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Computes the initial startDestination for the root NavHost based on
|
||||
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||
* StateFlows default to null until their async load lands, and we need a
|
||||
* definitive answer before drawing any nav graph. The cookie is no longer
|
||||
* in the row (it lives in the Keystore-backed SessionVault, #4985).
|
||||
* persisted auth state. Sits on top of AuthSessionDao directly rather
|
||||
* than AuthStore's StateFlow because the StateFlow defaults to null
|
||||
* until Room's first async emission — we need a definitive answer
|
||||
* before drawing any nav graph.
|
||||
*
|
||||
* - no row at all → ServerUrl (first launch)
|
||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||
@@ -32,7 +31,6 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class AuthGateViewModel @Inject constructor(
|
||||
private val dao: AuthSessionDao,
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<Any?>(null)
|
||||
@@ -40,13 +38,12 @@ class AuthGateViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authStore.awaitSessionHydrated()
|
||||
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||
val row = dao.get()
|
||||
internal.value = when {
|
||||
row == null -> ServerUrl
|
||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
|
||||
!signedIn -> Login
|
||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
|
||||
ServerUrl
|
||||
row.sessionCookie.isNullOrEmpty() -> Login
|
||||
else -> Home
|
||||
}
|
||||
}
|
||||
|
||||
+1
-76
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.cache.db
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
@@ -13,7 +11,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
@@ -29,8 +26,6 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
@@ -69,30 +64,14 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
CachedHistorySnapshotEntity::class,
|
||||
AuthSessionEntity::class,
|
||||
DiagnosticEventEntity::class,
|
||||
CachedNotificationEntity::class,
|
||||
CachedNotificationSettingsEntity::class,
|
||||
],
|
||||
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
|
||||
// background-delivery choice and its shade high-water mark (M489 #5347).
|
||||
// v11: + cached_notifications and cached_notification_settings, the
|
||||
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
|
||||
// both; nothing to backfill, the first refresh fills them.
|
||||
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||
// every row and an existing cache gains its values.
|
||||
// v9: + auth_session.normalizationJson, the loudness-normalization
|
||||
// preference (M464 #4998). The first schema step with an explicit
|
||||
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
|
||||
// row — the server address and theme — and the queued offline writes,
|
||||
// which is too much to lose for one added column.
|
||||
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||
// so cache-first surfaces stop offering files that cannot stream.
|
||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
version = 12,
|
||||
version = 8,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -112,58 +91,4 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
abstract fun cachedNotificationDao(): CachedNotificationDao
|
||||
}
|
||||
|
||||
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
|
||||
* and the sync is incremental, so it would never re-send them: the cursor goes
|
||||
* back to 0 and the next sync is a full one, upserting every row in place.
|
||||
*/
|
||||
val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackGain REAL")
|
||||
db.execSQL("ALTER TABLE cached_tracks ADD COLUMN trackPeak REAL")
|
||||
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumGain REAL")
|
||||
db.execSQL("ALTER TABLE cached_albums ADD COLUMN albumPeak REAL")
|
||||
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL
|
||||
* matches what Room generates for the two entities; Room checks it on open.
|
||||
*/
|
||||
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_notifications` (" +
|
||||
"`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " +
|
||||
"`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
|
||||
"`readAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" +
|
||||
"`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v11 → v12: background delivery (M489 #5347). The device choice defaults on,
|
||||
* as the entity's column default says; the high-water mark starts empty, so
|
||||
* the first catch-up sets it without announcing anything.
|
||||
*/
|
||||
val MIGRATION_11_12: Migration = object : Migration(11, 12) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN backgroundDelivery INTEGER NOT NULL DEFAULT 1")
|
||||
db.execSQL("ALTER TABLE auth_session ADD COLUMN notifiedUpTo INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
@@ -38,7 +37,6 @@ object DatabaseModule {
|
||||
// launch, so users lose only the unsynced mutation queue
|
||||
// (acceptable while we're iterating). Replace with explicit
|
||||
// Migration entries before the first tagged release.
|
||||
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
@@ -113,10 +111,5 @@ object DatabaseModule {
|
||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||
db.diagnosticEventDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
|
||||
db.cachedNotificationDao()
|
||||
|
||||
private const val DATABASE_NAME = "minstrel.db"
|
||||
}
|
||||
|
||||
-13
@@ -6,7 +6,6 @@ import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
@Dao
|
||||
interface AuthSessionDao {
|
||||
@@ -47,16 +46,4 @@ interface AuthSessionDao {
|
||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
||||
|
||||
/** Partial update: change only the serialized normalization preference. */
|
||||
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
|
||||
suspend fun setNormalizationJson(json: String?)
|
||||
|
||||
/** Partial update: change only the background-delivery choice. */
|
||||
@Query("UPDATE auth_session SET backgroundDelivery = :enabled WHERE id = 0")
|
||||
suspend fun setBackgroundDelivery(enabled: Boolean)
|
||||
|
||||
/** Partial update: change only the shade's high-water mark. */
|
||||
@Query("UPDATE auth_session SET notifiedUpTo = :upTo WHERE id = 0")
|
||||
suspend fun setNotifiedUpTo(upTo: Instant?)
|
||||
}
|
||||
|
||||
-4
@@ -31,8 +31,4 @@ interface CachedMutationDao {
|
||||
|
||||
@Query("DELETE FROM cached_mutations")
|
||||
suspend fun clear()
|
||||
|
||||
/** Whether a write of [kind] is still waiting to be replayed. */
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
|
||||
suspend fun hasPending(kind: String): Boolean
|
||||
}
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
package com.fabledsword.minstrel.cache.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
@Dao
|
||||
interface CachedNotificationDao {
|
||||
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
|
||||
fun observeAll(): Flow<List<CachedNotificationEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
|
||||
fun observeUnreadCount(): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM cached_notifications")
|
||||
suspend fun getAll(): List<CachedNotificationEntity>
|
||||
|
||||
@Query("DELETE FROM cached_notifications")
|
||||
suspend fun clear()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(rows: List<CachedNotificationEntity>)
|
||||
|
||||
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
|
||||
@Transaction
|
||||
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
|
||||
clear()
|
||||
insertAll(rows)
|
||||
}
|
||||
|
||||
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
|
||||
suspend fun markRead(id: String, at: Instant)
|
||||
|
||||
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
|
||||
suspend fun markAllRead(at: Instant)
|
||||
|
||||
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
||||
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
|
||||
|
||||
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
||||
suspend fun getSettings(): CachedNotificationSettingsEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
|
||||
}
|
||||
-21
@@ -38,27 +38,6 @@ interface CachedTrackDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedTrackEntity>)
|
||||
|
||||
/**
|
||||
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
|
||||
* the album's from its album row. A track not in the cache has no row.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT t.id AS id, t.trackGain AS trackGain, t.trackPeak AS trackPeak, " +
|
||||
"a.albumGain AS albumGain, a.albumPeak AS albumPeak " +
|
||||
"FROM cached_tracks t LEFT JOIN cached_albums a ON a.id = t.albumId " +
|
||||
"WHERE t.id IN (:ids)",
|
||||
)
|
||||
suspend fun replayGains(ids: List<String>): List<CachedReplayGain>
|
||||
|
||||
@Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<String>)
|
||||
}
|
||||
|
||||
/** One row of [CachedTrackDao.replayGains]. */
|
||||
data class CachedReplayGain(
|
||||
val id: String,
|
||||
val trackGain: Float?,
|
||||
val trackPeak: Float?,
|
||||
val albumGain: Float?,
|
||||
val albumPeak: Float?,
|
||||
)
|
||||
|
||||
-21
@@ -1,9 +1,7 @@
|
||||
package com.fabledsword.minstrel.cache.db.entities
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Single-row table holding the user's session cookie, configured
|
||||
@@ -45,23 +43,4 @@ data class AuthSessionEntity(
|
||||
* choice lives here. Default false = honor the account flag.
|
||||
*/
|
||||
val diagnosticsOptOut: Boolean = false,
|
||||
/**
|
||||
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
|
||||
* from the server or set here (M464 #4998). Null = never fetched; the
|
||||
* defaults apply. Kept so offline playback still levels.
|
||||
*/
|
||||
val normalizationJson: String? = null,
|
||||
/**
|
||||
* "Notifications when the app is closed" (M489 #5347): keep the
|
||||
* delivery foreground service running. A device choice, on by default.
|
||||
* The column default matches MIGRATION_11_12's, which Room checks.
|
||||
*/
|
||||
@ColumnInfo(defaultValue = "1")
|
||||
val backgroundDelivery: Boolean = true,
|
||||
/**
|
||||
* The newest notice this device has announced in the shade. A catch-up
|
||||
* announces only what is newer, so a reboot or a reconnect never
|
||||
* re-announces a backlog. Cleared on sign-out.
|
||||
*/
|
||||
val notifiedUpTo: Instant? = null,
|
||||
)
|
||||
|
||||
-3
@@ -18,8 +18,5 @@ data class CachedAlbumEntity(
|
||||
val releaseDate: String? = null,
|
||||
val coverPath: String? = null,
|
||||
val mbid: String? = null,
|
||||
// ReplayGain 2.0 album values (M464); null until every track is measured.
|
||||
val albumGain: Float? = null,
|
||||
val albumPeak: Float? = null,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
)
|
||||
|
||||
Vendored
-21
@@ -1,21 +0,0 @@
|
||||
package com.fabledsword.minstrel.cache.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* One notice from the user's inbox (M489), kept so the Notifications screen
|
||||
* and the bell's badge work offline, and so a read made offline shows at once.
|
||||
* The newest page is cached; older notices are the server's to keep.
|
||||
*/
|
||||
@Entity(tableName = "cached_notifications")
|
||||
data class CachedNotificationEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val link: String,
|
||||
val createdAt: Instant,
|
||||
val readAt: Instant?,
|
||||
)
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package com.fabledsword.minstrel.cache.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Single-row copy of the user's notification settings (M489), stored as the
|
||||
* wire JSON, so the settings screen opens offline and a toggle shows at once.
|
||||
*/
|
||||
@Entity(tableName = "cached_notification_settings")
|
||||
data class CachedNotificationSettingsEntity(
|
||||
@PrimaryKey val id: Int = SINGLETON_ID,
|
||||
val json: String,
|
||||
) {
|
||||
companion object {
|
||||
const val SINGLETON_ID = 1
|
||||
}
|
||||
}
|
||||
-4
@@ -26,9 +26,5 @@ data class CachedTrackEntity(
|
||||
val fileFormat: String? = null,
|
||||
val genre: String? = null,
|
||||
val missing: Boolean = false,
|
||||
// ReplayGain 2.0 track values (M464), kept so cached audio levels
|
||||
// offline. Null until the server has measured the track.
|
||||
val trackGain: Float? = null,
|
||||
val trackPeak: Float? = null,
|
||||
val fetchedAt: Instant = Clock.System.now(),
|
||||
)
|
||||
|
||||
-77
@@ -1,9 +1,7 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -43,22 +41,6 @@ object MutationKind {
|
||||
// an undo collapses to the latest intent instead of replaying as two
|
||||
// opposed calls whose order decides the outcome.
|
||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||
|
||||
// M464 #4998 loudness-normalization preference. The payload is the whole
|
||||
// preference, a target state like the toggles above, so queued changes
|
||||
// collapse to the last one and an older one can never be replayed last.
|
||||
const val NORMALIZATION_SET: String = "normalization_set"
|
||||
|
||||
// M489 notifications inbox. A read is a one-way action (read never goes
|
||||
// back to unread), so neither read kind needs collapsing. Read-all
|
||||
// carries the moment the user asked, so a late replay leaves newer
|
||||
// notices unread.
|
||||
const val NOTIFICATION_READ: String = "notification_read"
|
||||
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
|
||||
|
||||
// M489 per-kind channel setting. One row per (kind, channel) target
|
||||
// state, collapsed on that pair, so the newest choice is the one sent.
|
||||
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +85,6 @@ data class RequestCreatePayload(
|
||||
* This matches `feedback_offline_first_for_server_writes` — writes
|
||||
* never go fire-and-forget.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
|
||||
@Singleton
|
||||
class MutationQueue @Inject constructor(
|
||||
private val dao: CachedMutationDao,
|
||||
@@ -196,31 +177,6 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/** Queues the user's whole normalization preference for replay. */
|
||||
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
|
||||
MutationKind.NORMALIZATION_SET,
|
||||
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||
)
|
||||
|
||||
suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven(
|
||||
MutationKind.NOTIFICATION_READ,
|
||||
json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)),
|
||||
)
|
||||
|
||||
suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven(
|
||||
MutationKind.NOTIFICATIONS_READ_ALL,
|
||||
json.encodeToString(
|
||||
NotificationsReadAllPayload.serializer(),
|
||||
NotificationsReadAllPayload(upToIso),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long =
|
||||
insertUserDriven(
|
||||
MutationKind.NOTIFICATION_SETTING_SET,
|
||||
json.encodeToString(NotificationSettingPayload.serializer(), payload),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
@@ -366,36 +322,3 @@ data class PlaybackErrorReportPayload(
|
||||
val detail: String? = null,
|
||||
val clientId: String,
|
||||
)
|
||||
|
||||
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
|
||||
@Serializable
|
||||
data class NotificationReadPayload(val id: String)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
|
||||
* `upToIso` is the newest notice the user could see when they asked; null
|
||||
* when the inbox was empty on the device, which marks everything.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationsReadAllPayload(val upToIso: String?)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one
|
||||
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
|
||||
* "email". No defaults, so every field is always written.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationSettingPayload(
|
||||
val kind: String,
|
||||
val channel: String,
|
||||
val value: Boolean,
|
||||
)
|
||||
|
||||
/** The wire change for one queued channel setting, or null for an unknown channel. */
|
||||
internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? =
|
||||
when (p.channel) {
|
||||
"inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value)
|
||||
"phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value)
|
||||
"email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value)
|
||||
else -> null
|
||||
}
|
||||
|
||||
+1
-62
@@ -7,10 +7,6 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
@@ -26,7 +22,6 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -84,8 +79,6 @@ class MutationReplayer @Inject constructor(
|
||||
private val eventsApi: EventsApi = retrofit.create()
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
private val notificationsApi: NotificationsApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -173,23 +166,10 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||
MutationKind.NOTIFICATION_READ,
|
||||
MutationKind.NOTIFICATIONS_READ_ALL,
|
||||
MutationKind.NOTIFICATION_SETTING_SET,
|
||||
-> dispatchNotification(row)
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */
|
||||
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
|
||||
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
|
||||
MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload)
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||
val kindPath = when (decoded.entityType) {
|
||||
@@ -299,37 +279,6 @@ class MutationReplayer @Inject constructor(
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the queued normalization preference. The device already shows
|
||||
* it, so the server's echo is not written back: a change made since the
|
||||
* row was queued would be a newer row, and the collapse keeps only that.
|
||||
*/
|
||||
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
|
||||
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */
|
||||
private suspend fun dispatchNotificationRead(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload)
|
||||
notificationsApi.markRead(decoded.id)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchNotificationsReadAll(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload)
|
||||
notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso))
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
/** An unknown channel can only come from a corrupt row: DROP it. */
|
||||
private suspend fun dispatchNotificationSettingSet(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload)
|
||||
val change = notificationSettingChange(decoded) ?: return Outcome.DROP
|
||||
notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change)))
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -354,8 +303,7 @@ class MutationReplayer @Inject constructor(
|
||||
/**
|
||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||
* than an action — like-toggles, suggestion snoozes (#2374) and the
|
||||
* normalization preference (#4998) — because
|
||||
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||
* replaying a stale one last would invert the final state.
|
||||
*
|
||||
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||
@@ -392,14 +340,5 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||
|
||||
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
|
||||
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
|
||||
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
|
||||
|
||||
// One preference per user, so every normalization row shares one key.
|
||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||
}.getOrNull()?.let { row.kind }
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
-4
@@ -206,8 +206,6 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
|
||||
releaseDate = releaseDate,
|
||||
coverPath = coverArtPath,
|
||||
mbid = mbid,
|
||||
albumGain = albumGain,
|
||||
albumPeak = albumPeak,
|
||||
)
|
||||
|
||||
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||
@@ -222,6 +220,4 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
||||
fileFormat = fileFormat,
|
||||
genre = genre,
|
||||
missing = missing,
|
||||
trackGain = trackGain,
|
||||
trackPeak = trackPeak,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
package com.fabledsword.minstrel.events
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -32,6 +28,9 @@ import javax.inject.Singleton
|
||||
|
||||
private const val SSE_PATH = "/api/events/stream"
|
||||
private const val EVENTS_BUFFER_CAPACITY = 64
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_FACTOR = 2
|
||||
|
||||
/**
|
||||
* Long-lived SSE subscription to `GET /api/events/stream`. Exposes
|
||||
@@ -46,16 +45,11 @@ private const val EVENTS_BUFFER_CAPACITY = 64
|
||||
* - No client-side timeout — the server emits 15s heartbeats which
|
||||
* okhttp-sse handles transparently.
|
||||
* - Reconnect-with-backoff: if the stream drops mid-session (server
|
||||
* restart, network blip) it reconnects after [ReconnectBackoff]'s
|
||||
* jittered wait (2s doubling to 5 min), reset on a successful open.
|
||||
* A network coming up reconnects at once: the callback is a hint, and
|
||||
* the only test of whether the server is reachable is trying it. Only
|
||||
* restart, network blip) it reconnects with exponential backoff
|
||||
* (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
|
||||
* reconnects while still signed in; a sign-out cancels the pending
|
||||
* retry. Without this a single blip silently kills cross-device
|
||||
* reactivity until the next app launch.
|
||||
* - [connected] says whether a stream is open. Background delivery
|
||||
* (M489 #5347) catches up on every rising edge: nothing replays a
|
||||
* frame sent while the stream was down.
|
||||
*
|
||||
* The URL passes through the placeholder host that
|
||||
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the
|
||||
@@ -68,7 +62,6 @@ class EventsStream @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val json: Json,
|
||||
private val connectivity: ConnectivityObserver,
|
||||
) {
|
||||
private val factory = EventSources.createFactory(okHttpClient)
|
||||
|
||||
@@ -79,13 +72,10 @@ class EventsStream @Inject constructor(
|
||||
)
|
||||
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
|
||||
|
||||
private val connectedState = MutableStateFlow(false)
|
||||
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
|
||||
|
||||
private var currentSource: EventSource? = null
|
||||
@Volatile private var signedIn = false
|
||||
private var reconnectJob: Job? = null
|
||||
private var backoffMs = ReconnectBackoff.BASE_MS
|
||||
private var backoffMs = BASE_BACKOFF_MS
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -95,30 +85,13 @@ class EventsStream @Inject constructor(
|
||||
.collect { isSignedIn ->
|
||||
signedIn = isSignedIn
|
||||
if (isSignedIn) {
|
||||
backoffMs = ReconnectBackoff.BASE_MS
|
||||
backoffMs = BASE_BACKOFF_MS
|
||||
connect()
|
||||
} else {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
connectivity.online.collect { up -> if (up) reconnectNow() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts a pending backoff short: reconnects at once and starts the ladder
|
||||
* over. For a network that has just come up, or the app coming to the
|
||||
* foreground, where waiting out a five-minute backoff would leave the
|
||||
* server unheard from for nothing. Does nothing while a stream is open or
|
||||
* opening.
|
||||
*/
|
||||
@Synchronized
|
||||
fun reconnectNow() {
|
||||
if (!signedIn || reconnectJob?.isActive != true) return
|
||||
backoffMs = ReconnectBackoff.BASE_MS
|
||||
connect()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -133,22 +106,20 @@ class EventsStream @Inject constructor(
|
||||
reconnectJob?.cancel()
|
||||
currentSource?.cancel()
|
||||
currentSource = null
|
||||
connectedState.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnect after the current backoff, jittered, then
|
||||
* double it (capped). No-op when signed out — sign-out's [disconnect]
|
||||
* Schedule a reconnect after the current backoff, then double it
|
||||
* (capped). No-op when signed out — sign-out's [disconnect]
|
||||
* cancels the pending job. A successful [Listener.onOpen] resets
|
||||
* the backoff to the floor.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun scheduleReconnect() {
|
||||
connectedState.value = false
|
||||
if (!signedIn) return
|
||||
reconnectJob?.cancel()
|
||||
val waitMs = ReconnectBackoff.jittered(backoffMs)
|
||||
backoffMs = ReconnectBackoff.next(backoffMs)
|
||||
val waitMs = backoffMs
|
||||
backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
|
||||
reconnectJob = scope.launch {
|
||||
delay(waitMs)
|
||||
if (signedIn) connect()
|
||||
@@ -165,8 +136,7 @@ class EventsStream @Inject constructor(
|
||||
|
||||
private inner class Listener : EventSourceListener() {
|
||||
override fun onOpen(eventSource: EventSource, response: Response) {
|
||||
backoffMs = ReconnectBackoff.BASE_MS
|
||||
connectedState.value = true
|
||||
backoffMs = BASE_BACKOFF_MS
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -31,7 +30,6 @@ import javax.inject.Singleton
|
||||
class LiveEventsDispatcher @Inject constructor(
|
||||
private val eventsStream: EventsStream,
|
||||
private val likes: LikesRepository,
|
||||
private val notifications: NotificationsRepository,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
@@ -51,8 +49,6 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
"artist.liked",
|
||||
"artist.unliked",
|
||||
-> refreshLikes()
|
||||
// M489: a contentless nudge; the inbox refetches its newest page.
|
||||
"notification.created" -> refreshNotifications()
|
||||
}
|
||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||
@@ -62,18 +58,9 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
// App returned to the foreground. SSE will catch up but might
|
||||
// not have reconnected yet (it may be deep in its backoff, so it
|
||||
// is told to try now); flush the cross-screen refreshes
|
||||
// not have reconnected yet; flush the cross-screen refreshes
|
||||
// defensively.
|
||||
eventsStream.reconnectNow()
|
||||
refreshLikes()
|
||||
refreshNotifications()
|
||||
}
|
||||
|
||||
private fun refreshNotifications() {
|
||||
scope.launch {
|
||||
runCatching { notifications.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLikes() {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.fabledsword.minstrel.events
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* How long [EventsStream] waits before reconnecting (M489 #5347).
|
||||
*
|
||||
* The stream is wanted around the clock once notifications arrive with the app
|
||||
* closed, so a server that is down overnight must not cost a radio wakeup
|
||||
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The
|
||||
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
|
||||
* phone on the server reconnects, which is when the server has just come back
|
||||
* and can least take a spike.
|
||||
*
|
||||
* A network coming up (a hint, never a gate) or the app coming to the
|
||||
* foreground reconnects at once and starts the ladder over.
|
||||
*/
|
||||
internal object ReconnectBackoff {
|
||||
const val BASE_MS = 2_000L
|
||||
const val MAX_MS = 300_000L
|
||||
private const val JITTER = 0.25
|
||||
|
||||
/** The wait after [currentMs], before jitter. */
|
||||
fun next(currentMs: Long): Long = (currentMs * 2).coerceAtMost(MAX_MS)
|
||||
|
||||
/** [ms] moved by up to a quarter either way. */
|
||||
fun jittered(ms: Long, random: Random = Random.Default): Long {
|
||||
val spread = ms * JITTER
|
||||
return (ms + random.nextDouble(-spread, spread)).toLong()
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** One track's entry in `GET /api/tracks/replay-gain` (#4997). Decode-only. */
|
||||
@Serializable
|
||||
data class ReplayGainWire(
|
||||
@SerialName("track_gain") val trackGain: Float? = null,
|
||||
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||
@SerialName("album_gain") val albumGain: Float? = null,
|
||||
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReplayGainResponseWire(
|
||||
val items: Map<String, ReplayGainWire> = emptyMap(),
|
||||
)
|
||||
@@ -28,10 +28,6 @@ data class SyncAlbumWire(
|
||||
@SerialName("release_date") val releaseDate: String? = null,
|
||||
@SerialName("cover_art_path") val coverArtPath: String? = null,
|
||||
val mbid: String? = null,
|
||||
// The album's ReplayGain 2.0 values (#4997): dB to -18 LUFS and a linear
|
||||
// peak. Null until every track on the album is measured.
|
||||
@SerialName("album_gain") val albumGain: Float? = null,
|
||||
@SerialName("album_peak") val albumPeak: Float? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -55,9 +51,6 @@ data class SyncTrackWire(
|
||||
// its tracks stay playable, which is the correct reading of "this server
|
||||
// has nothing to say about missing files".
|
||||
val missing: Boolean = false,
|
||||
// The track's ReplayGain 2.0 values (#4997); null until it is measured.
|
||||
@SerialName("track_gain") val trackGain: Float? = null,
|
||||
@SerialName("track_peak") val trackPeak: Float? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,8 +25,6 @@ import com.fabledsword.minstrel.home.ui.HomeScreen
|
||||
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
|
||||
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||
import com.fabledsword.minstrel.notifications.ui.NotificationSettingsScreen
|
||||
import com.fabledsword.minstrel.notifications.ui.NotificationsScreen
|
||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||
import com.fabledsword.minstrel.player.ui.QueueScreen
|
||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||
@@ -61,7 +59,6 @@ fun MinstrelNavGraph(
|
||||
) {
|
||||
inShellTopLevel(navController, expandPlayer)
|
||||
inShellDetail(navController, expandPlayer)
|
||||
inShellNotifications(navController, expandPlayer)
|
||||
outsideShell(navController)
|
||||
}
|
||||
}
|
||||
@@ -203,27 +200,6 @@ private fun NavGraphBuilder.inShellDetail(
|
||||
}
|
||||
}
|
||||
|
||||
/** The notifications inbox and its settings (M489). */
|
||||
private fun NavGraphBuilder.inShellNotifications(
|
||||
navController: NavHostController,
|
||||
expandPlayer: () -> Unit,
|
||||
) {
|
||||
composable<Notifications> {
|
||||
WithAnimatedScope {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
NotificationsScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable<NotificationSettings> {
|
||||
WithAnimatedScope {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
NotificationSettingsScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||
composable<NowPlaying>(
|
||||
// Slide up from the bottom on enter; back down on dismiss.
|
||||
|
||||
@@ -13,8 +13,6 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object Settings
|
||||
@Serializable data object Admin
|
||||
@Serializable data object Requests
|
||||
@Serializable data object Notifications
|
||||
@Serializable data object NotificationSettings
|
||||
|
||||
// ── In-shell detail / push-on-top destinations ────────────────────────
|
||||
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
|
||||
import com.fabledsword.minstrel.cache.mutations.notificationSettingChange
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** A notification channel, as the server names it. */
|
||||
enum class NotificationChannel(val wire: String) { INBOX("inbox"), PHONE("phone"), EMAIL("email") }
|
||||
|
||||
/**
|
||||
* Per-user notification settings (M489), following snippet #5107: the device
|
||||
* copy (a cached JSON row) shows a change at once, the PUT is best effort,
|
||||
* and a failed or out-of-order one is queued for the MutationReplayer.
|
||||
*/
|
||||
@Singleton
|
||||
class NotificationSettingsRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val dao: CachedNotificationDao,
|
||||
private val mutationDao: CachedMutationDao,
|
||||
private val mutationQueue: MutationQueue,
|
||||
private val json: Json,
|
||||
) {
|
||||
private val api: NotificationsApi = retrofit.create()
|
||||
|
||||
val settings: Flow<NotificationSettingsWire?> = dao.observeSettings().map { it?.let(::decode) }
|
||||
|
||||
/**
|
||||
* Takes the server's settings unless a change made here is still queued:
|
||||
* the server has not seen it, and its older value would undo the change
|
||||
* on screen until the replay lands. Throws on a network failure.
|
||||
*/
|
||||
suspend fun refresh() {
|
||||
val server = api.getSettings()
|
||||
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) return
|
||||
save(server)
|
||||
}
|
||||
|
||||
suspend fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||
current()?.let { save(it.withChannel(kind, channel, value)) }
|
||||
val payload = NotificationSettingPayload(kind = kind, channel = channel.wire, value = value)
|
||||
// An earlier change still queued would replay after this PUT; queue
|
||||
// behind it instead, and the replayer sends the newest per channel.
|
||||
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) {
|
||||
mutationQueue.enqueueNotificationSettingSet(payload)
|
||||
return
|
||||
}
|
||||
val change = notificationSettingChange(payload) ?: return
|
||||
try {
|
||||
save(api.putSettings(PutNotificationSettingsBody(listOf(change))))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.i(e, "notification settings: PUT failed; queued")
|
||||
mutationQueue.enqueueNotificationSettingSet(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun current(): NotificationSettingsWire? = dao.getSettings()?.let(::decode)
|
||||
|
||||
/** Null for an unreadable row: the next refresh writes a good one. */
|
||||
private fun decode(row: CachedNotificationSettingsEntity): NotificationSettingsWire? =
|
||||
runCatching { json.decodeFromString(NotificationSettingsWire.serializer(), row.json) }.getOrNull()
|
||||
|
||||
private suspend fun save(s: NotificationSettingsWire) {
|
||||
val encoded = json.encodeToString(NotificationSettingsWire.serializer(), s)
|
||||
dao.upsertSettings(CachedNotificationSettingsEntity(json = encoded))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NotificationSettingsWire.withChannel(
|
||||
kind: String,
|
||||
channel: NotificationChannel,
|
||||
value: Boolean,
|
||||
): NotificationSettingsWire = copy(
|
||||
kinds = kinds.map { k ->
|
||||
if (k.kind != kind) {
|
||||
k
|
||||
} else {
|
||||
when (channel) {
|
||||
NotificationChannel.INBOX -> k.copy(inbox = value)
|
||||
NotificationChannel.PHONE -> k.copy(phone = value)
|
||||
NotificationChannel.EMAIL -> k.copy(email = value)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* The notifications inbox (M489). The newest page lives in Room so the bell's
|
||||
* badge and the Notifications screen work offline. Reads follow rule 100: the
|
||||
* device marks the row at once, the call is best effort, and a failed call is
|
||||
* queued for the MutationReplayer.
|
||||
*/
|
||||
@Singleton
|
||||
class NotificationsRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val dao: CachedNotificationDao,
|
||||
private val mutationQueue: MutationQueue,
|
||||
) {
|
||||
private val api: NotificationsApi = retrofit.create()
|
||||
|
||||
val notifications: Flow<List<CachedNotificationEntity>> = dao.observeAll().distinctUntilChanged()
|
||||
|
||||
/** Unread notices in the cached page, which is what the badge shows. */
|
||||
val unreadCount: Flow<Int> = dao.observeUnreadCount().distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Replaces the cache with the server's newest page. A read made on this
|
||||
* device and not yet replayed stays read: a read never goes back to
|
||||
* unread, so the device's mark wins over the server's older view.
|
||||
* Throws on a network failure; the cache then stands.
|
||||
*/
|
||||
suspend fun refresh() {
|
||||
val page = api.list(limit = PAGE_SIZE)
|
||||
val localReads = dao.getAll().associate { it.id to it.readAt }
|
||||
dao.replaceAll(page.items.mapNotNull { it.toEntity(localReads[it.id]) })
|
||||
}
|
||||
|
||||
suspend fun markRead(id: String) {
|
||||
dao.markRead(id, Clock.System.now())
|
||||
try {
|
||||
api.markRead(id)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
// A 4xx (404: trimmed or gone server-side) leaves nothing to mark;
|
||||
// a 5xx is the server's trouble, so the read waits in the queue.
|
||||
if (e.code() >= HTTP_SERVER_ERROR) {
|
||||
mutationQueue.enqueueNotificationRead(id)
|
||||
} else {
|
||||
Timber.i(e, "notifications: mark read refused (%d)", e.code())
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.i(e, "notifications: mark read failed; queued")
|
||||
mutationQueue.enqueueNotificationRead(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks everything the device holds read. The server is asked to mark
|
||||
* only what existed up to the newest notice shown here, so a replay that
|
||||
* lands later leaves anything newer unread.
|
||||
*/
|
||||
suspend fun markAllRead() {
|
||||
val upTo = readAllCutoff(dao.getAll().map { it.createdAt })?.toString()
|
||||
dao.markAllRead(Clock.System.now())
|
||||
try {
|
||||
api.readAll(ReadAllBody(upTo = upTo))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.i(e, "notifications: mark all read failed; queued")
|
||||
mutationQueue.enqueueNotificationsReadAll(upTo)
|
||||
}
|
||||
}
|
||||
|
||||
/** The page the device holds, newest first. */
|
||||
suspend fun cachedPage(): List<CachedNotificationEntity> = dao.getAll()
|
||||
|
||||
/** On sign-out: the next account on this device must not see these. */
|
||||
suspend fun clearLocal() {
|
||||
dao.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The newest page the device keeps; older notices stay on the server. */
|
||||
const val PAGE_SIZE = 50
|
||||
|
||||
private const val HTTP_SERVER_ERROR = 500
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest notice shown, rounded up a millisecond. Room keeps milliseconds
|
||||
* and the server microseconds, so the stored time can sit just before the
|
||||
* server's; without rounding up, the newest notice would stay unread.
|
||||
*/
|
||||
internal fun readAllCutoff(createdAts: List<Instant>): Instant? =
|
||||
createdAts.maxOrNull()?.plus(1.milliseconds)
|
||||
|
||||
/** Null for a row whose timestamp will not parse, rather than failing the page. */
|
||||
internal fun NotificationWire.toEntity(localReadAt: Instant?): CachedNotificationEntity? {
|
||||
val created = runCatching { Instant.parse(createdAt) }.getOrNull() ?: return null
|
||||
val serverRead = readAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||
return CachedNotificationEntity(
|
||||
id = id,
|
||||
kind = kind,
|
||||
title = title,
|
||||
body = body,
|
||||
link = link,
|
||||
createdAt = created,
|
||||
readAt = serverRead ?: localReadAt,
|
||||
)
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Starts background delivery after a reboot, and after the app updates itself
|
||||
* (M489 #5347). Without it, notifications stop until the user next opens the
|
||||
* app, which is exactly when they least need telling.
|
||||
*
|
||||
* Both broadcasts are among the few exemptions allowed to start a foreground
|
||||
* service from the background, so the start happens here, inside the
|
||||
* receiver's window, rather than whenever [DeliveryLauncher] next observes it.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject lateinit var authStore: AuthStore
|
||||
|
||||
@Inject @ApplicationScope lateinit var scope: CoroutineScope
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||
val pending = goAsync()
|
||||
scope.launch {
|
||||
try {
|
||||
// Bounded (rule 156): the session load gives up after its
|
||||
// own deadline and answers from what it has.
|
||||
authStore.awaitSessionHydrated()
|
||||
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||
if (deliveryWanted(signedIn, authStore.backgroundDelivery.value)) {
|
||||
DeliveryService.start(context)
|
||||
}
|
||||
} finally {
|
||||
pending.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import android.content.Context
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Phone notifications (M489 #5347). Activated by force-@Inject in
|
||||
* MinstrelApplication, and the single owner of [DeliveryService]'s lifetime:
|
||||
* it runs exactly while signed in with "Notifications when the app is closed"
|
||||
* on. Signing out stops it, so a logged-out device holds no connection.
|
||||
*
|
||||
* The catch-up runs here, in the process, whether or not the service does:
|
||||
* with background delivery off, notifications still reach the shade while the
|
||||
* process is alive (the app open, or music playing).
|
||||
*/
|
||||
@Singleton
|
||||
class DeliveryLauncher @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
authStore: AuthStore,
|
||||
eventsStream: EventsStream,
|
||||
sync: NotificationSync,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) {
|
||||
init {
|
||||
val signedIn = authStore.sessionCookie.map { !it.isNullOrEmpty() }.distinctUntilChanged()
|
||||
|
||||
scope.launch {
|
||||
combine(signedIn, authStore.backgroundDelivery, ::deliveryWanted)
|
||||
.distinctUntilChanged()
|
||||
.collect { wanted -> if (wanted) DeliveryService.start(context) else DeliveryService.stop(context) }
|
||||
}
|
||||
|
||||
// A nudge and a reconnect mean the same thing: go and look. The
|
||||
// stream only runs while signed in, so neither fires signed out.
|
||||
scope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind == NOTIFICATION_CREATED }
|
||||
.collect { sync.catchUp() }
|
||||
}
|
||||
scope.launch {
|
||||
eventsStream.connected.filter { it }.collect { sync.catchUp() }
|
||||
}
|
||||
|
||||
// A sign-out, not the signed-out start a cold launch begins with
|
||||
// before the session loads: that would wipe the mark on every start.
|
||||
scope.launch {
|
||||
var wasSignedIn = false
|
||||
signedIn.collect { now ->
|
||||
if (wasSignedIn && !now) sync.forget()
|
||||
wasSignedIn = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NOTIFICATION_CREATED = "notification.created"
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* The decisions behind phone notifications (M489 #5347), kept free of Android
|
||||
* so they can be tested as plain functions.
|
||||
*/
|
||||
|
||||
/** A handful each get a line; more than this become one line with a count. */
|
||||
internal const val INDIVIDUAL_LIMIT = 3
|
||||
|
||||
/** What a catch-up announces, and the high-water mark to keep afterwards. */
|
||||
internal data class CatchUp(
|
||||
val announce: List<CachedNotificationEntity>,
|
||||
val upTo: Instant?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Picks what to announce from the newest page.
|
||||
*
|
||||
* - With no mark yet (a fresh sign-in on this device), the mark is set to the
|
||||
* newest notice and nothing is announced: the user is looking at the app,
|
||||
* and a backlog in the shade is noise. An empty inbox sets the mark to the
|
||||
* start of time, so the very first notice is announced.
|
||||
* - Otherwise: unread notices newer than the mark, whose kind has the phone
|
||||
* on, oldest first so the newest lands on top of the shade.
|
||||
* - The mark moves to the newest notice in the page, announced or not, so a
|
||||
* kind with the phone off is never announced later.
|
||||
*
|
||||
* A coalesced notice (tracks missing, now 5) moves its time forward when its
|
||||
* count grows, so it is announced again with the new count.
|
||||
*/
|
||||
internal fun planCatchUp(
|
||||
page: List<CachedNotificationEntity>,
|
||||
mark: Instant?,
|
||||
phoneOn: (String) -> Boolean,
|
||||
): CatchUp {
|
||||
val newest = page.maxOfOrNull { it.createdAt }
|
||||
if (mark == null) return CatchUp(emptyList(), newest ?: Instant.DISTANT_PAST)
|
||||
val fresh = page
|
||||
.filter { it.readAt == null && it.createdAt > mark && phoneOn(it.kind) }
|
||||
.sortedBy { it.createdAt }
|
||||
val upTo = if (newest != null && newest > mark) newest else mark
|
||||
return CatchUp(fresh, upTo)
|
||||
}
|
||||
|
||||
/** How a catch-up reaches the shade. */
|
||||
internal sealed interface Announcement {
|
||||
data class Each(val items: List<CachedNotificationEntity>) : Announcement
|
||||
|
||||
/** Coming back from a day offline is one line, not forty buzzes. */
|
||||
data class Pile(val count: Int, val channel: ShadeChannel) : Announcement
|
||||
}
|
||||
|
||||
internal fun announcementFor(fresh: List<CachedNotificationEntity>): Announcement? = when {
|
||||
fresh.isEmpty() -> null
|
||||
fresh.size <= INDIVIDUAL_LIMIT -> Announcement.Each(fresh)
|
||||
// A pile goes where its notices would: library health only when every
|
||||
// one of them is, so a listener's own news is never filed under it.
|
||||
fresh.all { shadeChannelFor(it.kind) == ShadeChannel.LIBRARY_HEALTH } ->
|
||||
Announcement.Pile(fresh.size, ShadeChannel.LIBRARY_HEALTH)
|
||||
else -> Announcement.Pile(fresh.size, ShadeChannel.YOUR_REQUESTS)
|
||||
}
|
||||
|
||||
/** The phone channel per kind, as the user set it. No settings cached: on, the default for every kind. */
|
||||
internal fun phoneOnFor(settings: NotificationSettingsWire?): (String) -> Boolean {
|
||||
val byKind = settings?.kinds?.associateBy { it.kind }.orEmpty()
|
||||
return { kind -> byKind[kind]?.let { it.inbox && it.phone } ?: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Android channels, so either audience can be silenced in system settings as
|
||||
* well as in Minstrel's. The ids are permanent: renaming one orphans the
|
||||
* user's system-level choice for it.
|
||||
*/
|
||||
internal enum class ShadeChannel(val id: String, val title: String, val description: String) {
|
||||
YOUR_REQUESTS("your_requests", "Your requests", "Approved, declined, and new music arriving"),
|
||||
LIBRARY_HEALTH("library_health", "Library health", "For admins: requests to review and library problems"),
|
||||
}
|
||||
|
||||
private val requesterKinds = setOf("request_approved", "request_rejected", "request_completed")
|
||||
|
||||
/** Everything a listener can receive is theirs; the rest is admin work. */
|
||||
internal fun shadeChannelFor(kind: String): ShadeChannel =
|
||||
if (kind in requesterKinds) ShadeChannel.YOUR_REQUESTS else ShadeChannel.LIBRARY_HEALTH
|
||||
|
||||
/** The delivery service runs exactly while signed in with background delivery on. */
|
||||
internal fun deliveryWanted(signedIn: Boolean, enabled: Boolean): Boolean = signedIn && enabled
|
||||
|
||||
/** One line for a pile. */
|
||||
internal fun pileText(count: Int): String = "$count new notifications"
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
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.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.R
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Keeps Minstrel's process alive so notifications arrive with the app closed
|
||||
* (M489 #5347, Roundtable's DeliveryService).
|
||||
*
|
||||
* **Why a held connection instead of push.** The APK installs from the
|
||||
* Minstrel server itself, not the Play Store, so nothing pushes the app onto
|
||||
* Firebase; the alternatives cost more (UnifiedPush's only embedded
|
||||
* distributor is Firebase, and ntfy is a second app to install and set up).
|
||||
*
|
||||
* **This service holds nothing itself.** [com.fabledsword.minstrel.events.EventsStream]
|
||||
* stays connected for as long as the process lives and someone is signed in,
|
||||
* and [DeliveryLauncher] runs the catch-up on every nudge and reconnect. All
|
||||
* this does is keep the process from being reclaimed. While music plays the
|
||||
* player's own foreground service does that too; this one still runs, so
|
||||
* delivery does not depend on playback.
|
||||
*
|
||||
* **Typed `specialUse`, and that is not arbitrary.** Android 15 stops a
|
||||
* `dataSync` service after six hours, and `shortService` is capped at three
|
||||
* minutes. `specialUse` is the only type that may run as long as the user
|
||||
* wants it to.
|
||||
*
|
||||
* The quiet notice in the shade is the honest price of not using Google's
|
||||
* push, and the settings screen says so.
|
||||
*/
|
||||
class DeliveryService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ensureChannel()
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, ongoing(), type)
|
||||
// If the system reclaims the process, bring it back: the point is to
|
||||
// be running when nothing else is.
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun ongoing(): Notification {
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("Minstrel")
|
||||
.setContentText("Listening for notifications")
|
||||
.setOngoing(true)
|
||||
.setContentIntent(open)
|
||||
// MIN: at the bottom of the shade, no sound, no heads-up. It has
|
||||
// to exist; it does not have to be loud.
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, "Background connection", NotificationManager.IMPORTANCE_MIN)
|
||||
.apply { description = "The quiet notice shown while Minstrel listens for notifications" },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "com.fabledsword.minstrel.DELIVERY_STOP"
|
||||
private const val CHANNEL_ID = "background_connection"
|
||||
private const val NOTIFICATION_ID = 4801
|
||||
|
||||
/**
|
||||
* Starting a foreground service from the background is refused on
|
||||
* Android 12+ outside the exemptions (boot, an update, the app on
|
||||
* screen). A refusal is logged, not thrown: the app's next start, or
|
||||
* the next boot, tries again.
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
try {
|
||||
context.startForegroundService(Intent(context, DeliveryService::class.java))
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
|
||||
) {
|
||||
Timber.w(e, "delivery service: start refused")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
try {
|
||||
context.startService(Intent(context, DeliveryService::class.java).setAction(ACTION_STOP))
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: RuntimeException,
|
||||
) {
|
||||
// Not running and not startable from here: nothing to stop.
|
||||
Timber.i(e, "delivery service: stop not delivered")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
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.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.R
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Puts an [Announcement] in the shade (M489 #5347). The words are the
|
||||
* server's: the same title and body the inbox shows. Tapping one opens what it
|
||||
* is about; tapping a pile opens the inbox.
|
||||
*/
|
||||
@Singleton
|
||||
class NotificationPoster @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
// Checked just below: posting is skipped when the user has said no.
|
||||
@SuppressLint("MissingPermission")
|
||||
internal fun post(announcement: Announcement) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
// Denied POST_NOTIFICATIONS, or every channel silenced: say nothing.
|
||||
// The inbox still has it all.
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
ensureChannels()
|
||||
when (announcement) {
|
||||
is Announcement.Each -> announcement.items.forEach { manager.notify(it.id.hashCode(), single(it)) }
|
||||
is Announcement.Pile -> manager.notify(PILE_ID, pile(announcement))
|
||||
}
|
||||
}
|
||||
|
||||
private fun single(item: CachedNotificationEntity) =
|
||||
NotificationCompat.Builder(context, shadeChannelFor(item.kind).id)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(item.title)
|
||||
.setContentText(item.body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(item.body))
|
||||
.setWhen(item.createdAt.toEpochMilliseconds())
|
||||
.setShowWhen(true)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openIntent(item.link, item.id.hashCode()))
|
||||
.build()
|
||||
|
||||
private fun pile(p: Announcement.Pile) =
|
||||
NotificationCompat.Builder(context, p.channel.id)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("Minstrel")
|
||||
.setContentText(pileText(p.count))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(openIntent(link = null, requestCode = PILE_ID))
|
||||
.build()
|
||||
|
||||
/** Each notice gets its own request code, so their links stay apart. */
|
||||
private fun openIntent(link: String?, requestCode: Int): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode,
|
||||
Intent(context, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra(MainActivity.EXTRA_NOTIFICATION_LINK, link.orEmpty()),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun ensureChannels() {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
ShadeChannel.entries.forEach { c ->
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(c.id, c.title, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
.apply { description = c.description },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PILE_ID = 4802
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Decides what the shade says by asking the server, never by trusting a frame
|
||||
* (M489 #5347, Roundtable's NotificationSync).
|
||||
*
|
||||
* A `notification.created` frame and a reconnect both mean "go and look", so
|
||||
* both run [catchUp]. The frame carries nothing, so nothing can be announced
|
||||
* twice: the answer is the server's unread state and this device's
|
||||
* high-water mark, which survive a reboot. A notice raised while the phone was
|
||||
* unreachable is not lost either: the event bus replays nothing, but the row
|
||||
* is still there, unread.
|
||||
*/
|
||||
@Singleton
|
||||
class NotificationSync @Inject constructor(
|
||||
private val notifications: NotificationsRepository,
|
||||
private val settings: NotificationSettingsRepository,
|
||||
private val prefs: AuthSessionDao,
|
||||
private val poster: NotificationPoster,
|
||||
) {
|
||||
// One catch-up at a time: a nudge arriving with a reconnect must not
|
||||
// read the mark before the other has moved it.
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Announces what is new since the mark, then moves it. Failure is quiet on
|
||||
* purpose: this runs on a reconnect, exactly when a request is most likely
|
||||
* to lose a race with a network still settling, and the next trigger tries
|
||||
* again. While the app is on screen nothing is posted (the bell shows it),
|
||||
* but the mark still moves, so it is not announced later either.
|
||||
*/
|
||||
suspend fun catchUp() {
|
||||
mutex.withLock { catchUpLocked() }
|
||||
}
|
||||
|
||||
private suspend fun catchUpLocked() {
|
||||
try {
|
||||
notifications.refresh()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.i(e, "notification catch-up: refresh failed; next trigger retries")
|
||||
return
|
||||
}
|
||||
val plan = planCatchUp(
|
||||
page = notifications.cachedPage(),
|
||||
mark = prefs.get()?.notifiedUpTo,
|
||||
phoneOn = phoneOnFor(settings.settings.first()),
|
||||
)
|
||||
if (!appOnScreen()) announcementFor(plan.announce)?.let(poster::post)
|
||||
plan.upTo?.let { prefs.setNotifiedUpTo(it) }
|
||||
}
|
||||
|
||||
/** On sign-out: the next account starts with no mark, so it announces no backlog. */
|
||||
suspend fun forget() {
|
||||
mutex.withLock { prefs.setNotifiedUpTo(null) }
|
||||
}
|
||||
|
||||
private suspend fun appOnScreen(): Boolean = withContext(Dispatchers.Main) {
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.ui
|
||||
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
|
||||
/**
|
||||
* The app screen for a notice's link. The server writes web paths, the same
|
||||
* for every client; this maps them onto the app's routes. Admin pages the app
|
||||
* has no screen for (missing files, duplicates, playback errors) open the
|
||||
* Admin landing; anything unknown opens nothing.
|
||||
*/
|
||||
internal fun routeForLink(link: String): Any? {
|
||||
val parts = link.trim('/').split('/').filter { it.isNotEmpty() }
|
||||
return when {
|
||||
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
|
||||
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
|
||||
parts == listOf("requests") -> Requests
|
||||
parts == listOf("admin", "requests") -> AdminRequests
|
||||
parts == listOf("admin", "quarantine") -> AdminQuarantine
|
||||
parts.firstOrNull() == "admin" -> Admin
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
-312
@@ -1,312 +0,0 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private row composables
|
||||
|
||||
package com.fabledsword.minstrel.notifications.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
|
||||
private val CELL_WIDTH = 56.dp
|
||||
|
||||
/**
|
||||
* Per-user notification settings (M489): a row per kind with a box for each
|
||||
* channel. Rows read as a menu (rule 188); anything that needs explaining
|
||||
* is one short line.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationSettingsScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||
val backgroundDelivery by viewModel.backgroundDelivery.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
title = "Notifications",
|
||||
navController = navController,
|
||||
currentRouteName = NotificationSettings::class.qualifiedName,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
val s = settings
|
||||
if (s == null) {
|
||||
LoadingCentered(modifier = Modifier.padding(inner))
|
||||
} else {
|
||||
SettingsBody(
|
||||
settings = s,
|
||||
isAdmin = isAdmin,
|
||||
deviceRows = { DeviceRows(backgroundDelivery, viewModel::setBackgroundDelivery) },
|
||||
onSet = viewModel::set,
|
||||
modifier = Modifier.padding(inner),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsBody(
|
||||
settings: NotificationSettingsWire,
|
||||
isAdmin: Boolean,
|
||||
deviceRows: @Composable () -> Unit,
|
||||
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
deviceRows()
|
||||
HeaderRow()
|
||||
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||
val adminKinds = settings.kinds.filter { it.adminOnly }
|
||||
if (adminKinds.isNotEmpty()) {
|
||||
SectionLabel("Library health")
|
||||
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||
}
|
||||
if (!settings.emailAvailable) {
|
||||
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderRow() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.weight(1f))
|
||||
NotificationChannel.entries.forEach { c ->
|
||||
Text(
|
||||
text = channelLabel(c),
|
||||
modifier = Modifier.width(CELL_WIDTH),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KindRow(
|
||||
row: NotificationKindSettingWire,
|
||||
emailAvailable: Boolean,
|
||||
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||
) {
|
||||
val label = kindLabel(row.kind)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
|
||||
NotificationChannel.entries.forEach { c ->
|
||||
val enabled = channelEnabled(row, c, emailAvailable)
|
||||
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
|
||||
Checkbox(
|
||||
checked = channelValue(row, c) && enabled,
|
||||
onCheckedChange = { onSet(row.kind, c, it) },
|
||||
enabled = enabled,
|
||||
modifier = Modifier.semantics { contentDescription = "$label: ${channelLabel(c)}" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What this device does, above the per-kind grid that follows the account:
|
||||
* whether notifications arrive with the app closed (M489 #5347), and whether
|
||||
* the system lets Minstrel post them at all.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
// Re-read on every resume: the user may come back from system settings.
|
||||
var phoneAllowed by remember { mutableStateOf(true) }
|
||||
LifecycleResumeEffect(Unit) {
|
||||
phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
val askToNotify = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
phoneAllowed = granted
|
||||
}
|
||||
BackgroundDeliveryRow(
|
||||
checked = backgroundDelivery,
|
||||
onChange = { on ->
|
||||
onBackgroundDelivery(on)
|
||||
// Android 13+: turning it on is the moment to ask, if not yet allowed.
|
||||
if (on && !phoneAllowed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
askToNotify.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (!phoneAllowed) {
|
||||
PhoneBlockedRow(onClick = { openAppNotificationSettings(context) })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge)
|
||||
// The ongoing notice is the price of not using Google's push;
|
||||
// said plainly rather than left to be discovered.
|
||||
Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge)
|
||||
Muted("Allow notifications for Minstrel in system settings", padded = false)
|
||||
}
|
||||
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Muted(text: String, padded: Boolean = true) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
private fun openAppNotificationSettings(context: Context) {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
/** Phone and email ride on the inbox; email also needs an address and SMTP. */
|
||||
internal fun channelEnabled(
|
||||
row: NotificationKindSettingWire,
|
||||
channel: NotificationChannel,
|
||||
emailAvailable: Boolean,
|
||||
): Boolean = when (channel) {
|
||||
NotificationChannel.INBOX -> true
|
||||
NotificationChannel.PHONE -> row.inbox
|
||||
NotificationChannel.EMAIL -> row.inbox && emailAvailable
|
||||
}
|
||||
|
||||
internal fun channelValue(row: NotificationKindSettingWire, channel: NotificationChannel): Boolean =
|
||||
when (channel) {
|
||||
NotificationChannel.INBOX -> row.inbox
|
||||
NotificationChannel.PHONE -> row.phone
|
||||
NotificationChannel.EMAIL -> row.email
|
||||
}
|
||||
|
||||
internal fun channelLabel(c: NotificationChannel): String = when (c) {
|
||||
NotificationChannel.INBOX -> "Inbox"
|
||||
NotificationChannel.PHONE -> "Phone"
|
||||
NotificationChannel.EMAIL -> "Email"
|
||||
}
|
||||
|
||||
/** Short row labels, the same as the web's. Unknown kinds show their key. */
|
||||
internal fun kindLabel(kind: String): String = when (kind) {
|
||||
"request_approved" -> "Request approved"
|
||||
"request_rejected" -> "Request declined"
|
||||
"request_completed" -> "New music arrived"
|
||||
"request_pending" -> "Requests to review"
|
||||
"quarantine_flagged" -> "Tracks flagged"
|
||||
"scan_failed" -> "Library scan failed"
|
||||
"tracks_missing" -> "Tracks gone missing"
|
||||
"duplicates_found" -> "Duplicates to review"
|
||||
"playback_errors" -> "Playback errors"
|
||||
else -> kind
|
||||
}
|
||||
|
||||
internal fun emailUnavailableLine(reason: String?, isAdmin: Boolean): String = when {
|
||||
reason == "no_address" -> "Email is off: add an email address in your profile"
|
||||
isAdmin -> "Email is off: SMTP isn't set up on the server"
|
||||
else -> "Email is off: this server doesn't send email"
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
@HiltViewModel
|
||||
class NotificationSettingsViewModel @Inject constructor(
|
||||
private val repository: NotificationSettingsRepository,
|
||||
authController: AuthController,
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
/** "Notifications when the app is closed": this device's choice (M489 #5347). */
|
||||
val backgroundDelivery: StateFlow<Boolean> = authStore.backgroundDelivery
|
||||
|
||||
val settings: StateFlow<NotificationSettingsWire?> = repository.settings
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
||||
|
||||
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||
.map { it?.isAdmin == true }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), false)
|
||||
|
||||
init {
|
||||
// Offline, the device's copy stands.
|
||||
viewModelScope.launch { runCatching { repository.refresh() } }
|
||||
}
|
||||
|
||||
fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||
viewModelScope.launch { repository.set(kind, channel, value) }
|
||||
}
|
||||
|
||||
fun setBackgroundDelivery(on: Boolean) {
|
||||
authStore.setBackgroundDelivery(on)
|
||||
}
|
||||
}
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Bell
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import com.fabledsword.minstrel.nav.Notifications
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The notifications inbox (M489): one row per notice, its title on one line
|
||||
* and how long ago. Tapping a row marks it read and opens what it is about.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: NotificationsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val items by viewModel.items.collectAsStateWithLifecycle()
|
||||
val anyUnread = items?.any { it.readAt == null } == true
|
||||
Scaffold(
|
||||
contentWindowInsets = ShellContentWindowInsets,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
title = "Notifications",
|
||||
navController = navController,
|
||||
currentRouteName = Notifications::class.qualifiedName,
|
||||
onBack = { navController.popBackStack() },
|
||||
actions = {
|
||||
if (anyUnread) {
|
||||
TextButton(onClick = viewModel::markAllRead) { Text("Mark all read") }
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
val list = items
|
||||
when {
|
||||
list == null -> LoadingCentered()
|
||||
list.isEmpty() -> EmptyState(
|
||||
title = "Nothing waiting for you",
|
||||
body = "Requests, new music and anything needing a look will gather here.",
|
||||
icon = Lucide.Bell,
|
||||
)
|
||||
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(list, key = { it.id }) { item ->
|
||||
NotificationRow(
|
||||
item = item,
|
||||
onClick = {
|
||||
viewModel.open(item)
|
||||
routeForLink(item.link)?.let { navController.navigate(it) }
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationRow(item: CachedNotificationEntity, onClick: () -> Unit) {
|
||||
val unread = item.readAt == null
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
if (unread) contentDescription = "Unread: ${item.title}"
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.then(
|
||||
if (unread) {
|
||||
Modifier.background(MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = item.title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (unread) FontWeight.Medium else FontWeight.Normal,
|
||||
color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = ago(item.createdAt),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "just now", "12m", "5h", "3d": the same coarse steps as the web inbox. */
|
||||
internal fun ago(at: Instant, now: Instant = Clock.System.now()): String {
|
||||
val d = now - at
|
||||
return when {
|
||||
d >= 1.days -> "${d.inWholeDays}d"
|
||||
d >= 1.hours -> "${d.inWholeHours}h"
|
||||
d >= 1.minutes -> "${d.inWholeMinutes}m"
|
||||
else -> "just now"
|
||||
}
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.recoveries
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** `items` is null until the cache has been read once, so the screen can tell "loading" from "empty". */
|
||||
@HiltViewModel
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val repository: NotificationsRepository,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
val items: StateFlow<List<CachedNotificationEntity>?> = repository.notifications
|
||||
.map<List<CachedNotificationEntity>, List<CachedNotificationEntity>?> { it }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Back online: the cached page may be stale (#1245's recovery idiom).
|
||||
viewModelScope.launch { networkStatus.recoveries().collect { refresh() } }
|
||||
}
|
||||
|
||||
/** Offline, the cached page stands. */
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
runCatching { repository.refresh() }
|
||||
}
|
||||
|
||||
fun open(item: CachedNotificationEntity) {
|
||||
if (item.readAt != null) return
|
||||
viewModelScope.launch { repository.markRead(item.id) }
|
||||
}
|
||||
|
||||
fun markAllRead() {
|
||||
viewModelScope.launch { repository.markAllRead() }
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,6 @@ class MinstrelForwardingPlayer(
|
||||
* one. Diagnostics-only; see [TransportObservation].
|
||||
*/
|
||||
val onTransport: (TransportObservation) -> Unit = {},
|
||||
/** The renderer's 1-based queue position, every poll. */
|
||||
val onRendererTrack: (trackNumber: Int) -> Unit = {},
|
||||
)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -624,7 +622,6 @@ class MinstrelForwardingPlayer(
|
||||
trackNumber = info.track,
|
||||
)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
events.onRendererTrack(info.track)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
|
||||
@@ -12,10 +12,8 @@ import androidx.media3.session.SessionCommand
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||
import com.google.common.collect.ImmutableList
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -27,7 +25,6 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -69,8 +66,6 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
@Inject lateinit var likesRepository: LikesRepository
|
||||
|
||||
@Inject lateinit var normalizationRepository: NormalizationRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
@@ -87,21 +82,6 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
.build()
|
||||
mediaSession = session
|
||||
serviceScope.launch { observeLikeState(session, player) }
|
||||
serviceScope.launch { refreshNormalization() }
|
||||
}
|
||||
|
||||
// Takes up a leveling preference changed on another device (M464 #5000).
|
||||
// Offline, the device's copy stands, which is the one playback reads.
|
||||
private suspend fun refreshNormalization() {
|
||||
try {
|
||||
normalizationRepository.refresh()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.d(e, "normalization refresh skipped")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,6 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
@@ -71,7 +69,6 @@ class PlayerController @Inject constructor(
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val replayGains: ReplayGainStore,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -280,11 +277,6 @@ class PlayerController @Inject constructor(
|
||||
queueRefs = playable.tracks
|
||||
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||
val startIndex = playable.initialIndex
|
||||
// Gains for the first tracks, so the first one levels from its first
|
||||
// sample rather than ramping in once the lookup lands.
|
||||
replayGains.request(
|
||||
playable.tracks.drop(startIndex).take(GAIN_PREFETCH).map { it.id },
|
||||
)
|
||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||
// context after awaitReady() unblocks (ResumeController launches
|
||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||
@@ -800,13 +792,7 @@ class PlayerController @Inject constructor(
|
||||
// scrubber a real total even when the wrapped ExoPlayer is
|
||||
// paused under UPnP (it never probes a duration in that state).
|
||||
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
||||
// Album and track position let the gain processor tell an
|
||||
// album played in order from a mix (M464 #5000).
|
||||
trackNumber?.let { setTrackNumber(it) }
|
||||
discNumber?.let { setDiscNumber(it) }
|
||||
val extras = GainAudioProcessor.albumExtras(albumId)
|
||||
if (source != null) extras.putAll(sourceExtras(source))
|
||||
setExtras(extras)
|
||||
if (source != null) setExtras(sourceExtras(source))
|
||||
// Point the notification / lock-screen art at the SAME album
|
||||
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
||||
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
||||
@@ -874,7 +860,6 @@ class PlayerController @Inject constructor(
|
||||
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
||||
private const val MS_PER_SECOND = 1_000L
|
||||
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
||||
private const val GAIN_PREFETCH = 20
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.audio.AudioProcessor
|
||||
import androidx.media3.common.util.BitmapLoader
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
@@ -13,18 +12,11 @@ import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
|
||||
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -64,9 +56,6 @@ class PlayerFactory @Inject constructor(
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
private val authStore: AuthStore,
|
||||
private val replayGains: ReplayGainStore,
|
||||
private val sonosQueue: SonosQueueLoader,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -131,7 +120,6 @@ class PlayerFactory @Inject constructor(
|
||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
|
||||
onTransport = { transportInternal.tryEmit(it) },
|
||||
onRendererTrack = { sonosQueue.onRendererTrack(it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -154,28 +142,7 @@ class PlayerFactory @Inject constructor(
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(cacheDataSource)
|
||||
|
||||
// Loudness normalization (M464 #5000) runs inside the audio sink so
|
||||
// each track's gain starts on its first sample. Audio offload would
|
||||
// bypass the sink's processors; ExoPlayer leaves it off unless asked
|
||||
// (TrackSelectionParameters.audioOffloadPreferences), and nothing here
|
||||
// asks, so leveling always applies.
|
||||
val gainProcessor = GainAudioProcessor(
|
||||
prefs = { authStore.normalization.value },
|
||||
store = replayGains,
|
||||
)
|
||||
val renderersFactory = object : DefaultRenderersFactory(context) {
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean,
|
||||
): AudioSink = DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||
.setAudioProcessors(arrayOf<AudioProcessor>(gainProcessor))
|
||||
.build()
|
||||
}
|
||||
|
||||
val exo = ExoPlayer.Builder(context, renderersFactory)
|
||||
return ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
@@ -186,17 +153,6 @@ class PlayerFactory @Inject constructor(
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
// The processor finds a track's neighbours in play order, which
|
||||
// depends on shuffle mode.
|
||||
gainProcessor.shuffleEnabled = exo.shuffleModeEnabled
|
||||
exo.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
gainProcessor.shuffleEnabled = shuffleModeEnabled
|
||||
}
|
||||
},
|
||||
)
|
||||
return exo
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,13 +19,6 @@ import javax.inject.Singleton
|
||||
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
||||
private val api: CastApi = retrofit.create()
|
||||
|
||||
/**
|
||||
* Mints a URL for a speaker, leveled when the user's setting calls for
|
||||
* it (M464 #5002). The server answers with the plain stream when it does
|
||||
* not, so every speaker URL asks.
|
||||
*/
|
||||
suspend fun mint(trackId: String, asAlbum: Boolean = false, prerender: Boolean = false): StreamTokenResponse =
|
||||
api.streamToken(
|
||||
StreamTokenRequest(trackId = trackId, level = true, asAlbum = asAlbum, prerender = prerender),
|
||||
)
|
||||
suspend fun mint(trackId: String): StreamTokenResponse =
|
||||
api.streamToken(StreamTokenRequest(trackId = trackId))
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.audio.AudioProcessor
|
||||
import androidx.media3.common.audio.BaseAudioProcessor
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Applies each track's loudness gain inside ExoPlayer's audio sink (M464
|
||||
* #5000), so the level changes on the exact sample a track starts, gapless
|
||||
* transitions included. `Player.setVolume` could do neither: it stops at 1,
|
||||
* and set from a transition callback it lands about two seconds late
|
||||
* because the next track is already buffered (androidx/media#418).
|
||||
*
|
||||
* Media3 1.11 flushes the sink's processors at every item boundary with the
|
||||
* playlist [Timeline] and the new item's period, which is how this knows
|
||||
* which track it is processing and who its neighbours are.
|
||||
*
|
||||
* Boosts above unity are held under -1 dBFS by a peak limiter when the user
|
||||
* chose one. In headroom mode the gain already stops short of the track's
|
||||
* true peak, and the final clamp only guards against a bad measurement.
|
||||
*
|
||||
* Runs on the playback thread. [prefs] and [store] are read per buffer, so a
|
||||
* changed preference or a gain that arrives mid-track applies at once,
|
||||
* through a short ramp rather than a step.
|
||||
*/
|
||||
class GainAudioProcessor(
|
||||
private val prefs: () -> NormalizationPrefs,
|
||||
private val store: ReplayGainStore,
|
||||
) : BaseAudioProcessor() {
|
||||
|
||||
/** Mirrors the player's shuffle mode, so neighbours are found in play order. */
|
||||
@Volatile var shuffleEnabled: Boolean = false
|
||||
|
||||
private var currentId: String? = null
|
||||
private var asAlbum = false
|
||||
private val stage = GainStage()
|
||||
|
||||
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat =
|
||||
when (inputAudioFormat.encoding) {
|
||||
C.ENCODING_PCM_16BIT, C.ENCODING_PCM_FLOAT -> inputAudioFormat
|
||||
else -> AudioProcessor.AudioFormat.NOT_SET
|
||||
}
|
||||
|
||||
override fun onFlush(streamMetadata: AudioProcessor.StreamMetadata) {
|
||||
identify(streamMetadata)
|
||||
// A new stream starts at its own level: ramping in from the previous
|
||||
// track's gain would swell or dip its first moments.
|
||||
stage.reset(inputAudioFormat.sampleRate, inputAudioFormat.channelCount, targetGain())
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
currentId = null
|
||||
asAlbum = false
|
||||
}
|
||||
|
||||
override fun queueInput(inputBuffer: ByteBuffer) {
|
||||
val size = inputBuffer.remaining()
|
||||
if (size == 0) return
|
||||
val out = replaceOutputBuffer(size)
|
||||
val p = prefs()
|
||||
val target = targetGain(p)
|
||||
val limiting = p.mode != NormalizationMode.OFF && p.boost == NormalizationBoost.LIMITER
|
||||
if (stage.isUnity(target, limiting)) {
|
||||
out.put(inputBuffer)
|
||||
} else {
|
||||
stage.process(
|
||||
input = inputBuffer,
|
||||
out = out,
|
||||
channels = inputAudioFormat.channelCount,
|
||||
isFloat = inputAudioFormat.encoding == C.ENCODING_PCM_FLOAT,
|
||||
target = target,
|
||||
limiting = limiting,
|
||||
)
|
||||
}
|
||||
out.flip()
|
||||
}
|
||||
|
||||
private fun targetGain(p: NormalizationPrefs = prefs()): Float {
|
||||
val id = currentId ?: return 1f
|
||||
return GainMath.dbToLinear(GainMath.gainDb(p, store.get(id), asAlbum))
|
||||
}
|
||||
|
||||
// Which track this stream is, and whether it is playing as part of its
|
||||
// album. Also asks the store for the gains of the tracks coming up, so
|
||||
// each is known before it starts.
|
||||
private fun identify(meta: AudioProcessor.StreamMetadata) {
|
||||
currentId = null
|
||||
asAlbum = false
|
||||
val uid = meta.periodUid
|
||||
if (uid == null || meta.timeline.isEmpty) return
|
||||
val timeline = meta.timeline
|
||||
val index = timeline.getPeriodByUid(uid, Timeline.Period()).windowIndex
|
||||
val window = Timeline.Window()
|
||||
fun itemAt(i: Int): MediaItem? =
|
||||
if (i == C.INDEX_UNSET) null else timeline.getWindow(i, window).mediaItem
|
||||
fun nextOf(i: Int) = timeline.getNextWindowIndex(i, Player.REPEAT_MODE_OFF, shuffleEnabled)
|
||||
|
||||
val cur = itemAt(index)
|
||||
val prev = itemAt(timeline.getPreviousWindowIndex(index, Player.REPEAT_MODE_OFF, shuffleEnabled))
|
||||
val next = itemAt(nextOf(index))
|
||||
if (cur != null) {
|
||||
currentId = cur.mediaId
|
||||
asAlbum = GainMath.playingAsAlbum(prev?.albumPosition(), cur.albumPosition(), next?.albumPosition())
|
||||
}
|
||||
|
||||
val upcoming = mutableListOf<String>()
|
||||
var i = index
|
||||
while (i != C.INDEX_UNSET && upcoming.size < LOOKAHEAD) {
|
||||
itemAt(i)?.let { upcoming += it.mediaId }
|
||||
i = nextOf(i)
|
||||
}
|
||||
store.request(upcoming)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** MediaMetadata extras key holding the track's album id. */
|
||||
const val EXTRA_ALBUM_ID = "minstrel.album_id"
|
||||
|
||||
private const val LOOKAHEAD = 20
|
||||
|
||||
/** Puts what [albumPosition] reads into a MediaItem's metadata extras. */
|
||||
fun albumExtras(albumId: String, into: Bundle = Bundle()): Bundle =
|
||||
into.apply { putString(EXTRA_ALBUM_ID, albumId) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaItem.albumPosition(): AlbumPosition = AlbumPosition(
|
||||
albumId = mediaMetadata.extras?.getString(GainAudioProcessor.EXTRA_ALBUM_ID),
|
||||
discNumber = mediaMetadata.discNumber,
|
||||
trackNumber = mediaMetadata.trackNumber,
|
||||
)
|
||||
@@ -1,96 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* One track's ReplayGain 2.0 values from the server (#4997): dB to the
|
||||
* -18 LUFS reference, and linear true peaks. A null field has not been
|
||||
* measured yet.
|
||||
*/
|
||||
data class ReplayGain(
|
||||
val trackGain: Float?,
|
||||
val trackPeak: Float?,
|
||||
val albumGain: Float?,
|
||||
val albumPeak: Float?,
|
||||
) {
|
||||
companion object {
|
||||
val NONE = ReplayGain(null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a queue item sits in its album, for the auto-mode album rule. */
|
||||
data class AlbumPosition(val albumId: String?, val discNumber: Int?, val trackNumber: Int?)
|
||||
|
||||
/**
|
||||
* Loudness-normalization math (M464 #5000). The same rules as the web
|
||||
* player's `web/src/lib/player/gain.ts`, so a track levels the same on
|
||||
* every device.
|
||||
*/
|
||||
object GainMath {
|
||||
private const val REFERENCE_LUFS = -18
|
||||
|
||||
/** Headroom mode raises a quiet track only until its true peak reaches this. */
|
||||
const val PEAK_CEILING_DBTP = -1f
|
||||
|
||||
/**
|
||||
* No track is raised more than this, whatever its measurement says: a
|
||||
* near-silent track would otherwise come out as amplified noise.
|
||||
*/
|
||||
const val MAX_BOOST_DB = 12f
|
||||
|
||||
private const val DISC_STRIDE = 1000
|
||||
|
||||
// Amplitude decibels: 20 dB per factor of ten.
|
||||
private const val DB_PER_DECADE = 20f
|
||||
|
||||
/**
|
||||
* The gain to apply, in dB. 0 when leveling is off or the track has not
|
||||
* been measured: an unmeasured track plays as mastered.
|
||||
*/
|
||||
fun gainDb(prefs: NormalizationPrefs, g: ReplayGain?, asAlbum: Boolean): Float {
|
||||
if (prefs.mode == NormalizationMode.OFF || g == null) return 0f
|
||||
val wantAlbum = prefs.mode == NormalizationMode.ALBUM ||
|
||||
(prefs.mode == NormalizationMode.AUTO && asAlbum)
|
||||
// Album gain falls back to track gain while the album is still being
|
||||
// measured; track gain never falls back to album gain.
|
||||
val useAlbum = wantAlbum && g.albumGain != null
|
||||
val gain = if (useAlbum) g.albumGain else g.trackGain
|
||||
val peak = if (useAlbum) g.albumPeak else g.trackPeak
|
||||
return gain?.let { leveled(prefs, it, peak) } ?: 0f
|
||||
}
|
||||
|
||||
private fun leveled(prefs: NormalizationPrefs, gain: Float, peak: Float?): Float {
|
||||
var db = gain + (prefs.targetLufs - REFERENCE_LUFS)
|
||||
if (prefs.boost == NormalizationBoost.HEADROOM && peak != null && peak > 0f) {
|
||||
db = min(db, PEAK_CEILING_DBTP - DB_PER_DECADE * log10(peak))
|
||||
}
|
||||
return min(db, MAX_BOOST_DB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current item is being played as part of its album, in
|
||||
* order: a neighbour in play order is from the same album and sits on
|
||||
* the right side of it. That is when album gain keeps the album's own
|
||||
* dynamics (a quiet intro stays quiet); anywhere else track gain levels
|
||||
* the mix.
|
||||
*/
|
||||
fun playingAsAlbum(prev: AlbumPosition?, cur: AlbumPosition, next: AlbumPosition?): Boolean {
|
||||
val curOrder = order(cur)
|
||||
if (cur.albumId == null || curOrder == null) return false
|
||||
val prevOrder = prev?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
|
||||
val nextOrder = next?.takeIf { it.albumId == cur.albumId }?.let { order(it) }
|
||||
return (prevOrder != null && prevOrder < curOrder) || (nextOrder != null && nextOrder > curOrder)
|
||||
}
|
||||
|
||||
// Disc-major track order. A track with no number has no place in the
|
||||
// order and is never evidence of album play.
|
||||
private fun order(p: AlbumPosition): Int? =
|
||||
p.trackNumber?.let { (p.discNumber ?: 1) * DISC_STRIDE + it }
|
||||
|
||||
fun dbToLinear(db: Float): Float = 10f.pow(db / DB_PER_DECADE)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The sample arithmetic of [GainAudioProcessor], apart from Media3 so it can
|
||||
* be tested on the JVM: a gain that ramps toward its target, then an
|
||||
* optional peak limiter, then a clamp to full scale. Interleaved 16-bit or
|
||||
* float PCM in, the same format out.
|
||||
*/
|
||||
internal class GainStage {
|
||||
private var gain = 1f
|
||||
private var envelope = 0f
|
||||
private var rampCoeff = 1f
|
||||
private var releaseCoeff = 0f
|
||||
private var frame = FloatArray(2)
|
||||
|
||||
/** Sets the sample rate and channel count, and starts the gain at [startGain]. */
|
||||
fun reset(sampleRate: Int, channels: Int, startGain: Float) {
|
||||
val rate = sampleRate.coerceAtLeast(1).toFloat()
|
||||
rampCoeff = 1f - exp(-1f / (RAMP_SECONDS * rate))
|
||||
releaseCoeff = exp(-1f / (RELEASE_SECONDS * rate))
|
||||
if (frame.size < channels) frame = FloatArray(channels)
|
||||
gain = startGain
|
||||
envelope = 0f
|
||||
}
|
||||
|
||||
/** True when processing would copy the input unchanged. */
|
||||
fun isUnity(target: Float, limiting: Boolean): Boolean = gain == 1f && target == 1f && !limiting
|
||||
|
||||
/**
|
||||
* Processes every whole frame of [input] into [out]. A trailing partial
|
||||
* frame is dropped, as Media3's own processors do.
|
||||
*/
|
||||
@Suppress("LongParameterList") // the PCM layout is four facts; a holder type would only rename them
|
||||
fun process(
|
||||
input: ByteBuffer,
|
||||
out: ByteBuffer,
|
||||
channels: Int,
|
||||
isFloat: Boolean,
|
||||
target: Float,
|
||||
limiting: Boolean,
|
||||
) {
|
||||
val bytesPerFrame = channels * if (isFloat) FLOAT_BYTES else PCM16_BYTES
|
||||
val frames = input.remaining() / bytesPerFrame
|
||||
repeat(frames) {
|
||||
gain += (target - gain) * rampCoeff
|
||||
var peak = 0f
|
||||
for (c in 0 until channels) {
|
||||
val s = (if (isFloat) input.float else input.short / PCM16_SCALE) * gain
|
||||
frame[c] = s
|
||||
peak = max(peak, abs(s))
|
||||
}
|
||||
var reduce = 1f
|
||||
if (limiting) {
|
||||
envelope = max(peak, envelope * releaseCoeff)
|
||||
if (envelope > LIMIT_CEILING) reduce = LIMIT_CEILING / envelope
|
||||
}
|
||||
for (c in 0 until channels) {
|
||||
val v = (frame[c] * reduce).coerceIn(-1f, 1f)
|
||||
if (isFloat) out.putFloat(v) else out.putShort((v * PCM16_MAX).roundToInt().toShort())
|
||||
}
|
||||
}
|
||||
input.position(input.limit())
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** -1 dBFS, the ceiling the web player's limiter holds too. */
|
||||
const val LIMIT_CEILING = 0.8913f
|
||||
|
||||
private const val RAMP_SECONDS = 0.05f
|
||||
private const val RELEASE_SECONDS = 0.25f
|
||||
private const val PCM16_SCALE = 32768f
|
||||
private const val PCM16_MAX = 32767f
|
||||
private const val PCM16_BYTES = 2
|
||||
private const val FLOAT_BYTES = 4
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The gains the player levels by (M464 #5000), looked up per track and
|
||||
* held for the life of the process. The audio thread reads them with [get];
|
||||
* [request] fills them in the background.
|
||||
*
|
||||
* Sources, most to least preferred:
|
||||
* 1. the library cache, synced with the gains, so cached audio levels
|
||||
* offline;
|
||||
* 2. the server's replay-gain lookup, for a track not cached yet or
|
||||
* measured since the last sync;
|
||||
* 3. none: the track plays as mastered, and the player picks the gain up
|
||||
* the moment it lands.
|
||||
*/
|
||||
@Singleton
|
||||
class ReplayGainStore internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val api: ReplayGainApi,
|
||||
private val serverHealth: () -> ServerHealth,
|
||||
private val clock: () -> Long,
|
||||
) {
|
||||
@Inject constructor(
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
trackDao: CachedTrackDao,
|
||||
retrofit: Retrofit,
|
||||
network: NetworkStatusController,
|
||||
) : this(
|
||||
scope = scope,
|
||||
trackDao = trackDao,
|
||||
api = retrofit.create(),
|
||||
serverHealth = { network.state.value },
|
||||
clock = System::currentTimeMillis,
|
||||
)
|
||||
|
||||
private val gains = ConcurrentHashMap<String, ReplayGain>()
|
||||
private val inflight: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
// When the server last said it had nothing for a track. Not cached for
|
||||
// good: the backfill may measure it minutes later.
|
||||
private val missedAt = ConcurrentHashMap<String, Long>()
|
||||
|
||||
/** The track's gains, or null while they are unknown. Safe from any thread. */
|
||||
fun get(trackId: String): ReplayGain? = gains[trackId]
|
||||
|
||||
/** Starts loading gains for any of [trackIds] not already known or loading. */
|
||||
fun request(trackIds: Collection<String>) {
|
||||
val now = clock()
|
||||
val wanted = trackIds.filter { id ->
|
||||
!gains.containsKey(id) &&
|
||||
(missedAt[id]?.let { now - it > MISS_RETRY_MS } ?: true) &&
|
||||
inflight.add(id)
|
||||
}
|
||||
if (wanted.isEmpty()) return
|
||||
scope.launch {
|
||||
try {
|
||||
load(wanted)
|
||||
} finally {
|
||||
inflight.removeAll(wanted.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun load(ids: List<String>) {
|
||||
fromCache(ids)
|
||||
val rest = ids.filter { !gains.containsKey(it) }
|
||||
val health = serverHealth()
|
||||
if (rest.isEmpty() || health == ServerHealth.Offline || health == ServerHealth.ServerDown) return
|
||||
for (batch in rest.chunked(MAX_IDS_PER_REQUEST)) {
|
||||
// A failed lookup leaves the batch unknown; the next request asks again.
|
||||
if (!fromServer(batch)) return
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fromCache(ids: List<String>) {
|
||||
val rows = try {
|
||||
trackDao.replayGains(ids)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "replay gain: cache read failed")
|
||||
emptyList()
|
||||
}
|
||||
for (row in rows) {
|
||||
// A cached row without a track gain is not an answer: the server
|
||||
// may have measured the track since this device last synced.
|
||||
if (row.trackGain == null) continue
|
||||
gains[row.id] = ReplayGain(row.trackGain, row.trackPeak, row.albumGain, row.albumPeak)
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks the server about [batch]; false when it could not be asked. */
|
||||
private suspend fun fromServer(batch: List<String>): Boolean {
|
||||
val res = try {
|
||||
withTimeoutOrNull(REQUEST_TIMEOUT_MS) { api.getReplayGain(batch.joinToString(",")) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "replay gain: lookup failed")
|
||||
null
|
||||
} ?: return false
|
||||
val now = clock()
|
||||
for (id in batch) {
|
||||
val w = res.items[id]
|
||||
if (w == null) {
|
||||
missedAt[id] = now
|
||||
} else {
|
||||
gains[id] = ReplayGain(w.trackGain, w.trackPeak, w.albumGain, w.albumPeak)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_IDS_PER_REQUEST = 200 // the endpoint's limit
|
||||
const val REQUEST_TIMEOUT_MS = 10_000L
|
||||
const val MISS_RETRY_MS = 5 * 60_000L
|
||||
}
|
||||
}
|
||||
+22
-74
@@ -4,8 +4,6 @@ import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||
import com.fabledsword.minstrel.player.gain.AlbumPosition
|
||||
import com.fabledsword.minstrel.player.gain.GainMath
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
@@ -26,11 +24,6 @@ import javax.inject.Singleton
|
||||
* with its own failure modes, and it had grown large enough to hide one:
|
||||
* every write here is a SOAP call that can fail individually, and until
|
||||
* [verifyQueueLength] nothing ever read the result back.
|
||||
*
|
||||
* Every URL sent is a leveled one (M464 #5002): the server renders the track
|
||||
* at the user's loudness gain, or hands back the plain stream when leveling
|
||||
* is off. Renders are slow enough to matter, so the track playing and the
|
||||
* one after it are rendered ahead; the rest render when the speaker asks.
|
||||
*/
|
||||
@Singleton
|
||||
class SonosQueueLoader @Inject constructor(
|
||||
@@ -39,13 +32,6 @@ class SonosQueueLoader @Inject constructor(
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
) {
|
||||
// The queue as last sent to the renderer, for looking up the track after
|
||||
// the one it is playing.
|
||||
@Volatile private var sent: List<TrackRef> = emptyList()
|
||||
|
||||
// The renderer track the next one was last prerendered for.
|
||||
@Volatile private var prerenderedAfter = 0
|
||||
|
||||
suspend fun load(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
@@ -60,10 +46,8 @@ class SonosQueueLoader @Inject constructor(
|
||||
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
||||
initialBatch.size, currentIndex, queue.size,
|
||||
)
|
||||
sent = queue
|
||||
prerenderedAfter = currentIndex + 1
|
||||
initialBatch.indices.forEach { idx ->
|
||||
val token = mint(queue, idx, prerender = idx == currentIndex)
|
||||
initialBatch.forEachIndexed { idx, ref ->
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
@@ -80,12 +64,12 @@ class SonosQueueLoader @Inject constructor(
|
||||
Timber.w("UPnP select: Play")
|
||||
transport.play()
|
||||
Timber.w("UPnP select: initial done; backgrounding remainder")
|
||||
val remaining = queue.drop(initialEnd)
|
||||
// Verify even when there is no tail to append: the initial batch is
|
||||
// sent the same way and can be dropped the same way.
|
||||
scope.launch {
|
||||
prerender(queue, currentIndex + 1)
|
||||
if (initialEnd < queue.size) {
|
||||
extendQueueOnSonos(transport, route, queue, initialEnd)
|
||||
if (remaining.isNotEmpty()) {
|
||||
extendQueueOnSonos(transport, route, remaining, initialEnd)
|
||||
}
|
||||
verifyQueueLength(transport, route, queue)
|
||||
}
|
||||
@@ -100,16 +84,15 @@ class SonosQueueLoader @Inject constructor(
|
||||
private suspend fun extendQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
queue: List<TrackRef>,
|
||||
tracks: List<TrackRef>,
|
||||
startPosition: Int,
|
||||
) {
|
||||
val count = queue.size - startPosition
|
||||
Timber.w(
|
||||
"UPnP extend: appending %d tracks starting at position %d",
|
||||
count, startPosition + 1,
|
||||
tracks.size, startPosition + 1,
|
||||
)
|
||||
val succeeded = appendTracksToQueue(transport, route, queue, startPosition)
|
||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, count)
|
||||
val succeeded = appendTracksToQueue(transport, route, tracks, startPosition)
|
||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,17 +136,18 @@ class SonosQueueLoader @Inject constructor(
|
||||
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
|
||||
return
|
||||
}
|
||||
val missing = fullQueue.drop(nrTracks)
|
||||
Timber.w(
|
||||
"UPnP verify: %s holds %d of %d tracks; appending %d missing (round %d)",
|
||||
route.name, nrTracks, fullQueue.size, fullQueue.size - nrTracks, round + 1,
|
||||
route.name, nrTracks, fullQueue.size, missing.size, round + 1,
|
||||
)
|
||||
appendTracksToQueue(transport, route, fullQueue, nrTracks)
|
||||
appendTracksToQueue(transport, route, missing, nrTracks)
|
||||
}
|
||||
Timber.w("UPnP verify: gave up repairing queue length on %s", route.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append [queue] from [startPosition] (0-based) on, returning how many landed.
|
||||
* Append [tracks] at [startPosition] (0-based), returning how many landed.
|
||||
* Tolerates individual AddURIToQueue failures — log and continue so some
|
||||
* tracks loaded is better than zero tracks loaded — and stops early after
|
||||
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones.
|
||||
@@ -171,20 +155,20 @@ class SonosQueueLoader @Inject constructor(
|
||||
private suspend fun appendTracksToQueue(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
queue: List<TrackRef>,
|
||||
tracks: List<TrackRef>,
|
||||
startPosition: Int,
|
||||
): Int {
|
||||
var consecutiveFailures = 0
|
||||
var succeeded = 0
|
||||
var aborted = false
|
||||
for (i in 0 until queue.size - startPosition) {
|
||||
for ((i, ref) in tracks.withIndex()) {
|
||||
if (aborted) break
|
||||
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
||||
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
||||
aborted = true
|
||||
} else {
|
||||
val outcome = runCatching {
|
||||
val token = mint(queue, startPosition + i)
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
@@ -236,7 +220,6 @@ class SonosQueueLoader @Inject constructor(
|
||||
newQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val newIds = newQueue.map { it.id }
|
||||
sent = newQueue
|
||||
if (oldIds == newIds) return true
|
||||
val prefixLen = commonPrefixLength(oldIds, newIds)
|
||||
val suffixLen = commonSuffixLength(
|
||||
@@ -288,7 +271,8 @@ class SonosQueueLoader @Inject constructor(
|
||||
prefixLen + 1,
|
||||
)
|
||||
for (i in 0 until addedCount) {
|
||||
val token = mint(newQueue, prefixLen + i)
|
||||
val ref = newQueue[prefixLen + i]
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
@@ -299,27 +283,6 @@ class SonosQueueLoader @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on every poll with the renderer's 1-based track number. When it
|
||||
* moves, the track after it is rendered ahead, so the speaker's fetch of
|
||||
* it finds the render ready.
|
||||
*/
|
||||
fun onRendererTrack(trackNumber: Int) {
|
||||
if (trackNumber <= 0 || trackNumber == prerenderedAfter) return
|
||||
prerenderedAfter = trackNumber
|
||||
val queue = sent
|
||||
scope.launch { prerender(queue, trackNumber) }
|
||||
}
|
||||
|
||||
private suspend fun prerender(queue: List<TrackRef>, index: Int) {
|
||||
if (index !in queue.indices) return
|
||||
runCatching { mint(queue, index, prerender = true) }
|
||||
.onFailure { Timber.w(it, "UPnP prerender failed for %s", queue[index].id) }
|
||||
}
|
||||
|
||||
private suspend fun mint(queue: List<TrackRef>, index: Int, prerender: Boolean = false) =
|
||||
streamTokens.mint(queue[index].id, asAlbum = playingAsAlbum(queue, index), prerender = prerender)
|
||||
|
||||
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
||||
val limit = minOf(a.size, b.size)
|
||||
for (i in 0 until limit) {
|
||||
@@ -336,33 +299,18 @@ class SonosQueueLoader @Inject constructor(
|
||||
return limit
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Whether [queue]'s track at [index] plays among its album in order,
|
||||
* judged by its neighbours in the renderer's queue, which plays
|
||||
* straight through.
|
||||
*/
|
||||
internal fun playingAsAlbum(queue: List<TrackRef>, index: Int): Boolean =
|
||||
GainMath.playingAsAlbum(
|
||||
prev = queue.getOrNull(index - 1)?.let(::position),
|
||||
cur = position(queue[index]),
|
||||
next = queue.getOrNull(index + 1)?.let(::position),
|
||||
)
|
||||
|
||||
private fun position(t: TrackRef) =
|
||||
AlbumPosition(t.albumId.ifEmpty { null }, t.discNumber, t.trackNumber)
|
||||
|
||||
private companion object {
|
||||
// Abort the append loop after this many consecutive AddURIToQueue
|
||||
// failures; Sonos rate-limits burst adds and a wall of failures means
|
||||
// it has stopped accepting, not that the next one might land.
|
||||
private const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
private const val EXTEND_THROTTLE_MS = 50L
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// Verify/repair passes after a queue load. Two: one to catch the
|
||||
// common case (a rate-limit burst dropped a chunk), one to catch a
|
||||
// repair that itself got rate-limited. Beyond that the renderer is
|
||||
// refusing for a reason retrying won't fix, and the stall watchdog
|
||||
// becomes the backstop.
|
||||
private const val VERIFY_ROUNDS = 2
|
||||
const val VERIFY_ROUNDS = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Drag offset to row delta, rounded to the nearest row boundary. The Android
|
||||
* half of web's `offsetToDelta` (web/src/lib/components/queue-row-math.ts):
|
||||
* the two clients are meant to agree, and QueueDragMathTest mirrors web's
|
||||
* cases so both are held to it (#2436).
|
||||
*
|
||||
* Ties round toward positive infinity, as JS `Math.round` does: half a row
|
||||
* down moves one row, half a row up stays put. A row not yet measured
|
||||
* (height 0) moves nothing.
|
||||
*/
|
||||
internal fun queueDragDelta(offsetPx: Float, rowHeightPx: Int): Int =
|
||||
if (rowHeightPx > 0) (offsetPx / rowHeightPx).roundToInt() else 0
|
||||
|
||||
/**
|
||||
* The queue index a row dragged from [index] lands on, clamped to the queue.
|
||||
* A drag past either end stops at the first or last row.
|
||||
*/
|
||||
internal fun queueDragTarget(
|
||||
index: Int,
|
||||
offsetPx: Float,
|
||||
rowHeightPx: Int,
|
||||
queueSize: Int,
|
||||
): Int {
|
||||
// coerceIn throws on an empty range; a row can't be dragged in an empty
|
||||
// queue, but the composable's keys can briefly outlive the list.
|
||||
if (queueSize <= 0) return index
|
||||
return (index + queueDragDelta(offsetPx, rowHeightPx)).coerceIn(0, queueSize - 1)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/*
|
||||
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
|
||||
@@ -277,7 +278,8 @@ private fun Modifier.queueReorderDrag(
|
||||
onOffsetChange(offset)
|
||||
},
|
||||
onDragEnd = {
|
||||
val target = queueDragTarget(index, offset, rowHeightPx, queueSize)
|
||||
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||
if (target != index) onMove(index, target)
|
||||
offset = 0f
|
||||
onOffsetChange(0f)
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package com.fabledsword.minstrel.settings.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The user's loudness-normalization preference (M464 #4998). The server
|
||||
* holds it, so this device, the web player and casts all level the same
|
||||
* way; a copy is kept on the device so offline playback still applies it.
|
||||
* Field names and values match `GET/PUT /api/me/normalization`.
|
||||
*
|
||||
* No constructor defaults, on purpose: the app's Json leaves out any field
|
||||
* equal to its default (encodeDefaults is off), and the server refuses a PUT
|
||||
* missing a field. Without defaults every field is always sent.
|
||||
*/
|
||||
@Serializable
|
||||
data class NormalizationPrefs(
|
||||
val mode: NormalizationMode,
|
||||
@SerialName("target_lufs") val targetLufs: Int,
|
||||
val boost: NormalizationBoost,
|
||||
) {
|
||||
companion object {
|
||||
/** The targets the server accepts, quietest first. */
|
||||
val TARGETS: List<Int> = listOf(-18, -16, -14)
|
||||
|
||||
/** Must match library.DefaultNormalizationPrefs on the server. */
|
||||
val DEFAULT: NormalizationPrefs = NormalizationPrefs(
|
||||
mode = NormalizationMode.AUTO,
|
||||
targetLufs = -18,
|
||||
boost = NormalizationBoost.HEADROOM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class NormalizationMode {
|
||||
/** Tracks play at their mastered volume. */
|
||||
@SerialName("off") OFF,
|
||||
|
||||
/** Album gain while an album plays in order, track gain otherwise. */
|
||||
@SerialName("auto") AUTO,
|
||||
|
||||
@SerialName("track") TRACK,
|
||||
|
||||
@SerialName("album") ALBUM,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class NormalizationBoost {
|
||||
/** Quiet tracks are raised only as far as their true peak allows. */
|
||||
@SerialName("headroom") HEADROOM,
|
||||
|
||||
/** Quiet tracks are raised all the way and a limiter holds the peaks. */
|
||||
@SerialName("limiter") LIMITER,
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
package com.fabledsword.minstrel.settings.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The user's loudness-normalization preference (M464 #4998). The device's
|
||||
* copy lives in [AuthStore] so playback reads it offline; the server's copy
|
||||
* is what other devices and casts use.
|
||||
*
|
||||
* Writes follow rule 100: the device shows the change at once, the PUT is
|
||||
* best effort, and a failed PUT is queued for the MutationReplayer.
|
||||
*/
|
||||
@Singleton
|
||||
class NormalizationRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val authStore: AuthStore,
|
||||
private val mutationQueue: MutationQueue,
|
||||
private val mutationDao: CachedMutationDao,
|
||||
) {
|
||||
private val api: MeApi = retrofit.create()
|
||||
|
||||
val prefs: StateFlow<NormalizationPrefs> = authStore.normalization
|
||||
|
||||
/**
|
||||
* Takes the server's value, unless a change made here is still queued:
|
||||
* the server has not seen it yet, and taking its older value would undo
|
||||
* the change on screen until the replay landed. Throws on a network
|
||||
* failure; the device's copy then stands.
|
||||
*/
|
||||
suspend fun refresh() {
|
||||
val server = api.getNormalization()
|
||||
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) return
|
||||
authStore.setNormalization(server)
|
||||
}
|
||||
|
||||
suspend fun set(next: NormalizationPrefs) {
|
||||
authStore.setNormalization(next)
|
||||
// An earlier change still queued would be replayed after this PUT
|
||||
// and undo it. Queue behind it instead: the replayer sends only the
|
||||
// newest queued preference.
|
||||
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) {
|
||||
mutationQueue.enqueueNormalizationSet(next)
|
||||
return
|
||||
}
|
||||
try {
|
||||
api.putNormalization(next)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.i(e, "normalization: PUT failed; queued for replay")
|
||||
mutationQueue.enqueueNormalizationSet(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -41,10 +41,6 @@ data class AboutUiState(
|
||||
// check must decide on. Held in state rather than read inline so a test
|
||||
// can drive the comparison without a BuildConfig.
|
||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||
// A debug build is signed with this machine's debug key, so the server's
|
||||
// release-signed APK can never install over it (family idea #5103,
|
||||
// practice 9). It updates from Android Studio instead.
|
||||
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
||||
val isChecking: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
@@ -71,7 +67,7 @@ class AboutCardViewModel @Inject constructor(
|
||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||
|
||||
fun checkForUpdates() {
|
||||
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||
if (internal.value.isChecking) return
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||
val installed = internal.value.installedVersion
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package com.fabledsword.minstrel.settings.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
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.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
|
||||
/**
|
||||
* Volume leveling (M464 #4998). The choice is stored on the server, so the
|
||||
* web player and casts follow it too.
|
||||
*/
|
||||
@Composable
|
||||
fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
|
||||
val prefs by viewModel.prefs.collectAsStateWithLifecycle()
|
||||
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun NormalizationCardContent(
|
||||
prefs: NormalizationPrefs,
|
||||
onChange: (NormalizationPrefs) -> Unit,
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Volume leveling",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Hint(modeHint(prefs.mode))
|
||||
val modes = NormalizationMode.entries
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
modes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == prefs.mode,
|
||||
onClick = { onChange(prefs.copy(mode = mode)) },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = modes.size,
|
||||
),
|
||||
) { Text(modeLabel(mode)) }
|
||||
}
|
||||
}
|
||||
if (prefs.mode != NormalizationMode.OFF) {
|
||||
TargetRow(prefs = prefs, onChange = onChange)
|
||||
BoostRow(prefs = prefs, onChange = onChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||
Text(
|
||||
text = "Target loudness",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val targets = NormalizationPrefs.TARGETS
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
targets.forEachIndexed { index, lufs ->
|
||||
SegmentedButton(
|
||||
selected = lufs == prefs.targetLufs,
|
||||
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = targets.size,
|
||||
),
|
||||
) { Text("$lufs LUFS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Boost quiet tracks fully",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Hint("A limiter catches the peaks.")
|
||||
}
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Switch(
|
||||
checked = prefs.boost == NormalizationBoost.LIMITER,
|
||||
onCheckedChange = { on ->
|
||||
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
|
||||
onChange(prefs.copy(boost = boost))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
|
||||
NormalizationMode.OFF -> "Off"
|
||||
NormalizationMode.AUTO -> "Auto"
|
||||
NormalizationMode.TRACK -> "Track"
|
||||
NormalizationMode.ALBUM -> "Album"
|
||||
}
|
||||
|
||||
private fun modeHint(mode: NormalizationMode): String = when (mode) {
|
||||
NormalizationMode.OFF -> "Tracks play at their mastered volume."
|
||||
NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
|
||||
NormalizationMode.TRACK -> "Every track at the same loudness."
|
||||
NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package com.fabledsword.minstrel.settings.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Backs the Volume leveling card. Shows the device's copy straight away and
|
||||
* refreshes it from the server on open, so a change made on the web shows
|
||||
* here too.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class NormalizationViewModel @Inject constructor(
|
||||
private val repository: NormalizationRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val prefs: StateFlow<NormalizationPrefs> = repository.prefs
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.refresh()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
// Offline: the device's copy stands.
|
||||
Timber.i(e, "normalization: refresh failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(next: NormalizationPrefs) {
|
||||
viewModelScope.launch { repository.set(next) }
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class PasswordViewModel @Inject constructor(
|
||||
try {
|
||||
repository.changePassword(current = s.current, next = s.next)
|
||||
internal.update {
|
||||
PasswordUiState(message = "Password changed. Your other devices have been signed out.")
|
||||
PasswordUiState(message = "Password changed.")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Bell
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.ListMusic
|
||||
import com.composables.icons.lucide.LogOut
|
||||
@@ -53,7 +52,6 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shield
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
import com.fabledsword.minstrel.nav.ServerUrl
|
||||
@@ -100,7 +98,6 @@ fun SettingsScreen(
|
||||
themeMode = themeMode,
|
||||
onPickTheme = themeVm::setThemeMode,
|
||||
onNavToRequests = { navController.navigate(Requests) },
|
||||
onNavToNotifications = { navController.navigate(NotificationSettings) },
|
||||
onNavToAdmin = { navController.navigate(Admin) },
|
||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||
onSignOutClick = { showSignOutConfirm = true },
|
||||
@@ -124,7 +121,6 @@ private fun SettingsList(
|
||||
themeMode: ThemeMode,
|
||||
onPickTheme: (ThemeMode) -> Unit,
|
||||
onNavToRequests: () -> Unit,
|
||||
onNavToNotifications: () -> Unit,
|
||||
onNavToAdmin: () -> Unit,
|
||||
onToggleDiagnostics: (Boolean) -> Unit,
|
||||
onSignOutClick: () -> Unit,
|
||||
@@ -148,12 +144,6 @@ private fun SettingsList(
|
||||
subtitle = "Track what you've asked Minstrel to add",
|
||||
onClick = onNavToRequests,
|
||||
)
|
||||
NavTile(
|
||||
icon = Lucide.Bell,
|
||||
title = "Notifications",
|
||||
subtitle = "What reaches you, and where",
|
||||
onClick = onNavToNotifications,
|
||||
)
|
||||
if (state.isAdmin) {
|
||||
NavTile(
|
||||
icon = Lucide.Shield,
|
||||
@@ -171,7 +161,6 @@ private fun SettingsList(
|
||||
onToggle = onToggleDiagnostics,
|
||||
)
|
||||
}
|
||||
NormalizationCard()
|
||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||
StorageCard()
|
||||
AboutCard()
|
||||
@@ -393,14 +382,6 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
||||
|
||||
@Composable
|
||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (!state.selfUpdates) {
|
||||
Text(
|
||||
text = "Debug build: updates install from Android Studio.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -29,7 +28,6 @@ data class SettingsState(
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authController: AuthController,
|
||||
private val authStore: AuthStore,
|
||||
private val notifications: NotificationsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val transient = MutableStateFlow(TransientState())
|
||||
@@ -72,8 +70,6 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
transient.update { it.copy(isSigningOut = true) }
|
||||
authController.signOut()
|
||||
// The inbox is this account's; the next one on the device must not see it.
|
||||
runCatching { notifications.clearLocal() }
|
||||
transient.update { it.copy(isSigningOut = false, signedOut = true) }
|
||||
}
|
||||
}
|
||||
|
||||
-10
@@ -3,7 +3,6 @@ package com.fabledsword.minstrel.shared.widgets
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -23,7 +22,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
@HiltViewModel
|
||||
class AppBarActionsViewModel @Inject constructor(
|
||||
authController: AuthController,
|
||||
notifications: NotificationsRepository,
|
||||
) : ViewModel() {
|
||||
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||
.map { it?.isAdmin == true }
|
||||
@@ -32,12 +30,4 @@ class AppBarActionsViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Unread notices for the bell's badge (M489). */
|
||||
val unreadCount: StateFlow<Int> = notifications.unreadCount
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = 0,
|
||||
)
|
||||
}
|
||||
|
||||
-43
@@ -1,13 +1,10 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -17,7 +14,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Bell
|
||||
import com.composables.icons.lucide.House
|
||||
import com.composables.icons.lucide.LibraryBig
|
||||
import com.composables.icons.lucide.Lucide
|
||||
@@ -27,7 +23,6 @@ import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.Discover
|
||||
import com.fabledsword.minstrel.nav.Home
|
||||
import com.fabledsword.minstrel.nav.Library
|
||||
import com.fabledsword.minstrel.nav.Notifications
|
||||
import com.fabledsword.minstrel.nav.Playlists
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
@@ -55,7 +50,6 @@ fun MainAppBarActions(
|
||||
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
|
||||
Row {
|
||||
if (currentRouteName != Home::class.qualifiedName) {
|
||||
IconButton(onClick = { navController.navigate(Home) }) {
|
||||
@@ -72,47 +66,10 @@ fun MainAppBarActions(
|
||||
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
if (currentRouteName != Notifications::class.qualifiedName) {
|
||||
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
|
||||
}
|
||||
OverflowMenu(navController = navController, isAdmin = isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
/** The inbox bell with its unread badge (M489): the count to 9, then "9+". */
|
||||
@Composable
|
||||
private fun NotificationsBell(unread: Int, onClick: () -> Unit) {
|
||||
val label = badgeLabel(unread)
|
||||
IconButton(onClick = onClick) {
|
||||
// Inverse of the bar, not error red: an unread count is news, not a
|
||||
// fault, and the house style keeps the accent off general chrome.
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (label.isNotEmpty()) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.onSurface,
|
||||
contentColor = MaterialTheme.colorScheme.surface,
|
||||
) { Text(label) }
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Lucide.Bell,
|
||||
contentDescription = if (unread > 0) "Notifications, $unread unread" else "Notifications",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Badge text: nothing at zero, the count up to nine, then "9+". */
|
||||
internal fun badgeLabel(count: Int): String = when {
|
||||
count <= 0 -> ""
|
||||
count > MAX_BADGE -> "$MAX_BADGE+"
|
||||
else -> count.toString()
|
||||
}
|
||||
|
||||
private const val MAX_BADGE = 9
|
||||
|
||||
@Composable
|
||||
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
-7
@@ -29,9 +29,6 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
||||
* restart re-shows it, which is acceptable nudging for v1 (matches
|
||||
* Flutter). Server 404 / network errors stay silent. Constructed at
|
||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||
*
|
||||
* A debug build never polls: it is signed with a local debug key, so the
|
||||
* server's release-signed APK could never install over it (#5103).
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateBannerController @Inject constructor(
|
||||
@@ -48,10 +45,6 @@ class UpdateBannerController @Inject constructor(
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
if (!BuildConfig.DEBUG) startPolling()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
runOnce()
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Status-bar icon for Minstrel's own notifications (M489 #5347): Lucide's
|
||||
bell, stroked in white. The system tints it; only the alpha is used. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,8a6,6 0,0 1,12 0c0,7 3,9 3,9H3s3,-2 3,-9"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
<path
|
||||
android:pathData="M10.3,21a1.94,1.94 0,0 0,3.4 0"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -17,12 +17,8 @@
|
||||
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
||||
until runtime. So a permissive base-config is an honest description of our
|
||||
situation — the gain over the manifest attribute is that the reasoning now
|
||||
lives somewhere.
|
||||
|
||||
The LAN/WAN line this file cannot draw is drawn in code instead:
|
||||
api/CleartextGuard.kt refuses plain http:// to the Minstrel server whenever
|
||||
the connection lands on a public address (family baseline #5105, practice
|
||||
13). LAN servers and UPnP speakers are unaffected.
|
||||
lives somewhere, and there is one place to tighten if a future settings screen
|
||||
can distinguish a LAN server from a WAN one.
|
||||
|
||||
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
||||
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -44,7 +43,7 @@ class AuthCookieInterceptorTest {
|
||||
coEvery { setSessionCookie(any()) } returns Unit
|
||||
coEvery { setBaseUrl(any()) } returns Unit
|
||||
}
|
||||
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
|
||||
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -39,7 +38,7 @@ class BaseUrlInterceptorTest {
|
||||
coEvery { setSessionCookie(any()) } returns Unit
|
||||
coEvery { setBaseUrl(any()) } returns Unit
|
||||
}
|
||||
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.net.InetAddress
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Plain http:// to the Minstrel server only on a private address (#5105, practice 13). */
|
||||
class CleartextGuardTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun ip(s: String) = InetAddress.getByName(s)
|
||||
|
||||
@Test
|
||||
fun `home network and overlay addresses may use plain http`() {
|
||||
for (a in listOf(
|
||||
"127.0.0.1", "10.1.2.3", "172.16.0.9", "172.31.255.1", "192.168.1.20",
|
||||
"169.254.3.4", "100.64.0.1", "100.127.255.254", "::1", "fd12:3456::1", "fe80::1",
|
||||
)) {
|
||||
assertTrue(CleartextPolicy.allows(ip(a)), "$a should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public addresses may not`() {
|
||||
for (a in listOf(
|
||||
"8.8.8.8", "172.32.0.1", "100.128.0.1", "100.63.255.255", "203.0.113.5",
|
||||
"2001:db8::1", "2606:4700::1111",
|
||||
)) {
|
||||
assertFalse(CleartextPolicy.allows(ip(a)), "$a should be refused")
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(allows: Boolean) = OkHttpClient.Builder()
|
||||
.addNetworkInterceptor(CleartextGuardInterceptor { allows })
|
||||
.build()
|
||||
|
||||
private fun serverRequest() = Request.Builder()
|
||||
.url(server.url("/api/auth/login"))
|
||||
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `a refused server request sends nothing`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
assertThrows<CleartextToPublicHostException> {
|
||||
client(allows = false).newCall(serverRequest()).execute()
|
||||
}
|
||||
assertEquals(0, server.requestCount, "the request reached the server")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an allowed server request goes through`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
client(allows = true).newCall(serverRequest()).execute().use { assertEquals(200, it.code) }
|
||||
assertEquals(1, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requests not bound for the Minstrel server are left alone`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
client(allows = false).newCall(Request.Builder().url(server.url("/art.jpg")).build())
|
||||
.execute().use { assertEquals(200, it.code) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the refusal has its own message`() {
|
||||
val msg = ErrorCopy.fromThrowable(CleartextToPublicHostException("music.example.com"))
|
||||
assertTrue(msg.contains("https://"), msg)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class ErrorCopyTest {
|
||||
private fun httpError(status: Int, body: String): HttpException =
|
||||
HttpException(
|
||||
Response.error<Unit>(status, body.toResponseBody("application/json".toMediaType())),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun libraryNotWritableAppendsTheServerDetail() {
|
||||
val detail = "Minstrel runs as uid 1000, gid 1000 and cannot delete from /music/A " +
|
||||
"(read-only file system). The library mount must be writable by that user. " +
|
||||
"Nothing was deleted."
|
||||
val e = httpError(409, """{"error":{"code":"library_not_writable","message":"$detail"}}""")
|
||||
|
||||
assertEquals(
|
||||
"${ErrorCopy.messageFor("library_not_writable")} $detail",
|
||||
ErrorCopy.fromThrowable(e),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailCodeWithoutAMessageShowsTheCopyAlone() {
|
||||
val e = httpError(409, """{"error":{"code":"library_not_writable","message":""}}""")
|
||||
|
||||
assertEquals(ErrorCopy.messageFor("library_not_writable"), ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
|
||||
// Server messages are usually internal detail; appending them for every
|
||||
// code would leak driver errors into snackbars. This pins the scope.
|
||||
@Test
|
||||
fun otherCodesNeverCarryTheServerMessage() {
|
||||
val e = httpError(404, """{"error":{"code":"track_not_found","message":"pgx: no rows"}}""")
|
||||
|
||||
assertEquals(ErrorCopy.messageFor("track_not_found"), ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnparseableBodyFallsBackToUnknown() {
|
||||
val e = httpError(500, "not json")
|
||||
|
||||
assertEquals(ErrorCopy.messageFor("unknown"), ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transportFailureMapsToConnectionRefused() {
|
||||
assertEquals(
|
||||
ErrorCopy.messageFor("connection_refused"),
|
||||
ErrorCopy.fromThrowable(IOException("refused")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* The session cookie moved out of the Room row into a Keystore-backed vault
|
||||
* (M462 #4985). What matters is that nobody is signed out by it: an install
|
||||
* upgrading with a cookie in the row keeps it, a device whose Keystore will
|
||||
* not work keeps the old storage, and a sign-in or 401 that lands while the
|
||||
* one-time move runs is never overwritten by the value it read.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AuthStoreSessionVaultTest {
|
||||
/** A DAO whose single row holds [legacyCookie] in its sessionCookie column. */
|
||||
private class RowDao(var legacyCookie: String?) {
|
||||
val dao: AuthSessionDao = mockk {
|
||||
every { observe() } returns flowOf(null)
|
||||
coEvery { get() } answers {
|
||||
AuthSessionEntity(baseUrl = "http://music.local", sessionCookie = legacyCookie)
|
||||
}
|
||||
coEvery { upsert(any()) } answers { legacyCookie = firstArg<AuthSessionEntity>().sessionCookie }
|
||||
coEvery { setSessionCookie(any()) } answers { legacyCookie = firstArg() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an upgrading install keeps its session, moved out of the row into the vault`() = runTest {
|
||||
val row = RowDao(legacyCookie = "session=abc")
|
||||
val vault = FakeSessionVault()
|
||||
|
||||
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||
store.awaitSessionHydrated()
|
||||
|
||||
assertEquals("session=abc", store.sessionCookie.value)
|
||||
assertEquals("session=abc", vault.stored)
|
||||
assertNull(row.legacyCookie, "the plain-text copy must be cleared from the row")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cookie already in the vault is loaded without touching the row`() = runTest {
|
||||
val row = RowDao(legacyCookie = null)
|
||||
val vault = FakeSessionVault(stored = "session=xyz")
|
||||
|
||||
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||
store.awaitSessionHydrated()
|
||||
|
||||
assertEquals("session=xyz", store.sessionCookie.value)
|
||||
assertEquals(0, vault.writes)
|
||||
coVerify(exactly = 0) { row.dao.setSessionCookie(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the Keystore will not work the cookie stays in the row instead of being lost`() = runTest {
|
||||
val row = RowDao(legacyCookie = "session=abc")
|
||||
val vault = FakeSessionVault(available = false)
|
||||
|
||||
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||
store.awaitSessionHydrated()
|
||||
assertEquals("session=abc", store.sessionCookie.value)
|
||||
assertEquals("session=abc", row.legacyCookie)
|
||||
|
||||
store.setSessionCookie("session=new")
|
||||
assertEquals("session=new", row.legacyCookie, "fallback writes go to the row")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sign-in and sign-out write the vault, and never the row`() = runTest {
|
||||
val row = RowDao(legacyCookie = null)
|
||||
val vault = FakeSessionVault()
|
||||
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||
store.awaitSessionHydrated()
|
||||
|
||||
store.setSessionCookie("session=new")
|
||||
assertEquals("session=new", vault.stored)
|
||||
assertNull(row.legacyCookie)
|
||||
|
||||
store.setSessionCookie(null)
|
||||
assertNull(vault.stored)
|
||||
assertNull(store.sessionCookie.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sign-out that lands before hydration finishes is not undone by it`() = runTest {
|
||||
val row = RowDao(legacyCookie = "session=stale")
|
||||
val vault = FakeSessionVault()
|
||||
val scope = TestScope(StandardTestDispatcher(testScheduler))
|
||||
|
||||
// Hydration is queued but has not run; a 401 clears the session first.
|
||||
val store = AuthStore(row.dao, vault, scope)
|
||||
store.setSessionCookie(null)
|
||||
scope.advanceUntilIdle()
|
||||
|
||||
assertNull(store.sessionCookie.value, "hydration must not resurrect the stale cookie")
|
||||
assertNull(vault.stored)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
/**
|
||||
* In-memory [SessionVault] for JVM tests: the Android Keystore has no JVM
|
||||
* implementation. [available] = false stands in for a device whose Keystore
|
||||
* refuses to work, so callers' fallback paths can be exercised.
|
||||
*/
|
||||
class FakeSessionVault(
|
||||
var stored: String? = null,
|
||||
var available: Boolean = true,
|
||||
) : SessionVault {
|
||||
var writes = 0
|
||||
private set
|
||||
|
||||
override fun read(): String? = if (available) stored else null
|
||||
|
||||
override fun write(value: String?): Boolean {
|
||||
if (!available) return false
|
||||
writes++
|
||||
stored = value
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.fabledsword.minstrel.auth
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.util.Base64
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
|
||||
/**
|
||||
* The sealing half of the session vault, with an ordinary JVM AES key in
|
||||
* place of the Keystore one (the Keystore has no JVM implementation).
|
||||
*/
|
||||
class SealedBoxTest {
|
||||
private fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
@Test
|
||||
fun `round-trips a cookie`() {
|
||||
val key = newKey()
|
||||
assertEquals("session=abc", SealedBox.open(key, SealedBox.seal(key, "session=abc")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stored form does not contain the cookie, and differs every time`() {
|
||||
val key = newKey()
|
||||
val first = SealedBox.seal(key, "session=abc")
|
||||
val second = SealedBox.seal(key, "session=abc")
|
||||
assertNotEquals(first, second, "a fresh IV per seal")
|
||||
val decoded = String(Base64.getDecoder().decode(first), Charsets.ISO_8859_1)
|
||||
assertFalse(decoded.contains("session=abc"), "plaintext visible in the sealed value")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tampered value does not open`() {
|
||||
val key = newKey()
|
||||
val bytes = Base64.getDecoder().decode(SealedBox.seal(key, "session=abc"))
|
||||
bytes[bytes.size - 1] = (bytes[bytes.size - 1].toInt() xor 1).toByte()
|
||||
assertThrows<Exception> { SealedBox.open(key, Base64.getEncoder().encodeToString(bytes)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a value sealed under another key does not open`() {
|
||||
val sealed = SealedBox.seal(newKey(), "session=abc")
|
||||
assertThrows<Exception> { SealedBox.open(newKey(), sealed) }
|
||||
}
|
||||
}
|
||||
Vendored
-55
@@ -1,9 +1,6 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
@@ -132,56 +129,4 @@ class SupersededToggleIdsTest {
|
||||
fun `an empty queue collapses nothing`() {
|
||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||
}
|
||||
|
||||
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
|
||||
id = id,
|
||||
kind = MutationKind.NORMALIZATION_SET,
|
||||
payload = json.encodeToString(
|
||||
NormalizationPrefs.serializer(),
|
||||
NormalizationPrefs.DEFAULT.copy(mode = mode),
|
||||
),
|
||||
)
|
||||
|
||||
// There is one preference per user, so any two queued changes to it
|
||||
// collapse, and only the newest is sent (#4998).
|
||||
@Test
|
||||
fun `only the newest queued normalization change survives`() {
|
||||
val rows = listOf(
|
||||
normalizationRow(1, NormalizationMode.TRACK),
|
||||
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||
normalizationRow(3, NormalizationMode.OFF),
|
||||
normalizationRow(4, NormalizationMode.ALBUM),
|
||||
)
|
||||
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||
}
|
||||
|
||||
private fun settingRow(id: Long, kind: String, channel: String, value: Boolean) = CachedMutationEntity(
|
||||
id = id,
|
||||
kind = MutationKind.NOTIFICATION_SETTING_SET,
|
||||
payload = json.encodeToString(
|
||||
NotificationSettingPayload.serializer(),
|
||||
NotificationSettingPayload(kind, channel, value),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `notification settings collapse per kind and channel, never across them`() {
|
||||
val rows = listOf(
|
||||
settingRow(1, "request_completed", "email", false),
|
||||
settingRow(2, "request_completed", "phone", false),
|
||||
settingRow(3, "request_completed", "email", true),
|
||||
settingRow(4, "request_approved", "email", false),
|
||||
)
|
||||
// Only the older email toggle for request_completed is superseded.
|
||||
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queued setting becomes a one-channel change, and an unknown channel none`() {
|
||||
assertEquals(
|
||||
NotificationSettingChangeWire(kind = "k", phone = true),
|
||||
notificationSettingChange(NotificationSettingPayload("k", "phone", true)),
|
||||
)
|
||||
assertEquals(null, notificationSettingChange(NotificationSettingPayload("k", "pager", true)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.fabledsword.minstrel.events
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReconnectBackoffTest {
|
||||
@Test
|
||||
fun `doubles from two seconds to a five minute cap`() {
|
||||
val ladder = generateSequence(ReconnectBackoff.BASE_MS) { ReconnectBackoff.next(it) }.take(10).toList()
|
||||
assertEquals(listOf(2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 64_000L, 128_000L, 256_000L), ladder.take(8))
|
||||
assertEquals(300_000L, ladder[8])
|
||||
assertEquals(300_000L, ladder[9])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jitter stays within a quarter either way and does spread`() {
|
||||
val random = Random(42)
|
||||
val waits = List(1_000) { ReconnectBackoff.jittered(100_000L, random) }
|
||||
assertTrue(waits.all { it in 75_000L..125_000L }, "out of bounds: ${waits.minOrNull()}..${waits.maxOrNull()}")
|
||||
assertTrue(waits.toSet().size > 100, "jitter should spread reconnects")
|
||||
}
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.Retrofit
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
/** Settings follow snippet #5107: shown at once, never overwritten or reordered by an older change. */
|
||||
class NotificationSettingsRepositoryTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val api: NotificationsApi = mockk(relaxed = true)
|
||||
private val dao: CachedNotificationDao = mockk(relaxed = true)
|
||||
private val mutationDao: CachedMutationDao = mockk()
|
||||
private val queue: MutationQueue = mockk(relaxed = true)
|
||||
private val retrofit: Retrofit = mockk {
|
||||
every { create(NotificationsApi::class.java) } returns api
|
||||
}
|
||||
private val repo = NotificationSettingsRepository(retrofit, dao, mutationDao, queue, json)
|
||||
|
||||
private val settings = NotificationSettingsWire(
|
||||
kinds = listOf(
|
||||
NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true),
|
||||
),
|
||||
emailAvailable = true,
|
||||
)
|
||||
|
||||
private fun cached() {
|
||||
coEvery { dao.getSettings() } returns CachedNotificationSettingsEntity(
|
||||
json = json.encodeToString(NotificationSettingsWire.serializer(), settings),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a toggle is shown at once and sends only that channel`() = runTest {
|
||||
cached()
|
||||
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
|
||||
val saved = mutableListOf<CachedNotificationSettingsEntity>()
|
||||
coEvery { dao.upsertSettings(capture(saved)) } returns Unit
|
||||
val body = slot<PutNotificationSettingsBody>()
|
||||
coEvery { api.putSettings(capture(body)) } returns settings
|
||||
|
||||
repo.set("request_completed", NotificationChannel.EMAIL, false)
|
||||
|
||||
val optimistic = json.decodeFromString(NotificationSettingsWire.serializer(), saved.first().json)
|
||||
assertFalse(optimistic.kinds.single().email)
|
||||
assertEquals(
|
||||
listOf(NotificationSettingChangeWire(kind = "request_completed", email = false)),
|
||||
body.captured.kinds,
|
||||
)
|
||||
coVerify(exactly = 0) { queue.enqueueNotificationSettingSet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a toggle that cannot reach the server is queued`() = runTest {
|
||||
cached()
|
||||
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
|
||||
coEvery { api.putSettings(any()) } throws IOException("offline")
|
||||
|
||||
repo.set("request_completed", NotificationChannel.PHONE, false)
|
||||
|
||||
coVerify {
|
||||
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "phone", false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a toggle behind a queued one queues too`() = runTest {
|
||||
cached()
|
||||
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
|
||||
|
||||
repo.set("request_completed", NotificationChannel.INBOX, false)
|
||||
|
||||
coVerify(exactly = 0) { api.putSettings(any()) }
|
||||
coVerify {
|
||||
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "inbox", false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh leaves a queued change on screen`() = runTest {
|
||||
coEvery { api.getSettings() } returns settings
|
||||
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
|
||||
|
||||
repo.refresh()
|
||||
|
||||
coVerify(exactly = 0) { dao.upsertSettings(any()) }
|
||||
}
|
||||
}
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationsPageWire
|
||||
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Reads are offline-first (rule 100): the device marks the row at once, and a
|
||||
* read the server has not taken is queued, except when the server says the
|
||||
* notice is gone.
|
||||
*/
|
||||
class NotificationsRepositoryTest {
|
||||
private val api: NotificationsApi = mockk(relaxed = true)
|
||||
private val dao: CachedNotificationDao = mockk(relaxed = true)
|
||||
private val queue: MutationQueue = mockk(relaxed = true)
|
||||
private val retrofit: Retrofit = mockk {
|
||||
every { create(NotificationsApi::class.java) } returns api
|
||||
}
|
||||
private val repo = NotificationsRepository(retrofit, dao, queue)
|
||||
|
||||
private fun httpError(status: Int) = HttpException(Response.error<Unit>(status, "".toResponseBody()))
|
||||
|
||||
private fun row(id: String, created: String, readAt: Instant? = null) = CachedNotificationEntity(
|
||||
id = id,
|
||||
kind = "request_completed",
|
||||
title = "t",
|
||||
body = "b",
|
||||
link = "/requests",
|
||||
createdAt = Instant.parse(created),
|
||||
readAt = readAt,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a read is shown at once and sent`() = runTest {
|
||||
repo.markRead("n1")
|
||||
|
||||
coVerify { dao.markRead("n1", any()) }
|
||||
coVerify { api.markRead("n1") }
|
||||
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read that cannot reach the server is queued`() = runTest {
|
||||
coEvery { api.markRead("n1") } throws IOException("offline")
|
||||
|
||||
repo.markRead("n1")
|
||||
|
||||
coVerify { dao.markRead("n1", any()) }
|
||||
coVerify { queue.enqueueNotificationRead("n1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a notice the server no longer has is not queued`() = runTest {
|
||||
coEvery { api.markRead("n1") } throws httpError(404)
|
||||
|
||||
repo.markRead("n1")
|
||||
|
||||
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server error queues the read for later`() = runTest {
|
||||
coEvery { api.markRead("n1") } throws httpError(503)
|
||||
|
||||
repo.markRead("n1")
|
||||
|
||||
coVerify { queue.enqueueNotificationRead("n1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mark all read sends the newest notice shown, rounded up, and queues it offline`() = runTest {
|
||||
coEvery { dao.getAll() } returns listOf(
|
||||
row("a", "2026-10-08T10:00:00.123Z"),
|
||||
row("b", "2026-10-08T11:00:00.456Z"),
|
||||
)
|
||||
val body = slot<ReadAllBody>()
|
||||
coEvery { api.readAll(capture(body)) } throws IOException("offline")
|
||||
|
||||
repo.markAllRead()
|
||||
|
||||
coVerify { dao.markAllRead(any()) }
|
||||
assertEquals("2026-10-08T11:00:00.457Z", body.captured.upTo)
|
||||
coVerify { queue.enqueueNotificationsReadAll("2026-10-08T11:00:00.457Z") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh keeps a read made here that the server has not seen yet`() = runTest {
|
||||
val readHere = Instant.parse("2026-10-08T12:00:00Z")
|
||||
coEvery { dao.getAll() } returns listOf(row("a", "2026-10-08T10:00:00Z", readAt = readHere))
|
||||
coEvery { api.list(any()) } returns NotificationsPageWire(
|
||||
items = listOf(
|
||||
NotificationWire("a", "request_completed", "t", "b", "/requests", "2026-10-08T10:00:00Z", null),
|
||||
NotificationWire("b", "request_completed", "t", "b", "/requests", "2026-10-08T11:00:00Z", null),
|
||||
),
|
||||
unreadCount = 2,
|
||||
)
|
||||
val saved = slot<List<CachedNotificationEntity>>()
|
||||
coEvery { dao.replaceAll(capture(saved)) } returns Unit
|
||||
|
||||
repo.refresh()
|
||||
|
||||
val byId = saved.captured.associateBy { it.id }
|
||||
assertEquals(readHere, byId.getValue("a").readAt)
|
||||
assertNull(byId.getValue("b").readAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a notice with an unreadable timestamp is skipped, not the whole page`() {
|
||||
val bad = NotificationWire("x", "k", "t", "b", "/", "yesterday", null)
|
||||
assertNull(bad.toEntity(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no cutoff when nothing is shown`() {
|
||||
assertNull(readAllCutoff(emptyList()))
|
||||
}
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.delivery
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DeliveryPlanTest {
|
||||
private val t0 = Instant.parse("2026-10-08T12:00:00Z")
|
||||
|
||||
private fun notice(
|
||||
id: String,
|
||||
minutes: Long,
|
||||
kind: String = "request_approved",
|
||||
read: Boolean = false,
|
||||
) = CachedNotificationEntity(
|
||||
id = id,
|
||||
kind = kind,
|
||||
title = "t-$id",
|
||||
body = "b-$id",
|
||||
link = "/requests",
|
||||
createdAt = Instant.fromEpochMilliseconds(t0.toEpochMilliseconds() + minutes * 60_000),
|
||||
readAt = if (read) t0 else null,
|
||||
)
|
||||
|
||||
private val allOn: (String) -> Boolean = { true }
|
||||
|
||||
@Test
|
||||
fun `a first look sets the mark to the newest and announces nothing`() {
|
||||
val page = listOf(notice("b", 5), notice("a", 1))
|
||||
val plan = planCatchUp(page, mark = null, phoneOn = allOn)
|
||||
assertTrue(plan.announce.isEmpty())
|
||||
assertEquals(page[0].createdAt, plan.upTo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a first look at an empty inbox still lets the first notice through`() {
|
||||
val first = planCatchUp(emptyList(), mark = null, phoneOn = allOn)
|
||||
assertEquals(Instant.DISTANT_PAST, first.upTo)
|
||||
val next = planCatchUp(listOf(notice("a", 1)), mark = first.upTo, phoneOn = allOn)
|
||||
assertEquals(listOf("a"), next.announce.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `announces unread notices newer than the mark, oldest first, and moves the mark`() {
|
||||
val page = listOf(notice("c", 9), notice("b", 6), notice("old", 2), notice("read", 7, read = true))
|
||||
val plan = planCatchUp(page, mark = notice("m", 3).createdAt, phoneOn = allOn)
|
||||
assertEquals(listOf("b", "c"), plan.announce.map { it.id })
|
||||
assertEquals(page[0].createdAt, plan.upTo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing new leaves the mark where it was`() {
|
||||
val mark = notice("m", 10).createdAt
|
||||
val plan = planCatchUp(listOf(notice("a", 1)), mark = mark, phoneOn = allOn)
|
||||
assertTrue(plan.announce.isEmpty())
|
||||
assertEquals(mark, plan.upTo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a kind with the phone off is passed over, and not announced later either`() {
|
||||
val page = listOf(notice("h", 5, kind = "tracks_missing"), notice("r", 4))
|
||||
val plan = planCatchUp(page, mark = t0, phoneOn = { it != "tracks_missing" })
|
||||
assertEquals(listOf("r"), plan.announce.map { it.id })
|
||||
assertEquals(page[0].createdAt, plan.upTo, "the mark passes it")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phone prefs come from the cached settings, with every kind on when none are cached`() {
|
||||
val settings = NotificationSettingsWire(
|
||||
kinds = listOf(
|
||||
NotificationKindSettingWire("request_approved", false, inbox = true, phone = false, email = true),
|
||||
NotificationKindSettingWire("request_completed", false, inbox = false, phone = true, email = true),
|
||||
NotificationKindSettingWire("request_rejected", false, inbox = true, phone = true, email = true),
|
||||
),
|
||||
emailAvailable = true,
|
||||
)
|
||||
val phoneOn = phoneOnFor(settings)
|
||||
assertFalse(phoneOn("request_approved"))
|
||||
assertFalse(phoneOn("request_completed"), "phone rides on the inbox")
|
||||
assertTrue(phoneOn("request_rejected"))
|
||||
assertTrue(phoneOnFor(null)("tracks_missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three get a line each, more become one line with the count`() {
|
||||
assertNull(announcementFor(emptyList()))
|
||||
val three = (1..3).map { notice("n$it", it.toLong()) }
|
||||
assertEquals(Announcement.Each(three), announcementFor(three))
|
||||
|
||||
val five = (1..5).map { notice("n$it", it.toLong()) }
|
||||
val pile = assertIs<Announcement.Pile>(announcementFor(five))
|
||||
assertEquals(5, pile.count)
|
||||
assertEquals(ShadeChannel.YOUR_REQUESTS, pile.channel)
|
||||
assertEquals("5 new notifications", pileText(pile.count))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pile of admin notices goes to library health, a mixed one does not`() {
|
||||
val admin = (1..4).map { notice("a$it", it.toLong(), kind = "tracks_missing") }
|
||||
assertEquals(ShadeChannel.LIBRARY_HEALTH, assertIs<Announcement.Pile>(announcementFor(admin)).channel)
|
||||
val mixed = admin + notice("r", 9)
|
||||
assertEquals(ShadeChannel.YOUR_REQUESTS, assertIs<Announcement.Pile>(announcementFor(mixed)).channel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listener kinds are their requests, the rest is library health`() {
|
||||
listOf("request_approved", "request_rejected", "request_completed").forEach {
|
||||
assertEquals(ShadeChannel.YOUR_REQUESTS, shadeChannelFor(it))
|
||||
}
|
||||
listOf("request_pending", "quarantine_flagged", "scan_failed", "tracks_missing").forEach {
|
||||
assertEquals(ShadeChannel.LIBRARY_HEALTH, shadeChannelFor(it))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the service runs only signed in with background delivery on`() {
|
||||
assertTrue(deliveryWanted(signedIn = true, enabled = true))
|
||||
assertFalse(deliveryWanted(signedIn = true, enabled = false))
|
||||
assertFalse(deliveryWanted(signedIn = false, enabled = true))
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
package com.fabledsword.minstrel.notifications.ui
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||
import com.fabledsword.minstrel.shared.widgets.badgeLabel
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NotificationUiTest {
|
||||
@Test
|
||||
fun `server links open the matching screens`() {
|
||||
assertEquals(AlbumDetail("al-1"), routeForLink("/albums/al-1"))
|
||||
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
|
||||
assertEquals(Requests, routeForLink("/requests"))
|
||||
assertEquals(AdminRequests, routeForLink("/admin/requests"))
|
||||
assertEquals(AdminQuarantine, routeForLink("/admin/quarantine"))
|
||||
// Admin pages the app has no screen for open the Admin landing.
|
||||
assertEquals(Admin, routeForLink("/admin/duplicates"))
|
||||
assertEquals(Admin, routeForLink("/admin"))
|
||||
assertNull(routeForLink("/somewhere-new"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the badge counts to nine, then 9+`() {
|
||||
assertEquals("", badgeLabel(0))
|
||||
assertEquals("1", badgeLabel(1))
|
||||
assertEquals("9", badgeLabel(9))
|
||||
assertEquals("9+", badgeLabel(10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ages read in coarse steps`() {
|
||||
val now = Instant.parse("2026-10-08T12:00:00Z")
|
||||
assertEquals("just now", ago(Instant.parse("2026-10-08T11:59:30Z"), now))
|
||||
assertEquals("12m", ago(Instant.parse("2026-10-08T11:48:00Z"), now))
|
||||
assertEquals("5h", ago(Instant.parse("2026-10-08T07:00:00Z"), now))
|
||||
assertEquals("3d", ago(Instant.parse("2026-10-05T12:00:00Z"), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `phone and email ride on the inbox, and email needs to be available`() {
|
||||
val on = NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true)
|
||||
val off = on.copy(inbox = false)
|
||||
assertTrue(channelEnabled(on, NotificationChannel.PHONE, emailAvailable = true))
|
||||
assertFalse(channelEnabled(off, NotificationChannel.PHONE, emailAvailable = true))
|
||||
assertFalse(channelEnabled(on, NotificationChannel.EMAIL, emailAvailable = false))
|
||||
assertTrue(channelEnabled(off, NotificationChannel.INBOX, emailAvailable = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the email line says why it is off`() {
|
||||
assertEquals("Email is off: add an email address in your profile", emailUnavailableLine("no_address", false))
|
||||
assertEquals("Email is off: SMTP isn't set up on the server", emailUnavailableLine("smtp_not_configured", true))
|
||||
assertEquals("Email is off: this server doesn't send email", emailUnavailableLine("smtp_not_configured", false))
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationBoost
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.math.log10
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Mirrors web/src/lib/player/gain.test.ts: both players must level alike. */
|
||||
class GainMathTest {
|
||||
|
||||
private fun prefs(
|
||||
mode: NormalizationMode = NormalizationMode.AUTO,
|
||||
target: Int = -18,
|
||||
boost: NormalizationBoost = NormalizationBoost.HEADROOM,
|
||||
) = NormalizationPrefs(mode = mode, targetLufs = target, boost = boost)
|
||||
|
||||
private val g = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f)
|
||||
|
||||
@Test
|
||||
fun `off and unmeasured tracks play as mastered`() {
|
||||
assertEquals(0f, GainMath.gainDb(prefs(mode = NormalizationMode.OFF), g, false))
|
||||
assertEquals(0f, GainMath.gainDb(prefs(), null, false))
|
||||
assertEquals(0f, GainMath.gainDb(prefs(), ReplayGain.NONE, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mode picks the gain and auto follows album play`() {
|
||||
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), g, true))
|
||||
assertEquals(-4f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), g, false))
|
||||
assertEquals(-4f, GainMath.gainDb(prefs(), g, true))
|
||||
assertEquals(-6f, GainMath.gainDb(prefs(), g, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `album gain falls back to track gain until the album is measured`() {
|
||||
val partial = ReplayGain(trackGain = -6f, trackPeak = 1f, albumGain = null, albumPeak = null)
|
||||
assertEquals(-6f, GainMath.gainDb(prefs(mode = NormalizationMode.ALBUM), partial, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a louder target raises every gain by the difference`() {
|
||||
assertEquals(-2f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK, target = -14), g, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headroom stops a boost 1 dB under the true peak and the limiter lets it through`() {
|
||||
val quiet = ReplayGain(trackGain = 8f, trackPeak = 0.5f, albumGain = null, albumPeak = null)
|
||||
val headroom = GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), quiet, false)
|
||||
assertEquals(-1f - 20f * log10(0.5f), headroom, 1e-4f)
|
||||
val limited = GainMath.gainDb(
|
||||
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
|
||||
quiet,
|
||||
false,
|
||||
)
|
||||
assertEquals(8f, limited)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no boost passes the cap`() {
|
||||
val silent = ReplayGain(trackGain = 30f, trackPeak = 0.001f, albumGain = null, albumPeak = null)
|
||||
val db = GainMath.gainDb(
|
||||
prefs(mode = NormalizationMode.TRACK, boost = NormalizationBoost.LIMITER),
|
||||
silent,
|
||||
false,
|
||||
)
|
||||
assertEquals(GainMath.MAX_BOOST_DB, db)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cuts are never limited by the peak`() {
|
||||
val hot = ReplayGain(trackGain = -9f, trackPeak = 1.4f, albumGain = null, albumPeak = null)
|
||||
assertEquals(-9f, GainMath.gainDb(prefs(mode = NormalizationMode.TRACK), hot, false))
|
||||
}
|
||||
|
||||
private fun pos(album: String, track: Int?, disc: Int? = 1) = AlbumPosition(album, disc, track)
|
||||
|
||||
@Test
|
||||
fun `in-order neighbours from the same album are album play`() {
|
||||
assertTrue(GainMath.playingAsAlbum(null, pos("x", 1), pos("x", 2)))
|
||||
assertTrue(GainMath.playingAsAlbum(pos("x", 1), pos("x", 2), pos("x", 3)))
|
||||
assertTrue(GainMath.playingAsAlbum(pos("x", 2), pos("x", 3), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shuffled album, a mix and a lone track are not album play`() {
|
||||
assertFalse(GainMath.playingAsAlbum(null, pos("x", 3), pos("x", 1)))
|
||||
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), pos("y", 2)))
|
||||
assertFalse(GainMath.playingAsAlbum(null, pos("x", 1), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disc order counts`() {
|
||||
assertTrue(GainMath.playingAsAlbum(pos("x", 12, disc = 1), pos("x", 1, disc = 2), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a neighbour without a track number is not evidence of order`() {
|
||||
assertFalse(GainMath.playingAsAlbum(pos("x", null), pos("x", 3), null))
|
||||
assertFalse(GainMath.playingAsAlbum(null, pos("x", null), pos("x", null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dbToLinear`() {
|
||||
assertEquals(1f, GainMath.dbToLinear(0f))
|
||||
assertEquals(0.1f, GainMath.dbToLinear(-20f), 1e-6f)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GainStageTest {
|
||||
|
||||
private fun floats(vararg v: Float): ByteBuffer =
|
||||
ByteBuffer.allocateDirect(v.size * 4).order(ByteOrder.nativeOrder()).apply {
|
||||
v.forEach { putFloat(it) }
|
||||
flip()
|
||||
}
|
||||
|
||||
private fun shorts(vararg v: Short): ByteBuffer =
|
||||
ByteBuffer.allocateDirect(v.size * 2).order(ByteOrder.nativeOrder()).apply {
|
||||
v.forEach { putShort(it) }
|
||||
flip()
|
||||
}
|
||||
|
||||
private fun runFloat(stage: GainStage, input: ByteBuffer, target: Float, limiting: Boolean): FloatArray {
|
||||
val out = ByteBuffer.allocateDirect(input.remaining()).order(ByteOrder.nativeOrder())
|
||||
stage.process(input, out, channels = 2, isFloat = true, target = target, limiting = limiting)
|
||||
out.flip()
|
||||
return FloatArray(out.remaining() / 4) { out.float }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stream that starts at its gain is scaled from the first sample`() {
|
||||
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 0.5f) }
|
||||
val out = runFloat(stage, floats(0.8f, -0.4f), target = 0.5f, limiting = false)
|
||||
assertEquals(0.4f, out[0], 1e-6f)
|
||||
assertEquals(-0.2f, out[1], 1e-6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a changed target ramps rather than steps`() {
|
||||
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
|
||||
val n = 44_100 // one second of stereo frames
|
||||
val input = floats(*FloatArray(n * 2) { 0.5f })
|
||||
val out = runFloat(stage, input, target = 0.5f, limiting = false)
|
||||
assertTrue(out[0] > 0.49f, "first frame barely moved: ${out[0]}")
|
||||
assertEquals(0.25f, out.last(), 1e-3f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the limiter holds a boosted peak at -1 dBFS`() {
|
||||
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
|
||||
val out = runFloat(stage, floats(0.9f, -0.9f, 0.1f, 0.1f), target = 4f, limiting = true)
|
||||
out.forEach { assertTrue(abs(it) <= GainStage.LIMIT_CEILING + 1e-6f, "sample $it over the ceiling") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without the limiter a boost is clamped to full scale, 16-bit included`() {
|
||||
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 4f) }
|
||||
val input = shorts(20_000, -20_000)
|
||||
val out = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder())
|
||||
stage.process(input, out, channels = 2, isFloat = false, target = 4f, limiting = false)
|
||||
out.flip()
|
||||
assertEquals(Short.MAX_VALUE, out.short)
|
||||
assertEquals((-Short.MAX_VALUE).toShort(), out.short)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unity is a pass-through`() {
|
||||
val stage = GainStage().apply { reset(sampleRate = 44_100, channels = 2, startGain = 1f) }
|
||||
assertTrue(stage.isUnity(target = 1f, limiting = false))
|
||||
assertTrue(!stage.isUnity(target = 1f, limiting = true))
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.fabledsword.minstrel.player.gain
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.ReplayGainApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedReplayGain
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
|
||||
import com.fabledsword.minstrel.models.wire.ReplayGainWire
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/** The fallback ladder: the library cache, then the server, then nothing. */
|
||||
class ReplayGainStoreTest {
|
||||
private val dao: CachedTrackDao = mockk()
|
||||
private val api: ReplayGainApi = mockk()
|
||||
private var health = ServerHealth.Healthy
|
||||
|
||||
private fun store() = ReplayGainStore(
|
||||
scope = TestScope(),
|
||||
trackDao = dao,
|
||||
api = api,
|
||||
serverHealth = { health },
|
||||
clock = { 0L },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `cached gains are used and only the rest are asked of the server`() = runTest {
|
||||
coEvery { dao.replayGains(listOf("a", "b", "c")) } returns listOf(
|
||||
CachedReplayGain("a", trackGain = -6f, trackPeak = 1f, albumGain = -4f, albumPeak = 1f),
|
||||
// Cached but unmeasured at the last sync: the server may know more.
|
||||
CachedReplayGain("b", trackGain = null, trackPeak = null, albumGain = null, albumPeak = null),
|
||||
)
|
||||
coEvery { api.getReplayGain("b,c") } returns ReplayGainResponseWire(
|
||||
items = mapOf("b" to ReplayGainWire(trackGain = -3f, trackPeak = 0.9f)),
|
||||
)
|
||||
val s = store()
|
||||
|
||||
s.load(listOf("a", "b", "c"))
|
||||
|
||||
assertEquals(-4f, s.get("a")?.albumGain)
|
||||
assertEquals(-3f, s.get("b")?.trackGain)
|
||||
assertNull(s.get("c"), "a track the server has not measured stays unknown")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline, the cache is all there is`() = runTest {
|
||||
health = ServerHealth.Offline
|
||||
coEvery { dao.replayGains(any()) } returns emptyList()
|
||||
val s = store()
|
||||
|
||||
s.load(listOf("a"))
|
||||
|
||||
assertNull(s.get("a"))
|
||||
coVerify(exactly = 0) { api.getReplayGain(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed lookup leaves the track unknown, to be asked again`() = runTest {
|
||||
coEvery { dao.replayGains(any()) } returns emptyList()
|
||||
coEvery { api.getReplayGain(any()) } throws IOException("reset")
|
||||
val s = store()
|
||||
|
||||
s.load(listOf("a"))
|
||||
|
||||
assertNull(s.get("a"))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user