Compare commits

..
Author SHA1 Message Date
bvandeusen 237380b122 New brand mark, and both clients stop fetching their fonts at runtime (#128)
test-web / test (push) Successful in 54s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m36s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-09 14:36:15 -04:00
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 727f68950e Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
2026-08-17 16:28:13 -04:00
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
510 changed files with 3301 additions and 39470 deletions
+5 -19
View File
@@ -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/
+95
View File
@@ -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
+106 -671
View File
@@ -2,407 +2,55 @@ 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)
# workflow_dispatch → manual trigger (same rules based on the ref)
# push to main → :main and :latest (latest-release APK bundled)
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built 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
# freshly-built APK, signed with the SAME key as release builds — a different
# key cannot install over the stable app, so anyone crossing channels would
# have to uninstall and lose their data.
#
# :dev is published ALONE, with no per-commit tag. A rolling channel is
# rolling by definition; a commit-addressable image for it would be a
# rollback target nobody ever pulls, kept forever. Recovery on dev is to fix
# forward.
#
# Note what this repo does NOT need: a cross-repo dispatch to refresh the
# channel when its bundled APK is rebuilt. That mechanism exists elsewhere in
# the family because the app and the server live in separate repos. Minstrel
# is a monorepo — one push builds the APK and the image in the same run from
# the same commit, so the channel cannot go stale against its own artifact.
# The requirement is satisfied structurally; copying the mechanism would add
# a moving part to fix a problem that does not exist here.
#
# Release model: the tag IS the artifact's version name with a `v` in front.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
# between what the tag says and what the APK reports, and nothing to look up
# when minting one.
#
# TAGS ARE IMMUTABLE. Never move, retarget or delete a published tag. A
# same-day second release is not a collision — HHMM makes every tag unique
# by construction, so the answer is simply another tag.
#
# This block used to say the opposite: that the per-day tag was
# "intentionally mutable" and that a same-day re-cut should
# `git push -f origin vYYYY.MM.DD`. That instruction is what the family
# rulebook now forbids outright, and it has incidents behind it — moving a
# same-day tag forward once took a published release down with it. Anyone
# installing from a tag is holding something the tag no longer points at,
# which is a worse failure than an extra row in the tag list.
#
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
# Release model: per-day CalVer tags (no trailing patch digit). The day's
# tag is intentionally mutable — if a second release happens the same day,
# move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# the same name gets overwritten. :latest is updated by every main push
# AND every tag push, so it always reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# 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
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# AND the version sidecar published beside it — the recorded values, not
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
# keep bundling their own freshly-built APK.
# and reconstructs its exact versionName (tag + commit-count, the same
# formula android-release bakes in) for the version sidecar — no rebuild,
# just 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:
branches: [main, dev]
branches: [main]
tags: ['v*']
paths-ignore:
- '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
# into this: they are immutable and unique, so no tag build can ever be
# superseded by another run on the same ref.
# Force-moving the per-day tag (or rapidly re-pushing to main) should
# supersede the in-flight build — the operator explicitly wants the
# later commit to win.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
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
# all. Without it the only way to get a build onto a phone was to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
name: Build signed APK (tag releases only)
if: startsWith(github.ref, 'refs/tags/v')
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -427,18 +75,14 @@ jobs:
outputs:
version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history. The version name now reads only the tip commit's
# timestamp, so a shallow clone would technically serve — but this
# job derives a value that ships to devices, and a shallow checkout
# changes what git-derived values resolve to WITHOUT failing. The
# whole failure class here is a green build carrying a wrong
# version, so the cheap guarantee is worth keeping.
# fetch-depth: 0 retrieves full history; default shallow clone
# would return 1 for `git rev-list --count HEAD`, breaking the
# iteration suffix.
fetch-depth: 0
- name: Compute release version
@@ -447,23 +91,12 @@ jobs:
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
# The derivation lives in ci/version.sh, not here, so it can be
# executed by a test on every push. Anything inline in this file is
# unverifiable until a release is already running.
out="$(ci/version.sh HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
# The channel is a property of the LANE, not of the commit, which is
# why it is derived here rather than in version.sh. Same commit built
# on dev and on main reports the same NAME and differs only here —
# that is the whole point of separating the two values.
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
channel=dev
else
channel=stable
fi
echo "channel=${channel}" >> "$GITHUB_OUTPUT"
echo "::notice::APK $(printf '%s' "${out}" | tr '\n' ' ') channel=${channel}"
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION_NAME="${TAG}.${COMMIT_COUNT}"
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent —
@@ -475,7 +108,6 @@ jobs:
# the release together, so this passes). A bare `git push origin vX` is the
# case this catches.
- name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
working-directory: ${{ github.workspace }}
env:
@@ -523,49 +155,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,63 +170,17 @@ 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.
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
VERSION_CODE: ${{ needs.android-release.outputs.version_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.
#
# This is what lets a later :latest build stop RECONSTRUCTING the
# bundled APK's version and simply read what was recorded. The
# ordering key in particular cannot be re-derived after the fact —
# it is build-time minutes, so once this job ends the value exists
# nowhere else. Reconstruction could only ever recover the name,
# and only by duplicating a formula that then has to be kept in
# step across two files.
SIDECAR_PATH="/tmp/minstrel.apk.version"
printf '{"name":"%s","code":%s,"channel":"stable"}\n' \
"${VERSION_NAME}" "${VERSION_CODE}" > "${SIDECAR_PATH}"
cat "${SIDECAR_PATH}"
RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
@@ -651,37 +202,15 @@ jobs:
exit 1
fi
# Same treatment for the sidecar. Named `.apk.version` so the
# downloader's `\.apk$` match cannot pick it up by mistake.
SIDECAR_HTTP=$(curl -sS -L -o /tmp/upload-sidecar.out -w '%{http_code}' \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@${SIDECAR_PATH}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=minstrel-${TAG}.apk.version")
echo "sidecar_upload_http=${SIDECAR_HTTP}"
cat /tmp/upload-sidecar.out || true
echo
if [ "${SIDECAR_HTTP}" -lt 200 ] || [ "${SIDECAR_HTTP}" -ge 300 ]; then
echo "::error::version sidecar upload returned HTTP ${SIDECAR_HTTP}"
exit 1
fi
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 +222,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.
# Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled
# APK's exact versionName (see "Bundle latest release APK" below).
fetch-depth: 0
fetch-tags: true
- name: Detect buildable project
id: guard
@@ -720,68 +244,21 @@ 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"
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"
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"
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; 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
@@ -790,57 +267,54 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main`
# takes the "Bundle latest release APK" path below, because it is the
# one ref that moves a channel without building an APK of its own.
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
# Tag pushes only — android-release just produced this. Non-tag
# builds take the "Bundle latest release APK" path below instead.
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
# 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/
- name: Stage bundled APK + version sidecar
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
# All three pulled from android-release's outputs so the sidecar the
# server hands clients matches exactly what is baked into the APK
# they are comparing against.
# Pulled from android-release.outputs.version_name so the
# sidecar string the server hands clients matches the
# versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
APK_VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
APK_CHANNEL: ${{ needs.android-release.outputs.channel }}
run: |
set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# the sidecar published beside it, so what the server reports is what
# that build actually recorded rather than something re-derived here.
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# TAG) — identical to android-release's formula) so the version
# sidecar the server hands clients matches the installed build.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release or APK asset can be resolved. That
# degradation only actually works because the greps below carry
# `|| true`; under the runner's default pipefail a non-matching grep
# kills the step instead of falling through to the empty-case branch.
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
# version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -852,53 +326,26 @@ jobs:
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
# `|| true` on every one of these, and it is load-bearing rather
# than defensive habit. The runner already invokes this shell as
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
# exits non-zero even though `head` succeeded — and the step dies at
# the assignment, before ever reaching the `if` written to handle the
# empty case. Every "degrades gracefully" branch below is unreachable
# without this.
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
# Take the version the release RECORDED rather than recomputing it.
# This used to re-derive the name from the tagged commit, which meant
# the formula lived in two files that had to be kept in step, and it
# could only ever recover the name — the ordering key is build-time
# minutes and does not exist anywhere after that build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi
echo "::notice::bundled release APK from ${TAG}"
VERSION_NAME="${TAG#v}.${COUNT}"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
ls -lh client/
- 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 +356,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 +368,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 +408,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}"
+150
View File
@@ -0,0 +1,150 @@
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'
# 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 ./...
+44
View File
@@ -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
-5
View File
@@ -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
View File
@@ -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 \
+11 -31
View File
@@ -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,11 @@ 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` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
- `: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.
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.
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.
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
## Specs
@@ -159,8 +140,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 +150,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).
+8 -23
View File
@@ -21,24 +21,13 @@ android {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
// versionName / versionCode are released-build values injected by CI.
// Local / debug builds fall back to "dev" so the About card reads
// honestly.
//
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
// every lane building this source reports the same string and the
// channel is the only thing that differs between them.
//
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by
// construction.
//
// This comment used to say versionCode was a commit count and that it
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
// versionName / versionCode are released-build values injected by
// CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
@@ -72,13 +61,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")
}
-27
View File
@@ -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() },
@@ -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
}
}
@@ -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"
}
@@ -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?)
}
@@ -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
}
@@ -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)
}
@@ -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?,
)
@@ -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,
)
@@ -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(),
)
@@ -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?,
)
@@ -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
}
}
@@ -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(),
)
@@ -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
}
@@ -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
}
@@ -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,26 +1,15 @@
package com.fabledsword.minstrel.models
/**
* The server-bundled APK, as reported by `GET /api/client/version`.
* Wire shape returned by `GET /api/client/version`. Mirrors
* the Flutter client's `UpdateInfo`.
*
* Three values that are deliberately kept apart:
*
* - [version] is a LABEL for people — "YYYY.MM.DD.HHMM", derived from the
* build's commit, so two channels carrying the same code read the same.
* Display this; never decide on it when [code] is present.
* - [code] is the ORDERING KEY, and is the same value Android itself
* installs by. It answers "may this be installed over that?", which the
* name cannot. Null when the server predates the field.
* - [channel] is a SIBLING FIELD, never a suffix inside the name. Reported
* verbatim rather than validated, so an unexpected value is shown rather
* than dropped.
*
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
* `version` is the server-bundled APK version (may have a leading
* "v" from the git tag); `apkUrl` is server-relative (e.g.
* `/api/client/apk`); `sizeBytes` is the download size.
*/
data class UpdateInfo(
val version: String,
val code: Long?,
val channel: String?,
val apkUrl: String,
val sizeBytes: Long,
)
@@ -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,
)
/**
@@ -4,26 +4,12 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `GET /api/client/version`.
*
* `apkUrl` falls back to `/api/client/apk` if the server omits it.
*
* [code] MUST stay nullable, and this is not a style preference. The app's
* Json is configured with `coerceInputValues = true`, which replaces a JSON
* null with the declared default for a NON-nullable property — so writing
* `val code: Long = 0` would turn "this server reports no ordering key" into
* "this build's ordering key is 0", silently, with no error anywhere. A
* nullable type is what keeps absent distinguishable from zero, and the
* distinction is the whole reason the field exists.
*
* A server predating the ordering key sends neither [code] nor [channel];
* both arrive null and the caller falls back to comparing names.
* Wire shape for `GET /api/client/version`. Defaults match Flutter:
* apk_url falls back to `/api/client/apk` if the server omits it.
*/
@Serializable
data class UpdateInfoWire(
val version: String = "",
val code: Long? = null,
val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0,
)
@@ -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 ────────────────────────
@@ -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)
}
}
},
)
@@ -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,
)
}
@@ -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()
}
}
}
}
@@ -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"
}
}
@@ -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"
@@ -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")
}
}
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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"
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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
}
}
@@ -109,11 +109,8 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
* NowPlayingScreen via [onExpandClick].
*
* Layout (Column):
* - Slim seek slider pinned at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next.
* Weighted so it fills the rest of the fixed-height bar and centres its
* own content; otherwise the row keeps its intrinsic 48dp and the
* leftover height collects at the bottom as dead surface.
* - Slim seek slider at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next
*
* No kebab on the mini bar (operator 2026-06-01): the full kebab
* surface lives on NowPlayingScreen, and dropping it from the mini
@@ -167,12 +164,6 @@ fun MiniPlayer(
durationMs = state.durationMs,
)
MiniRow(
// Take whatever the progress fill leaves. Without this the
// Column stacks 4dp + the row's intrinsic 48dp from the top
// and the remaining 28dp of an 80dp bar sits empty
// underneath — the content looked top-aligned rather than
// centred, with a dead strip above the gesture bar.
modifier = Modifier.weight(1f),
track = track,
isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading,
@@ -214,7 +205,6 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
@Composable
@Suppress("LongParameterList")
private fun MiniRow(
modifier: Modifier,
track: TrackRef,
isPlaying: Boolean,
isUpnpLoading: Boolean,
@@ -226,7 +216,7 @@ private fun MiniRow(
onToggleLike: () -> Unit,
) {
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -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)
@@ -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,
}
@@ -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)
}
}
}
@@ -9,7 +9,7 @@ import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isUpdateAvailable
import com.fabledsword.minstrel.update.data.isVersionNewer
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,14 +37,6 @@ sealed interface UpdateCheckResult {
data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME,
// The value the platform installs by, and therefore the one the update
// 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,
@@ -53,9 +45,8 @@ data class AboutUiState(
/**
* Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus this build via
* [isUpdateAvailable] — on the ordering key where the server reports one,
* on the name otherwise — and reports the terminal state.
* [UpdateRepository.getLatest], compares versus the build's
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
* When an update is available, [install] downloads the APK via
* [ApkInstaller] and installs it — routing the user to the "install
* unknown apps" settings page first when that permission hasn't been
@@ -71,21 +62,13 @@ 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
val installedCode = internal.value.installedCode
val result = runCatching { repository.getLatest() }
.map { latest ->
if (
isUpdateAvailable(
serverCode = latest.code,
serverName = latest.version,
installedCode = installedCode,
installedName = installed,
)
) {
if (isVersionNewer(latest.version, installed)) {
UpdateCheckResult.UpdateAvailable(latest)
} else {
UpdateCheckResult.Latest
@@ -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."
}
@@ -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) }
}
}
@@ -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,
)
}
@@ -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) }
@@ -19,8 +19,7 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
/**
* Drives the shell's soft "update available" banner. Polls
* `/api/client/version` at launch + every 24h and, when the bundled
* APK outranks this build — by ordering key where the server reports one,
* by name otherwise — exposes its [UpdateInfo] so
* APK is strictly newer than this build, exposes its [UpdateInfo] so
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
* install. Mirrors Flutter's `ClientUpdateController`.
*
@@ -29,9 +28,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 +44,6 @@ class UpdateBannerController @Inject constructor(
}.stateIn(scope, SharingStarted.Eagerly, null)
init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch {
while (true) {
runOnce()
@@ -66,13 +58,6 @@ class UpdateBannerController @Inject constructor(
private suspend fun runOnce() {
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
latest.value = info.takeIf {
isUpdateAvailable(
serverCode = it.code,
serverName = it.version,
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
)
}
latest.value = info.takeIf { isVersionNewer(it.version, BuildConfig.VERSION_NAME) }
}
}
@@ -21,39 +21,10 @@ class UpdateRepository @Inject constructor(retrofit: Retrofit) {
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
version = version,
code = code,
channel = channel,
apkUrl = apkUrl,
sizeBytes = sizeBytes,
)
/**
* True when [server] should be offered over the installed build.
*
* **Decide on the ordering key whenever the server sends one.** That is the
* same value Android's package installer compares, so an offer made this way
* implies an install the platform will actually accept. The app used to
* compare NAMES while the platform installed by `versionCode`, with nothing
* keeping the two orderings consistent — so it could offer a build Android
* then refused as a downgrade, or stay quiet about one it would have taken.
*
* Name comparison survives only as the fallback for a server that predates
* the field. A null code means "this server cannot tell me" — never "zero" —
* because treating absent as zero would rank every such server as infinitely
* old and offer its build to everyone, forever.
*/
fun isUpdateAvailable(
serverCode: Long?,
serverName: String,
installedCode: Long,
installedName: String,
): Boolean =
if (serverCode != null) {
serverCode > installedCode
} else {
isVersionNewer(serverName, installedName)
}
/**
* True when [server] is strictly newer than [installed]. Mirrors
* Flutter's `isVersionNewer` — splits both strings on `.`, parses
@@ -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) }
}
}
@@ -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")
}
}
@@ -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()) }
}
}
@@ -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()))
}
}
@@ -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))
}
}

Some files were not shown because too many files have changed in this diff Show More