Compare commits

..
Author SHA1 Message Date
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
577 changed files with 3158 additions and 42144 deletions
+5 -19
View File
@@ -6,20 +6,9 @@
**/build **/build
web/build web/build
# The Android client — built by its own job, never from this context. The APK # Flutter mobile client — built separately on developer machines / Flutter CI.
# reaches the image through client/, downloaded as a CI artifact, so nothing # Including it in the Go build context wastes ~70 files and invalidates the
# here reads android/ sources. # `COPY . .` layer cache on every Flutter-only change.
#
# 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/
# Docs and IDE noise # Docs and IDE noise
docs/ docs/
@@ -37,8 +26,5 @@ docs/
!.env.example !.env.example
# CI workflow files don't need to ship in the image. # CI workflow files don't need to ship in the image.
# .forgejo/
# This said `.forgejo/` and `.github/` — neither of which this repo has. Gitea .github/
# Actions reads `.gitea/`, so the one directory that actually exists was the
# one not excluded, and every workflow edit invalidated the context.
.gitea/
+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. # Builds and pushes the minstrel container image to the Gitea registry.
# #
# push to dev → :dev (freshly-built dev APK bundled) # push to main → :main and :latest (latest-release APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled) # push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled) # workflow_dispatch → manual trigger (same rules based on the ref)
# 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. # 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,
# :<sha> on main is the ROLLBACK UNIT — every production commit addressable # move the tag with `git push -f origin vYYYY.MM.DD` and the image tag of
# without a release ceremony. It is minted only on main, where rollback is # the same name gets overwritten. :latest is updated by every main push
# actually worth having: merges are gated (rule 2) so they number in the dozens # AND every tag push, so it always reflects the newest blessed image.
# 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.
# #
# APK pipeline: on tag pushes the android-release job builds + signs the # APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release # Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot # job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no # start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Attaching the APK to the gitea Release is # race, no silent-failure mode. Asset attachment to the gitea Release
# its own job (release-assets), behind the test gate below. # 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 always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip # :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 # 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 # non-tag builds image-release pulls the MOST RECENT release's signed APK
# AND the version sidecar published beside it — the recorded values, not # and reconstructs its exact versionName (tag + commit-count, the same
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds # formula android-release bakes in) for the version sidecar — no rebuild,
# keep bundling their own freshly-built APK. # just rebundle. Tag builds keep bundling their own freshly-built APK.
# #
# THE GATE (rule 177, M462 #4984). Every verifying lane lives in this file — # Android testing (lint + detekt + unit tests, debug APK upload on main)
# Go (vet, lint, short tests), the Postgres integration suite, the web app # lives in android.yml and runs independently on every push.
# (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.
on: on:
push: push:
branches: [main, dev] branches: [main]
tags: ['v*'] tags: ['v*']
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
workflow_dispatch: 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 # Force-moving the per-day tag (or rapidly re-pushing to main) should
# operator explicitly wants the later commit to win. Tags no longer enter # supersede the in-flight build — the operator explicitly wants the
# into this: they are immutable and unique, so no tag build can ever be # later commit to win.
# superseded by another run on the same ref.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: 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: android-release:
name: Build signed APK (releases and dev) name: Build signed APK (tag releases only)
# Also builds on `dev`, which is what makes a test channel possible at if: startsWith(github.ref, 'refs/tags/v')
# 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'
runs-on: flutter-ci runs-on: flutter-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-android:36 image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -427,18 +75,14 @@ jobs:
outputs: outputs:
version_name: ${{ steps.ver.outputs.name }} version_name: ${{ steps.ver.outputs.name }}
version_code: ${{ steps.ver.outputs.code }} version_code: ${{ steps.ver.outputs.code }}
channel: ${{ steps.ver.outputs.channel }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history. The version name now reads only the tip commit's # fetch-depth: 0 retrieves full history; default shallow clone
# timestamp, so a shallow clone would technically serve — but this # would return 1 for `git rev-list --count HEAD`, breaking the
# job derives a value that ships to devices, and a shallow checkout # iteration suffix.
# 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 fetch-depth: 0
- name: Compute release version - name: Compute release version
@@ -447,23 +91,12 @@ jobs:
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -euo pipefail set -euo pipefail
# The derivation lives in ci/version.sh, not here, so it can be TAG="${GITHUB_REF#refs/tags/v}"
# executed by a test on every push. Anything inline in this file is COMMIT_COUNT=$(git rev-list --count HEAD)
# unverifiable until a release is already running. VERSION_NAME="${TAG}.${COMMIT_COUNT}"
out="$(ci/version.sh HEAD)" echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT" echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})"
# 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}"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea # 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 — # 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 # the release together, so this passes). A bare `git push origin vX` is the
# case this catches. # case this catches.
- name: Release must exist for this tag - name: Release must exist for this tag
if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
env: env:
@@ -523,49 +155,14 @@ jobs:
-PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \ -PMINSTREL_VERSION_NAME=${{ steps.ver.outputs.name }} \
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }} -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 - name: Upload APK as workflow artifact
# Stock action (snippet #2271) — never @v3, which uploads something Gitea # Mirrored action, never actions/upload-artifact — @v4+ refuses on the
# will never serve back. This is the producing half of a pair: # hostname, @v3 uploads something Gitea will never serve back. This is
# image-release downloads `minstrel-apk` below. Any upload v4+ pairs with # the producing half of a pair: image-release downloads `minstrel-apk`
# any download v4+ on this forge (every combination tested 2026-09-10, # below with the matching download-artifact mirror. Both must stay on
# Scribe spike #3843), so the two pins need not move together. # the v4 protocol — mixing a v3 upload with a v4 download (or the
uses: actions/upload-artifact@v7 # reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with: with:
name: minstrel-apk name: minstrel-apk
path: android/app/build/outputs/apk/release/app-release.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. # artifact existing, so an empty upload must fail here, not there.
if-no-files-found: error 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 - 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 shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
VERSION_NAME: ${{ needs.android-release.outputs.version_name }}
VERSION_CODE: ${{ needs.android-release.outputs.version_code }}
run: | run: |
set -euxo pipefail set -euxo pipefail
TAG="${GITHUB_REF#refs/tags/}" TAG="${GITHUB_REF#refs/tags/}"
REPO="${GITHUB_REPOSITORY}" REPO="${GITHUB_REPOSITORY}"
APK_PATH="release-apk/app-release.apk" APK_PATH="app/build/outputs/apk/release/app-release.apk"
ls -lh "${APK_PATH}" 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 \ RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")" "https://git.fabledsword.com/api/v1/repos/${REPO}/releases/tags/${TAG}")"
@@ -651,37 +202,15 @@ jobs:
exit 1 exit 1
fi 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: image-release:
name: Build + push container image name: Build + push container image
# Every lane must have SUCCEEDED, each named here (rule 177). Then the # `needs:` waits for android-release. For tag pushes android-release
# APK: tag and dev pushes build one and it must have succeeded; main # runs and must succeed before this job starts — guaranteeing the
# pushes skip android-release and bundle the latest release's APK # APK artifact is present. For main pushes android-release is
# instead, so for main alone a skipped android-release is expected. # skipped; the `if: ...` below lets this job run anyway and the
needs: [go, integration, web, android, govulncheck, android-release] # download/copy steps gate themselves on the tag context.
if: >- needs: [android-release]
${{ if: ${{ !failure() && !cancelled() }}
!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')) }}
runs-on: go-ci runs-on: go-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26 image: git.fabledsword.com/bvandeusen/ci-go:1.26
@@ -693,16 +222,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history, and rule 149 names this specifically: any job that # Full history + tags so non-tag :latest builds can resolve the
# DERIVES the version name needs it, because a shallow clone changes # latest release tag's commit count and reconstruct the bundled
# what git-derived values resolve to WITHOUT failing — a too-low # APK's exact versionName (see "Bundle latest release APK" below).
# value, silently, with every lane green.
#
# This job was depth-1 while it took the version from GITHUB_REF. It
# now runs ci/version.sh itself, because with :<version> image tags
# gone the server's self-reported version is the only thing that says
# which build an image is.
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
- name: Detect buildable project - name: Detect buildable project
id: guard id: guard
@@ -720,68 +244,21 @@ jobs:
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
shell: bash shell: bash
run: | 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 if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
# A release refreshes the CHANNEL and mints nothing else. VERSION="${GITHUB_REF#refs/tags/}"
# echo "args=-t ${IMAGE}:${VERSION} -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
# The tag build exists to produce the signed APK and attach it to echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
# the release; the image it rebuilds is the SAME SOURCE as the main echo "::notice::Release build: ${VERSION} + latest"
# 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"
else else
# The production line: :latest tracks main's tip (rule 147) and # Main is the protected, post-PR-merge branch. Treat it as the
# :<sha> is the rollback unit (rule 145). Full 40-char SHA, matching # rolling stable channel — every main push moves :latest.
# the family's other repos, so a rollback target is addressable # Pinned consumers can target :vYYYY.MM.DD; everyone else
# straight from the commit anyone is reading. # gets the newest main.
CHANNEL=stable echo "args=-t ${IMAGE}:main -t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "args=-t ${IMAGE}:latest -t ${IMAGE}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT" echo "version=main" >> "$GITHUB_OUTPUT"
echo "::notice::Main-branch build ${VERSION}: :latest + :${GITHUB_SHA}" echo "::notice::Main-branch build: :main + :latest"
fi fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
- name: Registry login - name: Registry login
if: steps.guard.outputs.ready == 'true' if: steps.guard.outputs.ready == 'true'
shell: bash shell: bash
@@ -790,57 +267,54 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact - name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main` # Tag pushes only — android-release just produced this. Non-tag
# takes the "Bundle latest release APK" path below, because it is the # builds take the "Bundle latest release APK" path below instead.
# 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')
if: >- # Consuming half of the pair — never actions/download-artifact. Same fork,
steps.guard.outputs.ready == 'true' && # same reason: upstream's client-side GHES check rejects this hostname
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev') # before it connects. bvandeusen/download-artifact mirrors
# Consuming half of the pair: stock download-artifact, which works here for # code.forgejo.org/forgejo/download-artifact.
# 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 # SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
# CI-runner image carries — the runner uses the image's own node. # the action's own version number — the two actions release on unrelated
uses: actions/download-artifact@v8 # 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: with:
name: minstrel-apk name: minstrel-apk
path: client/ path: client/
- name: Stage bundled APK + version sidecar - name: Stage bundled APK + version sidecar
if: >- if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
shell: bash shell: bash
env: env:
# All three pulled from android-release's outputs so the sidecar the # Pulled from android-release.outputs.version_name so the
# server hands clients matches exactly what is baked into the APK # sidecar string the server hands clients matches the
# they are comparing against. # versionName baked into the APK they're comparing against.
APK_VERSION_NAME: ${{ needs.android-release.outputs.version_name }} 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: | run: |
set -euxo pipefail set -euxo pipefail
# The artifact lands as `app-release.apk` (the original Gradle # The artifact lands as `app-release.apk` (the original Gradle
# output name). The Dockerfile COPYs client/* into /app/client/ # output name). The Dockerfile COPYs client/* into /app/client/
# and the server reads minstrel.apk + minstrel.apk.version. # and the server reads minstrel.apk + minstrel.apk.version.
mv client/app-release.apk client/minstrel.apk mv client/app-release.apk client/minstrel.apk
printf '{"name":"%s","code":%s,"channel":"%s"}\n' \ echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
"${APK_VERSION_NAME}" "${APK_VERSION_CODE}" "${APK_CHANNEL}" \
> client/minstrel.apk.version
cat client/minstrel.apk.version
ls -lh client/ ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds) - name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so # Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest # without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and # 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 # reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
# that build actually recorded rather than something re-derived here. # 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 # Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release or APK asset can be resolved. That # version — if no release / APK asset / tag-count can be resolved.
# degradation only actually works because the greps below carry if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
# `|| 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'
shell: bash shell: bash
env: env:
CI_TOKEN: ${{ secrets.CI_TOKEN }} CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -852,53 +326,26 @@ jobs:
if [ -z "${REL_JSON}" ]; then if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0 echo "::notice::no published release — image ships without bundled APK"; exit 0
fi fi
# `|| true` on every one of these, and it is load-bearing rather TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
# than defensive habit. The runner already invokes this shell as APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
# `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
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi fi
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}" COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
if [ -z "${COUNT}" ]; then
# Take the version the release RECORDED rather than recomputing it. echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
# 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
fi 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/ ls -lh client/
- name: Build and push - name: Build and push
if: steps.guard.outputs.ready == 'true' 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: | run: |
docker buildx build --pull \ docker buildx build \
--build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \ --build-arg MINSTREL_VERSION="${{ steps.tags.outputs.version }}" \
--build-arg MINSTREL_CHANNEL="${{ steps.tags.outputs.channel }}" \
--push ${{ steps.tags.outputs.args }} . --push ${{ steps.tags.outputs.args }} .
# Verifies a tag release actually ended up complete, and names the specific # 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 # `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 # 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 # already moved `:latest`, so the code was deployable and nothing looked
# obviously wrong. The release was simply missing its APK and its image, # obviously wrong. The release was simply missing its APK and its immutable
# which is easy to skim past. # `:vYYYY.MM.DD` image, which is easy to skim past.
# #
# This job cannot prevent that (the cause was a runner failing to launch, not # 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 # anything in this file). What it does is turn an incomplete release into an
@@ -921,7 +368,7 @@ jobs:
# above did NOT succeed. # above did NOT succeed.
verify-release: verify-release:
name: Verify release artifacts (tag releases only) 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') }} if: ${{ always() && startsWith(github.ref, 'refs/tags/v') }}
runs-on: go-ci runs-on: go-ci
container: container:
@@ -961,30 +408,18 @@ jobs:
# missing when v2026.08.07 had to be re-cut. `always()` on this job means # 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 # it runs even when image-release failed, so without this the guard would
# cheerfully verify an incomplete release. # cheerfully verify an incomplete release.
# - name: Immutable image tag must exist
# 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
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
IMAGE="git.fabledsword.com/bvandeusen/minstrel" IMAGE="git.fabledsword.com/bvandeusen/minstrel"
echo "${{ secrets.CI_TOKEN }}" \ echo "${{ secrets.CI_TOKEN }}" \
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin | docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
if ! docker manifest inspect "${IMAGE}:${GITHUB_SHA}" > /dev/null 2>&1; then if ! docker manifest inspect "${IMAGE}:${TAG}" > /dev/null 2>&1; then
echo "::error::image ${IMAGE}:${GITHUB_SHA} does not exist — this commit has no rollback target." 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."
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."
exit 1 exit 1
fi 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 binary, built with `go test -c`
*.test *.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 # Bundled Android APK + version sidecar (#397). Populated by CI for
# tag releases; never committed. README in client/ explains the flow. # tag releases; never committed. README in client/ explains the flow.
client/minstrel.apk client/minstrel.apk
+6 -21
View File
@@ -7,7 +7,7 @@ RUN npm ci
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build
FROM golang:1.26-bookworm AS builder FROM golang:1.25-bookworm AS builder
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -15,32 +15,17 @@ COPY . .
# Overwrite the committed placeholder with the freshly-built SPA assets. # Overwrite the committed placeholder with the freshly-built SPA assets.
COPY --from=web /web/build ./web/build COPY --from=web /web/build ./web/build
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
# Version stamping. release.yml passes the DERIVED version name # Version stamping: release.yml passes the git tag via MINSTREL_VERSION
# (YYYY.MM.DD.HHMM) and the lane's channel; a local `docker build` falls back # build-arg; local `docker build` falls back to "dev". Surfaced at
# to "dev"/"local". Both are surfaced at /healthz. # /healthz for operator-side image-version verification.
#
# 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.
ARG MINSTREL_VERSION=dev ARG MINSTREL_VERSION=dev
ARG MINSTREL_CHANNEL=local
RUN go build -trimpath \ RUN go build -trimpath \
-ldflags="-s -w \ -ldflags="-s -w -X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}'" \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerVersion=${MINSTREL_VERSION}' \
-X 'git.fabledsword.com/bvandeusen/minstrel/internal/server.ServerChannel=${MINSTREL_CHANNEL}'" \
-o /out/minstrel ./cmd/minstrel -o /out/minstrel ./cmd/minstrel
FROM debian:bookworm-slim 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 \ 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/* && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1000 minstrel \ 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: services:
minstrel: minstrel:
image: git.fabledsword.com/bvandeusen/minstrel:latest 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'] ports: ['4533:4533']
volumes: volumes:
# Your music library. Point ./music at wherever your audio files # Your music library. Point ./music at wherever your audio files
# live. Writable, because Minstrel deletes a file when an admin asks # live. Mounted read-only — Minstrel never writes to your library.
# it to (for example, quarantine's "Delete file"). It never moves, - ./music:/music:ro
# 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
# Generated data: playlist cover collages, artist art, caches. # Generated data: playlist cover collages, artist art, caches.
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image # The path must match MINSTREL_STORAGE_DATA_DIR, which the image
# sets to /app/data — keep this mount on /app/data or your cache # sets to /app/data — keep this mount on /app/data or your cache
@@ -54,7 +47,7 @@ services:
environment: environment:
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
# Colon-separated library roots to scan; must match the container # 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 MINSTREL_LIBRARY_SCAN_PATHS: /music
depends_on: [db] depends_on: [db]
@@ -79,9 +72,9 @@ docker compose up -d
## First run ## 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> <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). 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 ## Configuration
Most operators only need the env vars in the quickstart above. A few extras worth knowing: 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>`): 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. - `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
- `:<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. - `: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.)
- `: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. - `: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. 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.
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.
## Specs ## Specs
@@ -159,8 +140,7 @@ Two concurrent dev processes:
truncates your dev `minstrel` data (admin user, library, likes). It truncates your dev `minstrel` data (admin user, library, likes). It
brings up the compose Postgres and creates the test DB if missing. 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 - CI runs both: a fast `go test -short -race` gate plus an integration
job with its own ephemeral Postgres (the `integration` lane in job with its own ephemeral Postgres (`.gitea/workflows/test-go.yml`).
`.gitea/workflows/release.yml`, which also gates every image publish).
### Production build ### Production build
@@ -170,7 +150,7 @@ Two concurrent dev processes:
- Day-to-day work happens on `dev` (or feature branches merged into `dev`). - Day-to-day work happens on `dev` (or feature branches merged into `dev`).
- `main` is **protected** — changes land via PR from `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). Task and milestone tracking: Fable (`Minstrel` project, id 12).
+9 -23
View File
@@ -21,24 +21,13 @@ android {
applicationId = "com.fabledsword.minstrel" applicationId = "com.fabledsword.minstrel"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
// versionName / versionCode are released-build values injected by CI. // versionName / versionCode are released-build values injected by
// Local / debug builds fall back to "dev" so the About card reads // CI from the git tag + commit count. Local / debug builds fall
// honestly. // back to "dev" so the About card reads honestly. Releases ship
// // versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so // versionCode=<commits>, which is monotonic forever and lets the
// every lane building this source reports the same string and the // shared isVersionNewer comparator distinguish two same-day
// channel is the only thing that differs between them. // re-cuts (the iteration suffix differs).
//
// 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.
val versionNameOverride = val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride = val versionCodeOverride =
@@ -72,13 +61,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "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 = signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) { if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
null signingConfigs.getByName("debug")
} else { } else {
signingConfigs.getByName("release") signingConfigs.getByName("release")
} }
@@ -165,6 +150,7 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
-27
View File
@@ -8,10 +8,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <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 <!-- 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+) platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
is what lets that install happen with NO confirm dialog. The platform is what lets that install happen with NO confirm dialog. The platform
@@ -61,29 +57,6 @@
</intent-filter> </intent-filter>
</service> </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 <!-- The FileProvider that used to live here existed solely to expose the
downloaded update APK as a content:// URI for the old ACTION_VIEW downloaded update APK as a content:// URI for the old ACTION_VIEW
install intent. A PackageInstaller session takes a stream instead, install intent. A PackageInstaller session takes a stream instead,
@@ -1,14 +1,10 @@
package com.fabledsword.minstrel package com.fabledsword.minstrel
import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -20,12 +16,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
import com.fabledsword.minstrel.cache.CachedTrackIds import com.fabledsword.minstrel.cache.CachedTrackIds
import com.fabledsword.minstrel.connectivity.LocalServerHealth 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.DetailSeedCache
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.nav.MinstrelNavGraph import com.fabledsword.minstrel.nav.MinstrelNavGraph
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.NowPlaying 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.shared.widgets.LocalCachedTrackIds
import com.fabledsword.minstrel.theme.MinstrelTheme import com.fabledsword.minstrel.theme.MinstrelTheme
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
@@ -45,10 +35,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -56,72 +42,41 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var seedCache: DetailSeedCache @Inject lateinit var seedCache: DetailSeedCache
@Inject lateinit var cachedTrackIds: CachedTrackIds @Inject lateinit var cachedTrackIds: CachedTrackIds
@Inject lateinit var serverHealth: NetworkStatusController @Inject lateinit var serverHealth: NetworkStatusController
@Inject lateinit var authStore: AuthStore
// Set when the user taps a notification: the media one asks for the full // Flipped to true when the user taps the media notification (or
// player, a Minstrel notice for what it is about. The App composable // any other entry point that asks for the full player). The App
// navigates there once the NavHost is ready, then calls back to clear it // composable observes this, navigates to NowPlaying once the
// so the navigation doesn't re-fire on the next recomposition. // NavHost is ready, then calls back to reset the flag so the
private val pendingRoute = MutableStateFlow<Any?>(null) // navigation doesn't re-fire on the next recomposition.
private val pendingOpenNowPlaying = MutableStateFlow(false)
// 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()) { }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
consumeRouteIntent(intent) consumeOpenNowPlayingIntent(intent)
askToNotifyOnceWanted()
setContent { setContent {
App( App(
seedCache = seedCache, seedCache = seedCache,
cachedTrackIds = cachedTrackIds, cachedTrackIds = cachedTrackIds,
serverHealth = serverHealth, serverHealth = serverHealth,
pendingRoute = pendingRoute.asStateFlow(), pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
onOpenedRoute = { pendingRoute.value = null }, onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
) )
} }
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
consumeRouteIntent(intent) consumeOpenNowPlayingIntent(intent)
} }
private fun consumeRouteIntent(intent: Intent?) { private fun consumeOpenNowPlayingIntent(intent: Intent?) {
if (intent == null) return if (intent?.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false) == true) {
if (intent.getBooleanExtra(EXTRA_OPEN_NOW_PLAYING, false)) { pendingOpenNowPlaying.value = true
pendingRoute.value = NowPlaying
// Strip the extra so a subsequent config-change recreation // Strip the extra so a subsequent config-change recreation
// doesn't re-trigger the navigation. // doesn't re-trigger the navigation.
intent.removeExtra(EXTRA_OPEN_NOW_PLAYING) 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 { companion object {
@@ -129,10 +84,6 @@ class MainActivity : ComponentActivity() {
* so a media-notification tap lands on the full NowPlaying screen * so a media-notification tap lands on the full NowPlaying screen
* instead of whatever shell route MainActivity last rendered. */ * instead of whatever shell route MainActivity last rendered. */
const val EXTRA_OPEN_NOW_PLAYING = "com.fabledsword.minstrel.action.OPEN_NOW_PLAYING" 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, seedCache: DetailSeedCache,
cachedTrackIds: CachedTrackIds, cachedTrackIds: CachedTrackIds,
serverHealth: NetworkStatusController, serverHealth: NetworkStatusController,
pendingRoute: StateFlow<Any?>, pendingOpenNowPlaying: StateFlow<Boolean>,
onOpenedRoute: () -> Unit, onOpenedNowPlaying: () -> Unit,
themeVm: ThemePreferenceViewModel = hiltViewModel(), themeVm: ThemePreferenceViewModel = hiltViewModel(),
gate: AuthGateViewModel = hiltViewModel(), gate: AuthGateViewModel = hiltViewModel(),
) { ) {
val theme by themeVm.themeMode.collectAsStateWithLifecycle() val theme by themeVm.themeMode.collectAsStateWithLifecycle()
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle() val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle() val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
val pending by pendingRoute.collectAsStateWithLifecycle() val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
MinstrelTheme(darkOverride = theme.toDarkOverride()) { MinstrelTheme(darkOverride = theme.toDarkOverride()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalDetailSeedCache provides seedCache, LocalDetailSeedCache provides seedCache,
@@ -168,14 +119,16 @@ private fun App(
// Queue / unauthenticated) bypass the shell entirely. // Queue / unauthenticated) bypass the shell entirely.
val navController = rememberNavController() val navController = rememberNavController()
// Honour a pending notification-tap once the NavHost is // Honour a pending notification-tap once the NavHost is
// mounted. launchSingleTop avoids stacking copies of a // mounted. launchSingleTop avoids stacking copies of
// screen if the user taps the notification while already // NowPlaying if the user taps the notification while
// on it; the callback clears it so a later recomposition // already on it; the callback clears the flag so a later
// (config change, theme switch) doesn't re-navigate. // recomposition (config change, theme switch) doesn't
// re-navigate.
LaunchedEffect(pending, navController) { LaunchedEffect(pending, navController) {
val route = pending ?: return@LaunchedEffect if (pending) {
navController.navigate(route) { launchSingleTop = true } navController.navigate(NowPlaying) { launchSingleTop = true }
onOpenedRoute() onOpenedNowPlaying()
}
} }
MinstrelNavGraph( MinstrelNavGraph(
navController = navController, navController = navController,
@@ -16,7 +16,6 @@ import com.fabledsword.minstrel.diagnostics.DiagnosticsUploader
import com.fabledsword.minstrel.events.EventsStream import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.events.LiveEventsDispatcher import com.fabledsword.minstrel.events.LiveEventsDispatcher
import com.fabledsword.minstrel.metadata.FreshnessSweeper import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.PlayEventsReporter import com.fabledsword.minstrel.player.PlayEventsReporter
@@ -76,14 +75,6 @@ class MinstrelApplication :
*/ */
@Suppress("unused") @Inject lateinit var mutationReplayer: MutationReplayer @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 * Same construct-the-singleton trick — PlayEventsReporter's init
* block subscribes to PlayerController.uiState and reports the * 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.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -46,12 +42,6 @@ fun AdminQuarantineScreen(
viewModel: AdminQuarantineViewModel = hiltViewModel(), viewModel: AdminQuarantineViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.transientMessages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
Scaffold( Scaffold(
contentWindowInsets = ShellContentWindowInsets, contentWindowInsets = ShellContentWindowInsets,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -63,7 +53,6 @@ fun AdminQuarantineScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
) { inner -> ) { inner ->
PullToRefreshScaffold( PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() }, onRefresh = { viewModel.refresh().join() },
@@ -10,13 +10,10 @@ import com.fabledsword.minstrel.events.EventsStream
import com.fabledsword.minstrel.models.AdminQuarantineItemRef import com.fabledsword.minstrel.models.AdminQuarantineItemRef
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -37,15 +34,6 @@ class AdminQuarantineViewModel @Inject constructor(
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading) private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
val uiState: StateFlow<AdminQuarantineUiState> = internal.asStateFlow() 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 { init {
refresh() refresh()
viewModelScope.launch { viewModelScope.launch {
@@ -98,9 +86,8 @@ class AdminQuarantineViewModel @Inject constructor(
try { try {
action(trackId) action(trackId)
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) { ) {
transientMessagesChannel.trySend(ErrorCopy.fromThrowable(e))
refresh() refresh()
} }
} }
@@ -50,14 +50,7 @@ class BaseUrlInterceptor @Inject constructor(
.port(baseUrl.port) .port(baseUrl.port)
.build() .build()
} ?: original.url } ?: original.url
return chain.proceed( return chain.proceed(original.newBuilder().url(rewritten).build())
original.newBuilder()
.url(rewritten)
// Lets CleartextGuardInterceptor tell server requests from
// external fetches once the placeholder host is gone.
.tag(MinstrelServerRequest::class.java, MinstrelServerRequest)
.build(),
)
} }
companion object { 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. * as connection failures.
*/ */
fun fromThrowable(t: Throwable): String = when (t) { fun fromThrowable(t: Throwable): String = when (t) {
is HttpException -> fromHttp(t) is HttpException -> messageFor(codeFromHttp(t))
is CleartextToPublicHostException -> messageFor("cleartext_public")
is IOException -> messageFor("connection_refused") is IOException -> messageFor("connection_refused")
else -> TABLE.getValue("unknown") else -> TABLE.getValue("unknown")
} }
/** private fun codeFromHttp(e: HttpException): String {
* 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 {
val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull() val raw = runCatching { e.response()?.errorBody()?.string() }.getOrNull()
?: return Body() ?: return "unknown"
return runCatching { json.decodeFromString<Envelope>(raw).error } val code = runCatching { json.decodeFromString<Envelope>(raw).error?.code }
.getOrNull() ?: Body() .getOrNull()
.orEmpty()
return code.ifEmpty { "unknown" }
} }
private val TABLE: Map<String, String> = mapOf( private val TABLE: Map<String, String> = mapOf(
@@ -76,7 +58,6 @@ object ErrorCopy {
"forbidden" to "You don't have permission to do that.", "forbidden" to "You don't have permission to do that.",
"not_authorized" 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.", "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.", "wrong_password" to "Current password is incorrect.",
"password_too_short" to "Password must be at least 8 characters.", "password_too_short" to "Password must be at least 8 characters.",
"username_invalid" to "That username isn't valid.", "username_invalid" to "That username isn't valid.",
@@ -102,9 +83,6 @@ object ErrorCopy {
"mbid_required" to "An MBID is required for this lookup.", "mbid_required" to "An MBID is required for this lookup.",
"system_playlist_readonly" to "System playlists can't be edited directly.", "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.", "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_unreachable" to
"Lidarr is unreachable right now. Try again, or check Admin → Integrations.", "Lidarr is unreachable right now. Try again, or check Admin → Integrations.",
"lidarr_disabled" to "Lidarr integration is not enabled.", "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_pending" to "This request is no longer pending.",
"request_not_found" to "That request no longer exists.", "request_not_found" to "That request no longer exists.",
"track_not_found" to "That track 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.", "album_not_found" to "That album no longer exists.",
"artist_not_found" to "That artist no longer exists.", "artist_not_found" to "That artist no longer exists.",
"playlist_not_found" to "That playlist no longer exists.", "playlist_not_found" to "That playlist no longer exists.",
@@ -70,9 +70,6 @@ object NetworkModule {
.addInterceptor(auth) .addInterceptor(auth)
.addInterceptor(baseUrl) .addInterceptor(baseUrl)
.addInterceptor(logging) .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) .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build() .build()
@@ -29,20 +29,11 @@ interface CastApi {
* Request body. [expSeconds] is clamped server-side to [60, 86400]; * Request body. [expSeconds] is clamped server-side to [60, 86400];
* the 21_600 default (6h) is long enough to play through any typical * the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback. * 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 @Serializable
data class StreamTokenRequest( data class StreamTokenRequest(
val trackId: String, val trackId: String,
val expSeconds: Int = 21_600, 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 url: String,
val mime: String = "audio/mpeg", val mime: String = "audio/mpeg",
val title: String = "", 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.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import retrofit2.http.Body import retrofit2.http.Body
@@ -60,14 +59,6 @@ interface MeApi {
*/ */
@PUT("api/me/listenbrainz") @PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire 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.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -34,14 +27,6 @@ import javax.inject.Singleton
* in-memory state changes synchronously so the next interceptor read * in-memory state changes synchronously so the next interceptor read
* sees the new value immediately; the DAO write coroutine catches up * sees the new value immediately; the DAO write coroutine catches up
* shortly after. * 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 // AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one // app_preferences — see entity comment). It legitimately owns one
@@ -54,7 +39,6 @@ import javax.inject.Singleton
@Singleton @Singleton
class AuthStore @Inject constructor( class AuthStore @Inject constructor(
private val dao: AuthSessionDao, private val dao: AuthSessionDao,
private val vault: SessionVault,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) { ) {
private val sessionCookieState = MutableStateFlow<String?>(null) private val sessionCookieState = MutableStateFlow<String?>(null)
@@ -78,39 +62,18 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false) private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow() 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 } 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 { init {
scope.launch { scope.launch {
dao.observe().collect { row -> dao.observe().collect { row ->
sessionCookieState.value = row?.sessionCookie
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
userJsonState.value = row?.userJson userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson) cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false 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) }.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?) { fun setSessionCookie(value: String?) {
cookieTouched = true
sessionCookieState.value = value sessionCookieState.value = value
scope.launch { cookieLock.withLock { storeCookie(value) } } scope.launch { persistCookie(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)
}
} }
fun setBaseUrl(value: String) { fun setBaseUrl(value: String) {
@@ -206,24 +121,7 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) } scope.launch { persistDiagnosticsOptOut(value) }
} }
fun setNormalization(value: NormalizationPrefs) { private suspend fun persistCookie(value: String?) {
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?) {
if (dao.get() == null) { if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value)) dao.upsert(currentEntity().copy(sessionCookie = value))
} else { } 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( private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID, id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and sessionCookie = sessionCookieState.value,
// persistLegacyCookie sets it explicitly on the fallback path.
sessionCookie = null,
baseUrl = baseUrlState.value, baseUrl = baseUrlState.value,
userJson = userJsonState.value, userJson = userJsonState.value,
themeMode = themeModeState.value, themeMode = themeModeState.value,
@@ -301,19 +189,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value, cacheSettingsState.value,
), ),
diagnosticsOptOut = diagnosticsOptOutState.value, diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
backgroundDelivery = backgroundDeliveryState.value,
) )
companion object { companion object {
const val DEFAULT_BASE_URL: String = "http://localhost:8080" 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 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 * Computes the initial startDestination for the root NavHost based on
* persisted auth state. Reads the row from AuthSessionDao directly, and * persisted auth state. Sits on top of AuthSessionDao directly rather
* the session cookie only after [AuthStore.awaitSessionHydrated]: both * than AuthStore's StateFlow because the StateFlow defaults to null
* StateFlows default to null until their async load lands, and we need a * until Room's first async emission — we need a definitive answer
* definitive answer before drawing any nav graph. The cookie is no longer * before drawing any nav graph.
* in the row (it lives in the Keystore-backed SessionVault, #4985).
* *
* - no row at all → ServerUrl (first launch) * - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in) * - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -32,7 +31,6 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthGateViewModel @Inject constructor( class AuthGateViewModel @Inject constructor(
private val dao: AuthSessionDao, private val dao: AuthSessionDao,
private val authStore: AuthStore,
) : ViewModel() { ) : ViewModel() {
private val internal = MutableStateFlow<Any?>(null) private val internal = MutableStateFlow<Any?>(null)
@@ -40,13 +38,12 @@ class AuthGateViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
authStore.awaitSessionHydrated()
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
val row = dao.get() val row = dao.get()
internal.value = when { internal.value = when {
row == null -> ServerUrl row == null -> ServerUrl
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
!signedIn -> Login ServerUrl
row.sessionCookie.isNullOrEmpty() -> Login
else -> Home else -> Home
} }
} }
@@ -3,8 +3,6 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao 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.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao 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.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao 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.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity 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.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
@@ -69,30 +64,14 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHistorySnapshotEntity::class, CachedHistorySnapshotEntity::class,
AuthSessionEntity::class, AuthSessionEntity::class,
DiagnosticEventEntity::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), // v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream. // so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column // v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch — // on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the // which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand. // new column populated, so there is nothing to migrate by hand.
version = 12, version = 8,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(MinstrelTypeConverters::class) @TypeConverters(MinstrelTypeConverters::class)
@@ -112,58 +91,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao 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.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao 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.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
@@ -38,7 +37,6 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue // launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit // (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release. // Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -113,10 +111,5 @@ object DatabaseModule {
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao = fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
db.diagnosticEventDao() db.diagnosticEventDao()
@Provides
@Singleton
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
db.cachedNotificationDao()
private const val DATABASE_NAME = "minstrel.db" private const val DATABASE_NAME = "minstrel.db"
} }
@@ -6,7 +6,6 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.Instant
@Dao @Dao
interface AuthSessionDao { interface AuthSessionDao {
@@ -47,16 +46,4 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */ /** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0") @Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean) 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") @Query("DELETE FROM cached_mutations")
suspend fun clear() 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedTrackEntity>) 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)") @Query("DELETE FROM cached_tracks WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) 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 package com.fabledsword.minstrel.cache.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/** /**
* Single-row table holding the user's session cookie, configured * 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. * choice lives here. Default false = honor the account flag.
*/ */
val diagnosticsOptOut: Boolean = false, 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 releaseDate: String? = null,
val coverPath: String? = null, val coverPath: String? = null,
val mbid: 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(), 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 fileFormat: String? = null,
val genre: String? = null, val genre: String? = null,
val missing: Boolean = false, 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(), val fetchedAt: Instant = Clock.System.now(),
) )
@@ -1,9 +1,7 @@
package com.fabledsword.minstrel.cache.mutations 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.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -43,22 +41,6 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two // an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome. // opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle" 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 * This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget. * never go fire-and-forget.
*/ */
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton @Singleton
class MutationQueue @Inject constructor( class MutationQueue @Inject constructor(
private val dao: CachedMutationDao, 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( suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL, MutationKind.REQUEST_CANCEL,
json.encodeToString( json.encodeToString(
@@ -366,36 +322,3 @@ data class PlaybackErrorReportPayload(
val detail: String? = null, val detail: String? = null,
val clientId: String, 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.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi 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.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi 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.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -84,8 +79,6 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create() private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create() private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create() private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val notificationsApi: NotificationsApi = retrofit.create()
private val mutex = Mutex() private val mutex = Mutex()
@@ -173,23 +166,10 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload) MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload) MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(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. // Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP 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 { private suspend fun dispatchLikeToggle(payload: String): Outcome {
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload) val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
val kindPath = when (decoded.entityType) { val kindPath = when (decoded.entityType) {
@@ -299,37 +279,6 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT 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 { private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload) val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report( playbackErrorsApi.report(
@@ -354,8 +303,7 @@ class MutationReplayer @Inject constructor(
/** /**
* Row ids of desired-state toggles superseded by a later toggle for the same * 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 * entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles, suggestion snoozes (#2374) and the * than an action — like-toggles and suggestion snoozes (#2374) — because
* normalization preference (#4998) — because
* replaying a stale one last would invert the final state. * 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 * 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) json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" } }.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 else -> null
} }
@@ -206,8 +206,6 @@ private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
releaseDate = releaseDate, releaseDate = releaseDate,
coverPath = coverArtPath, coverPath = coverArtPath,
mbid = mbid, mbid = mbid,
albumGain = albumGain,
albumPeak = albumPeak,
) )
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity( private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
@@ -222,6 +220,4 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
fileFormat = fileFormat, fileFormat = fileFormat,
genre = genre, genre = genre,
missing = missing, missing = missing,
trackGain = trackGain,
trackPeak = trackPeak,
) )
@@ -18,7 +18,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.TransportObservation
import com.fabledsword.minstrel.player.output.OutputPickerController import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@@ -104,7 +103,6 @@ class DiagnosticsReporter @Inject constructor(
launch { collectUpnpDrops() } launch { collectUpnpDrops() }
launch { collectPlayerState() } launch { collectPlayerState() }
launch { collectTrackChanges() } launch { collectTrackChanges() }
launch { collectTransportFlap() }
launch { collectRoutes() } launch { collectRoutes() }
launch { heartbeatLoop() } launch { heartbeatLoop() }
} }
@@ -193,67 +191,6 @@ class DiagnosticsReporter @Inject constructor(
} }
} }
/**
* Catch the renderer rapidly leaving and re-entering PLAYING.
*
* The operator reports the Sonos "play pause play pause, like someone
* pressing it every half second", usually as a track starts, cleared by a
* manual pause or skip. Nothing here could see that: `player_state`
* carries source/loading/error but not playing, `track_change` needs the
* index to move, and the heartbeat samples once per 45s. The symptom fell
* through every existing collector, which is why it has only ever been
* described and never measured.
*
* Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event
* carrying the whole sequence. The summary is the useful artefact: it
* pairs the renderer's states with local-vs-Sonos track and position, so
* an episode says whether the app and the renderer disagreed about which
* track was playing, or agreed while the renderer rebuffered.
*
* See [TransportObservation] on the 1 Hz sampling limit.
*/
private suspend fun collectTransportFlap() {
val detector = TransportFlapDetector()
playerController.transportEvents.collect { obs ->
record("upnp_sync", buildJsonObject {
put("event", "transport")
put("state", obs.state)
put("status_ok", obs.statusOk)
put("sonos_track", obs.trackNumber)
put("sonos_pos_ms", obs.positionMs)
put("play_intent", obs.playIntent)
})
detector.onChange(obs)?.let { recordFlapSummary(it) }
}
}
private suspend fun recordFlapSummary(recent: List<TransportObservation>) {
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
val spanMs = recent.last().atElapsedMs - recent.first().atElapsedMs
record("upnp_sync", buildJsonObject {
put("event", "transport_flap")
put("changes", recent.size)
put("window_ms", spanMs)
// The sequence itself, e.g. "PLAYING>TRANSITIONING>STOPPED>PLAYING".
// Whether STOPPED appears at all is the first question to ask of a
// captured episode.
put("sequence", recent.joinToString(">") { it.state })
put("sonos_positions_ms", recent.joinToString(",") { it.positionMs.toString() })
put("sonos_tracks", recent.joinToString(",") { it.trackNumber.toString() })
put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
addPowerFields(this)
})
}
private suspend fun collectRoutes() { private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever // 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag. // logs the ACTIVE route (routesState.current), so no "connected" flag.
@@ -1,75 +0,0 @@
package com.fabledsword.minstrel.diagnostics
import com.fabledsword.minstrel.player.TransportObservation
/**
* Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition.
*
* The operator reports the Sonos "play pause play pause, like someone pressing
* it every half second", usually as a track starts. No diagnostic event could
* see it, so it has been described several times and measured never. This is
* the rule that decides when an episode is worth writing down.
*
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an
* episode, and which readings make it up". Keeps the windowing and the
* one-episode-one-summary rule testable without a renderer or a clock.
*/
class TransportFlapDetector(
private val windowMs: Long = FLAP_WINDOW_MS,
private val minChanges: Int = FLAP_MIN_CHANGES,
private val summaryCooldownMs: Long = FLAP_SUMMARY_COOLDOWN_MS,
) {
private val recent = ArrayDeque<TransportObservation>()
private var lastSummaryAtMs: Long? = null
/**
* Feed one transport change. Returns the readings making up an episode
* worth recording, or null when there is nothing to say.
*
* The returned list is a copy: the caller may hold it while more readings
* arrive.
*/
fun onChange(observation: TransportObservation): List<TransportObservation>? {
recent.addLast(observation)
dropReadingsOlderThan(observation.atElapsedMs)
if (!isEpisode(observation.atElapsedMs)) return null
lastSummaryAtMs = observation.atElapsedMs
return recent.toList()
}
private fun dropReadingsOlderThan(nowMs: Long) {
while (recent.isNotEmpty() && nowMs - recent.first().atElapsedMs > windowMs) {
recent.removeFirst()
}
}
/**
* Enough changes packed together, and far enough from the last thing we
* wrote down. The cooldown is what keeps one episode to one summary: a
* sustained fault produces a change every poll, and a summary per reading
* would bury the per-change events underneath them.
*/
private fun isEpisode(nowMs: Long): Boolean {
val since = lastSummaryAtMs
val cooled = since == null || nowMs - since >= summaryCooldownMs
return recent.size >= minChanges && cooled
}
/** Forget everything — call when the route changes or casting ends. */
fun reset() {
recent.clear()
lastSummaryAtMs = null
}
companion object {
// Readings arrive at the 1 Hz poll cadence, and a normal track
// transition is 2-3 changes (PLAYING -> TRANSITIONING -> PLAYING).
// Four inside six seconds is not a track change, and it is not a
// person at the Sonos app either; it is the renderer not settling.
const val FLAP_WINDOW_MS = 6_000L
const val FLAP_MIN_CHANGES = 4
const val FLAP_SUMMARY_COOLDOWN_MS = 60_000L
}
}
@@ -1,18 +1,14 @@
package com.fabledsword.minstrel.events package com.fabledsword.minstrel.events
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -32,6 +28,9 @@ import javax.inject.Singleton
private const val SSE_PATH = "/api/events/stream" private const val SSE_PATH = "/api/events/stream"
private const val EVENTS_BUFFER_CAPACITY = 64 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 * 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 * - No client-side timeout — the server emits 15s heartbeats which
* okhttp-sse handles transparently. * okhttp-sse handles transparently.
* - Reconnect-with-backoff: if the stream drops mid-session (server * - Reconnect-with-backoff: if the stream drops mid-session (server
* restart, network blip) it reconnects after [ReconnectBackoff]'s * restart, network blip) it reconnects with exponential backoff
* jittered wait (2s doubling to 5 min), reset on a successful open. * (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
* 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
* reconnects while still signed in; a sign-out cancels the pending * reconnects while still signed in; a sign-out cancels the pending
* retry. Without this a single blip silently kills cross-device * retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch. * 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 * The URL passes through the placeholder host that
* `BaseUrlInterceptor` rewrites — same mechanism the rest of the * `BaseUrlInterceptor` rewrites — same mechanism the rest of the
@@ -68,7 +62,6 @@ class EventsStream @Inject constructor(
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val json: Json, private val json: Json,
private val connectivity: ConnectivityObserver,
) { ) {
private val factory = EventSources.createFactory(okHttpClient) private val factory = EventSources.createFactory(okHttpClient)
@@ -79,13 +72,10 @@ class EventsStream @Inject constructor(
) )
val events: SharedFlow<LiveEvent> = emitter.asSharedFlow() val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()
private val connectedState = MutableStateFlow(false)
val connected: StateFlow<Boolean> = connectedState.asStateFlow()
private var currentSource: EventSource? = null private var currentSource: EventSource? = null
@Volatile private var signedIn = false @Volatile private var signedIn = false
private var reconnectJob: Job? = null private var reconnectJob: Job? = null
private var backoffMs = ReconnectBackoff.BASE_MS private var backoffMs = BASE_BACKOFF_MS
init { init {
scope.launch { scope.launch {
@@ -95,30 +85,13 @@ class EventsStream @Inject constructor(
.collect { isSignedIn -> .collect { isSignedIn ->
signedIn = isSignedIn signedIn = isSignedIn
if (isSignedIn) { if (isSignedIn) {
backoffMs = ReconnectBackoff.BASE_MS backoffMs = BASE_BACKOFF_MS
connect() connect()
} else { } else {
disconnect() 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 @Synchronized
@@ -133,22 +106,20 @@ class EventsStream @Inject constructor(
reconnectJob?.cancel() reconnectJob?.cancel()
currentSource?.cancel() currentSource?.cancel()
currentSource = null currentSource = null
connectedState.value = false
} }
/** /**
* Schedule a reconnect after the current backoff, jittered, then * Schedule a reconnect after the current backoff, then double it
* double it (capped). No-op when signed out — sign-out's [disconnect] * (capped). No-op when signed out — sign-out's [disconnect]
* cancels the pending job. A successful [Listener.onOpen] resets * cancels the pending job. A successful [Listener.onOpen] resets
* the backoff to the floor. * the backoff to the floor.
*/ */
@Synchronized @Synchronized
private fun scheduleReconnect() { private fun scheduleReconnect() {
connectedState.value = false
if (!signedIn) return if (!signedIn) return
reconnectJob?.cancel() reconnectJob?.cancel()
val waitMs = ReconnectBackoff.jittered(backoffMs) val waitMs = backoffMs
backoffMs = ReconnectBackoff.next(backoffMs) backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
reconnectJob = scope.launch { reconnectJob = scope.launch {
delay(waitMs) delay(waitMs)
if (signedIn) connect() if (signedIn) connect()
@@ -165,8 +136,7 @@ class EventsStream @Inject constructor(
private inner class Listener : EventSourceListener() { private inner class Listener : EventSourceListener() {
override fun onOpen(eventSource: EventSource, response: Response) { override fun onOpen(eventSource: EventSource, response: Response) {
backoffMs = ReconnectBackoff.BASE_MS backoffMs = BASE_BACKOFF_MS
connectedState.value = true
} }
override fun onEvent( override fun onEvent(
@@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -31,7 +30,6 @@ import javax.inject.Singleton
class LiveEventsDispatcher @Inject constructor( class LiveEventsDispatcher @Inject constructor(
private val eventsStream: EventsStream, private val eventsStream: EventsStream,
private val likes: LikesRepository, private val likes: LikesRepository,
private val notifications: NotificationsRepository,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
@@ -51,8 +49,6 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked", "artist.liked",
"artist.unliked", "artist.unliked",
-> refreshLikes() -> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications()
} }
// Other kinds (playlist.*, quarantine.*, request.status_changed, // Other kinds (playlist.*, quarantine.*, request.status_changed,
// scan.*) reach screen-scoped subscribers via EventsStream // scan.*) reach screen-scoped subscribers via EventsStream
@@ -62,18 +58,9 @@ class LiveEventsDispatcher @Inject constructor(
override fun onStart(owner: LifecycleOwner) { override fun onStart(owner: LifecycleOwner) {
// App returned to the foreground. SSE will catch up but might // App returned to the foreground. SSE will catch up but might
// not have reconnected yet (it may be deep in its backoff, so it // not have reconnected yet; flush the cross-screen refreshes
// is told to try now); flush the cross-screen refreshes
// defensively. // defensively.
eventsStream.reconnectNow()
refreshLikes() refreshLikes()
refreshNotifications()
}
private fun refreshNotifications() {
scope.launch {
runCatching { notifications.refresh() }
}
} }
private fun refreshLikes() { 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 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 the server-bundled APK version (may have a leading
* * "v" from the git tag); `apkUrl` is server-relative (e.g.
* - [version] is a LABEL for people — "YYYY.MM.DD.HHMM", derived from the * `/api/client/apk`); `sizeBytes` is the download size.
* 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`).
*/ */
data class UpdateInfo( data class UpdateInfo(
val version: String, val version: String,
val code: Long?,
val channel: String?,
val apkUrl: String, val apkUrl: String,
val sizeBytes: Long, 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("release_date") val releaseDate: String? = null,
@SerialName("cover_art_path") val coverArtPath: String? = null, @SerialName("cover_art_path") val coverArtPath: String? = null,
val mbid: 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 @Serializable
@@ -55,9 +51,6 @@ data class SyncTrackWire(
// its tracks stay playable, which is the correct reading of "this server // its tracks stay playable, which is the correct reading of "this server
// has nothing to say about missing files". // has nothing to say about missing files".
val missing: Boolean = false, 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 import kotlinx.serialization.Serializable
/** /**
* Wire shape for `GET /api/client/version`. * Wire shape for `GET /api/client/version`. Defaults match Flutter:
* * apk_url falls back to `/api/client/apk` if the server omits it.
* `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.
*/ */
@Serializable @Serializable
data class UpdateInfoWire( data class UpdateInfoWire(
val version: String = "", val version: String = "",
val code: Long? = null,
val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk", @SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0, @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.AlbumDetailScreen
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen 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.NowPlayingScreen
import com.fabledsword.minstrel.player.ui.QueueScreen import com.fabledsword.minstrel.player.ui.QueueScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
@@ -61,7 +59,6 @@ fun MinstrelNavGraph(
) { ) {
inShellTopLevel(navController, expandPlayer) inShellTopLevel(navController, expandPlayer)
inShellDetail(navController, expandPlayer) inShellDetail(navController, expandPlayer)
inShellNotifications(navController, expandPlayer)
outsideShell(navController) 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) { private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
composable<NowPlaying>( composable<NowPlaying>(
// Slide up from the bottom on enter; back down on dismiss. // 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 Settings
@Serializable data object Admin @Serializable data object Admin
@Serializable data object Requests @Serializable data object Requests
@Serializable data object Notifications
@Serializable data object NotificationSettings
// ── In-shell detail / push-on-top destinations ──────────────────────── // ── 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() }
}
}
@@ -14,7 +14,6 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo import com.fabledsword.minstrel.player.output.upnp.TransportInfo
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
@@ -49,12 +48,12 @@ import timber.log.Timber
* *
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route * Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold * liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
* (DROP_THRESHOLD consecutive failures) fires [RemoteEvents.onDrop]. A failed transport * (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's * command (play/pause/seek/next) does NOT drop on its own: a locked phone's
* WiFi power-save can stall a single command's socket I/O for a second or * WiFi power-save can stall a single command's socket I/O for a second or
* two while the renderer is perfectly reachable, so commands retry on * two while the renderer is perfectly reachable, so commands retry on
* transient IO failure and otherwise defer to the poll loop. The factory * transient IO failure and otherwise defer to the poll loop. The factory
* wraps that callback into a SharedFlow consumed by the NowPlaying * wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar. * surface as a snackbar.
* *
* Queue mode: OutputPickerController loads the full queue into Sonos's * Queue mode: OutputPickerController loads the full queue into Sonos's
@@ -72,31 +71,10 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock, private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController, private val networkStatus: NetworkStatusController,
private val events: RemoteEvents = RemoteEvents(), private val onDrop: (routeName: String) -> Unit,
private val onStalled: (trackId: String) -> Unit = {},
) : ForwardingPlayer(delegate) { ) : ForwardingPlayer(delegate) {
/**
* The ways remote playback reports trouble outward. Grouped rather than
* passed as three more constructor lambdas: they share a lifetime, they
* all end up as flows on [PlayerFactory], and the list grows every time
* the renderer finds a new way to disappoint us.
*/
data class RemoteEvents(
/** A route stopped answering and playback fell back to the phone. */
val onDrop: (routeName: String) -> Unit = {},
/** A track could not be got playing again; surfaces to the user. */
val onStalled: (trackId: String) -> Unit = {},
/** The renderer's queue is short of ours and needs rebuilding. */
val onQueueTruncated: () -> Unit = {},
/**
* A raw poll reading, emitted only when it differs from the previous
* 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) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val handler = Handler(delegate.applicationLooper) private val handler = Handler(delegate.applicationLooper)
private var pollJob: Job? = null private var pollJob: Job? = null
@@ -119,16 +97,6 @@ class MinstrelForwardingPlayer(
// visibly jumps backwards immediately after a drag, then forwards again. // visibly jumps backwards immediately after a drag, then forwards again.
@Volatile private var lastSeekIssuedAtMs: Long = 0L @Volatile private var lastSeekIssuedAtMs: Long = 0L
// Last-read renderer queue length + when we read it. See [queueStateFor]:
// a stopped renderer is polled once a second and its queue does not change
// by itself, so re-asking every tick is pure round-trips.
@Volatile private var cachedNrTracks: Int = 0
@Volatile private var lastMediaInfoAtMs: Long = 0L
// Previous raw transport reading, so [TransportObservation]s are emitted
// on change rather than once a second forever. Null until the first poll.
@Volatile private var lastObservedTransport: Pair<TransportState, Boolean>? = null
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit; // Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
// pollLoop's select{} races the delay against this channel so the next // pollLoop's select{} races the delay against this channel so the next
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS. // pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
@@ -520,35 +488,17 @@ class MinstrelForwardingPlayer(
// without this the radio power-saves on a locked screen and the // without this the radio power-saves on a locked screen and the
// poll below starves -- see [CastNetworkLock]. // poll below starves -- see [CastNetworkLock].
castNetworkLock.acquire() castNetworkLock.acquire()
// STOP the wrapped ExoPlayer -- not pause. pause() is only // Pause the wrapped ExoPlayer so we are not playing local audio
// playWhenReady=false: ExoPlayer's LoadControl keeps loading, so a // simultaneously with the remote renderer. handler.post targets the
// paused-but-prepared player goes on downloading the current track // application looper, so this runs on the same thread that processes
// (~50s of buffer). During a cast that means the phone pulls the // our override calls -- no race with the pause() override branching
// same file the renderer is streaming, over the same WiFi, and // to SOAP (holder.active is already non-null by the time this post
// re-arms on every track change via syncLocalCursorToRemote's // fires, but delegate.pause() bypasses the override entirely).
// seekTo. At FLAC bitrates that is a second full-rate download handler.post { delegate.pause() }
// competing with the speaker for air, starting exactly when a new
// track does. stop() ends the loading; Media3 keeps the media
// items, the current index and the position, so cursor sync and
// the handoff back are unaffected, and getPlaybackState() already
// reports STATE_READY while remote so no external reader sees IDLE.
//
// handler.post targets the application looper, so this runs on the
// same thread that processes our override calls -- no race with the
// pause() override branching to SOAP (holder.active is already
// non-null by the time this post fires, but delegate.stop()
// bypasses the override entirely).
handler.post { delegate.stop() }
pollJob = scope.launch { pollLoop(active) } pollJob = scope.launch { pollLoop(active) }
} else { } else {
castNetworkLock.release() castNetworkLock.release()
remoteState.reset() remoteState.reset()
lastObservedTransport = null
// The delegate was stopped for the cast, so it is IDLE and would
// ignore a play(). Re-prepare it for local playback. Safe when the
// queue is empty, and it does not start playback on its own --
// playWhenReady is still false until something calls play().
handler.post { delegate.prepare() }
// The next cast starts with a clean attempt budget; a stall on the // The next cast starts with a clean attempt budget; a stall on the
// route we just left says nothing about the next one. // route we just left says nothing about the next one.
stallWatchdog.reset() stallWatchdog.reset()
@@ -566,7 +516,7 @@ class MinstrelForwardingPlayer(
} else if (remoteState.recordPollFailure()) { } else if (remoteState.recordPollFailure()) {
if (networkStatus.state.value == ServerHealth.Healthy) { if (networkStatus.state.value == ServerHealth.Healthy) {
Timber.w("UPnP drop threshold tripped for %s", active.routeName) Timber.w("UPnP drop threshold tripped for %s", active.routeName)
handler.post { events.onDrop(active.routeName) } handler.post { onDrop(active.routeName) }
return return
} }
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed) networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
@@ -624,7 +574,6 @@ class MinstrelForwardingPlayer(
trackNumber = info.track, trackNumber = info.track,
) )
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri) syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
events.onRendererTrack(info.track)
val transport = active.avTransport.getTransportInfo() val transport = active.avTransport.getTransportInfo()
when (transport.state) { when (transport.state) {
TransportState.PLAYING -> { TransportState.PLAYING -> {
@@ -645,7 +594,6 @@ class MinstrelForwardingPlayer(
} }
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
} }
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport) checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged() notifyRemoteStateChanged()
} }
@@ -665,15 +613,12 @@ class MinstrelForwardingPlayer(
transport: TransportInfo, transport: TransportInfo,
) { ) {
val decision = stallWatchdog.onPoll( val decision = stallWatchdog.onPoll(
RemoteStallWatchdog.Poll( trackUri = trackUri,
trackUri = trackUri, state = transport.state,
state = transport.state, statusOk = transport.statusOk,
statusOk = transport.statusOk, playIntent = remoteState.lastPlayIntent,
playIntent = remoteState.lastPlayIntent, positionMs = remoteState.positionMs,
positionMs = remoteState.positionMs, nowMs = SystemClock.elapsedRealtime(),
nowMs = SystemClock.elapsedRealtime(),
queue = queueStateFor(active, transport),
),
) )
when (decision) { when (decision) {
is RemoteStallWatchdog.Decision.Resume -> { is RemoteStallWatchdog.Decision.Resume -> {
@@ -693,17 +638,6 @@ class MinstrelForwardingPlayer(
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName) Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
} }
} }
is RemoteStallWatchdog.Decision.RepairQueue -> {
Timber.w(
"UPnP queue truncated on %s: renderer ended at its last track " +
"while %d local tracks remain; repair attempt %d",
active.routeName, delegate.mediaItemCount, decision.attempt,
)
// The renderer isn't broken -- it played everything it was
// given. Rebuilding the queue is the fix; the controller owns
// queue loading, so ask it rather than duplicating that here.
handler.post { events.onQueueTruncated() }
}
RemoteStallWatchdog.Decision.GiveUp -> { RemoteStallWatchdog.Decision.GiveUp -> {
Timber.w( Timber.w(
"UPnP stall on %s: giving up after repeated resume attempts", "UPnP stall on %s: giving up after repeated resume attempts",
@@ -711,80 +645,12 @@ class MinstrelForwardingPlayer(
) )
// Tell the user and the admin inbox. Silence here would be the // Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out. // original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } } trackIdFromStreamUri(trackUri)?.let { handler.post { onStalled(it) } }
} }
RemoteStallWatchdog.Decision.None -> Unit RemoteStallWatchdog.Decision.None -> Unit
} }
} }
/**
* Publish this poll's raw transport reading if it differs from the last.
*
* Change-gated on purpose: steady playback is one reading repeated once a
* second, which is worth nothing and would fill the ring buffer. What is
* worth capturing is the renderer LEAVING a state — which during normal
* playback happens a couple of times per track, and during the fault the
* operator describes should happen repeatedly within a few seconds.
*/
private fun observeTransport(transport: TransportInfo, info: PositionInfo) {
val key = transport.state to transport.statusOk
if (key == lastObservedTransport) return
lastObservedTransport = key
events.onTransport(
TransportObservation(
state = transport.state.name,
statusOk = transport.statusOk,
trackNumber = info.track,
positionMs = info.relTimeMs,
playIntent = remoteState.lastPlayIntent,
atElapsedMs = SystemClock.elapsedRealtime(),
),
)
}
/**
* How the renderer's queue compares to ours, for [RemoteStallWatchdog].
*
* Only asked when the transport is actually stopped or reporting an error:
* while it plays, the answer changes nothing and GetMediaInfo would be a
* third SOAP round-trip every second. Even then the result is cached for
* [MEDIA_INFO_TTL_MS], because a stopped renderer gets polled once a
* second and its queue length does not change on its own.
*
* A renderer that reports NrTracks=0 is telling us nothing usable (some
* don't implement it) -- that reads as UNKNOWN, never as "empty queue",
* so an unhelpful renderer keeps the old resume-and-seek behaviour rather
* than being told its queue is broken.
*/
@Suppress("ReturnCount") // one early return per verdict reads better than nesting
private suspend fun queueStateFor(
active: ActiveUpnp,
transport: TransportInfo,
): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
lastMediaInfoAtMs = now
cachedNrTracks = runCatching { active.avTransport.getMediaInfo().nrTracks }
.onFailure { Timber.w(it, "UPnP GetMediaInfo failed on %s", active.routeName) }
.getOrDefault(0)
}
val nrTracks = cachedNrTracks
val rendererTrack = remoteState.trackNumber
if (nrTracks <= 0 || rendererTrack <= 0) return RemoteStallWatchdog.QueueState.UNKNOWN
if (rendererTrack < nrTracks) return RemoteStallWatchdog.QueueState.HAS_MORE
// On its last track. Whether that is a problem depends entirely on
// whether we have tracks it never received.
return if (delegate.mediaItemCount > nrTracks) {
RemoteStallWatchdog.QueueState.TRUNCATED
} else {
RemoteStallWatchdog.QueueState.COMPLETE
}
}
/** /**
* Align the paused local delegate cursor to the track the renderer is * Align the paused local delegate cursor to the track the renderer is
* actually playing, so the un-overridden current-item getters * actually playing, so the un-overridden current-item getters
@@ -877,10 +743,6 @@ class MinstrelForwardingPlayer(
const val POLL_INTERVAL_MS = 1_000L const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2 const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L const val SEEK_ACK_WINDOW_MS = 2_000L
// How long a GetMediaInfo queue-length reading stays good for. The
// watchdog needs three agreeing polls (~3s) before it acts, so one
// read comfortably covers a decision without asking every tick.
const val MEDIA_INFO_TTL_MS = 5_000L
// Safety upper bound on how long the polling tick will wait for // Safety upper bound on how long the polling tick will wait for
// Sonos to ack a user transport. The common case clears event-driven // Sonos to ack a user transport. The common case clears event-driven
// when Sonos's reported Track matches the wrapped player; this only // when Sonos's reported Track matches the wrapped player; this only
@@ -12,10 +12,8 @@ import androidx.media3.session.SessionCommand
import com.fabledsword.minstrel.MainActivity import com.fabledsword.minstrel.MainActivity
import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.fabledsword.minstrel.settings.data.NormalizationRepository
import com.google.common.collect.ImmutableList import com.google.common.collect.ImmutableList
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -27,7 +25,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -69,8 +66,6 @@ class MinstrelPlayerService : MediaSessionService() {
@Inject lateinit var likesRepository: LikesRepository @Inject lateinit var likesRepository: LikesRepository
@Inject lateinit var normalizationRepository: NormalizationRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var mediaSession: MediaSession? = null private var mediaSession: MediaSession? = null
@@ -87,21 +82,6 @@ class MinstrelPlayerService : MediaSessionService() {
.build() .build()
mediaSession = session mediaSession = session
serviceScope.launch { observeLikeState(session, player) } 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 androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef 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.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.resolveServerUrl import com.fabledsword.minstrel.shared.resolveServerUrl
@@ -71,7 +69,6 @@ class PlayerController @Inject constructor(
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val replayGains: ReplayGainStore,
) { ) {
/** /**
@@ -80,13 +77,6 @@ class PlayerController @Inject constructor(
* during UPnP playback shows "Disconnected from <name>" to the user. * during UPnP playback shows "Disconnected from <name>" to the user.
*/ */
val dropEvents: SharedFlow<String> = playerFactory.dropEvents val dropEvents: SharedFlow<String> = playerFactory.dropEvents
/**
* Raw UPnP transport readings from [PlayerFactory.transportEvents], for
* the diagnostics reporter. Read-only tap — nothing in the playback path
* consumes it.
*/
val transportEvents: SharedFlow<TransportObservation> = playerFactory.transportEvents
private val sessionToken = private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -280,11 +270,6 @@ class PlayerController @Inject constructor(
queueRefs = playable.tracks queueRefs = playable.tracks
val items = playable.tracks.map { it.toMediaItem(source) } val items = playable.tracks.map { it.toMediaItem(source) }
val startIndex = playable.initialIndex 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 // Drift #562 cold-boot resume calls this from a non-Main suspend
// context after awaitReady() unblocks (ResumeController launches // context after awaitReady() unblocks (ResumeController launches
// on Dispatchers.Default by the time it reaches us). MediaController // on Dispatchers.Default by the time it reaches us). MediaController
@@ -800,13 +785,7 @@ class PlayerController @Inject constructor(
// scrubber a real total even when the wrapped ExoPlayer is // scrubber a real total even when the wrapped ExoPlayer is
// paused under UPnP (it never probes a duration in that state). // paused under UPnP (it never probes a duration in that state).
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND) if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
// Album and track position let the gain processor tell an if (source != null) setExtras(sourceExtras(source))
// 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)
// Point the notification / lock-screen art at the SAME album // Point the notification / lock-screen art at the SAME album
// cover the in-app surfaces use (TrackRef.coverUrl -> // cover the in-app surfaces use (TrackRef.coverUrl ->
// /api/albums/{id}/cover). Without this, Media3 falls back to // /api/albums/{id}/cover). Without this, Media3 falls back to
@@ -874,7 +853,6 @@ class PlayerController @Inject constructor(
const val MINSTREL_SOURCE_KEY: String = "minstrel_source" const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
private const val MS_PER_SECOND = 1_000L private const val MS_PER_SECOND = 1_000L
private const val MAX_INTERPOLATION_DRIFT_MS = 5_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.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.util.BitmapLoader import androidx.media3.common.util.BitmapLoader
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader 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.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer 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.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig 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.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.SonosQueueLoader
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -64,9 +56,6 @@ class PlayerFactory @Inject constructor(
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController, 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() } private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
@@ -95,29 +84,6 @@ class PlayerFactory @Inject constructor(
) )
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow() val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
// Fires when the renderer is found to have reached the end of a queue
// shorter than ours -- i.e. part of the queue load never landed. The
// controller owns queue loading, so it collects this and rebuilds.
// Same one-is-enough buffering: repeated notices are the same problem.
private val queueRepairInternal = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val queueRepairEvents: SharedFlow<Unit> = queueRepairInternal.asSharedFlow()
// Raw renderer transport readings, change-gated. Unlike the flows above
// this one carries a SEQUENCE — the diagnostics flap detector needs
// several readings in a row to tell oscillation from a normal track
// transition — so it buffers more than one and drops oldest under
// pressure rather than collapsing to the latest.
private val transportInternal = MutableSharedFlow<TransportObservation>(
replay = 0,
extraBufferCapacity = TRANSPORT_EVENT_BUFFER,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val transportEvents: SharedFlow<TransportObservation> = transportInternal.asSharedFlow()
fun build(): Player { fun build(): Player {
val exo = buildExoPlayer() val exo = buildExoPlayer()
return MinstrelForwardingPlayer( return MinstrelForwardingPlayer(
@@ -126,13 +92,8 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState, remoteState = remoteState,
castNetworkLock = CastNetworkLock(context), castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth, networkStatus = serverHealth,
events = MinstrelForwardingPlayer.RemoteEvents( onDrop = { name -> emitDrop(name) },
onDrop = { name -> emitDrop(name) }, onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
onQueueTruncated = { queueRepairInternal.tryEmit(Unit) },
onTransport = { transportInternal.tryEmit(it) },
onRendererTrack = { sonosQueue.onRendererTrack(it) },
),
) )
} }
@@ -154,28 +115,7 @@ class PlayerFactory @Inject constructor(
val mediaSourceFactory = DefaultMediaSourceFactory(context) val mediaSourceFactory = DefaultMediaSourceFactory(context)
.setDataSourceFactory(cacheDataSource) .setDataSourceFactory(cacheDataSource)
// Loudness normalization (M464 #5000) runs inside the audio sink so return ExoPlayer.Builder(context)
// 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)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
@@ -186,17 +126,6 @@ class PlayerFactory @Inject constructor(
) )
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.build() .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
} }
/** /**
@@ -217,12 +146,6 @@ class PlayerFactory @Inject constructor(
.build(), .build(),
) )
private companion object {
// Enough readings to hold a whole flap episode plus the normal
// transitions around it; the detector's window is only a few seconds.
const val TRANSPORT_EVENT_BUFFER = 32
}
private fun emitDrop(routeName: String) { private fun emitDrop(routeName: String) {
dropEventsInternal.tryEmit(routeName) dropEventsInternal.tryEmit(routeName)
} }
@@ -29,23 +29,6 @@ import com.fabledsword.minstrel.player.output.upnp.TransportState
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an * [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
* infinite retry loop against the renderer. * infinite retry loop against the renderer.
* *
* A stop is not always a fault, and not always the same fault. Three
* different things arrive here looking identical — the transport says
* STOPPED and we wanted to be playing:
*
* 1. The stream died mid-track. Re-play and seek back. ([Decision.Resume])
* 2. The renderer reached the end of a queue *shorter than ours*, because
* part of the load never landed. Nothing is broken; it is playing
* exactly what it was given. Repairing the queue is the fix, and
* re-playing the finished track is not. ([Decision.RepairQueue])
* 3. The renderer reached the end of the queue and so did we. Playback is
* simply over. ([Decision.None])
*
* Case 3 matters as much as the others: without [QueueState] every cast
* session would end with the watchdog retrying the last track three times
* and then reporting a `stalled` error for a listening session that
* finished perfectly normally.
*
* Pure decision state, no coroutines and no SOAP: the caller owns the poll * Pure decision state, no coroutines and no SOAP: the caller owns the poll
* loop and performs the transport calls, this only says what should happen. * loop and performs the transport calls, this only says what should happen.
* That keeps the awkward part — counting, keying and giving up — testable * That keeps the awkward part — counting, keying and giving up — testable
@@ -53,46 +36,6 @@ import com.fabledsword.minstrel.player.output.upnp.TransportState
*/ */
class RemoteStallWatchdog { class RemoteStallWatchdog {
/**
* What the renderer's queue looks like relative to ours, as of this poll.
* The caller derives it from GetMediaInfo's NrTracks against the local
* queue; it only needs to be accurate when the transport is not playing.
*/
enum class QueueState {
/**
* The renderer didn't report a usable count, or it is playing and the
* question is moot. Treated as "assume a real stall" — the old
* behaviour, which is right when we know nothing.
*/
UNKNOWN,
/** The renderer still has tracks after the current one. */
HAS_MORE,
/**
* The renderer is on its last track but our queue has tracks it never
* received — the load was truncated.
*/
TRUNCATED,
/** Renderer is on its last track and so are we: playback is over. */
COMPLETE,
}
/**
* One poll's worth of observation. Grouped into a type rather than passed
* as a long parameter list so adding a fact doesn't reshuffle call sites.
*/
data class Poll(
val trackUri: String,
val state: TransportState,
val statusOk: Boolean,
val playIntent: Boolean,
val positionMs: Long,
val nowMs: Long,
val queue: QueueState = QueueState.UNKNOWN,
)
sealed interface Decision { sealed interface Decision {
/** Nothing to do. */ /** Nothing to do. */
data object None : Decision data object None : Decision
@@ -104,14 +47,6 @@ class RemoteStallWatchdog {
*/ */
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
/**
* The renderer ran off the end of a queue we failed to fully load.
* The caller should append the tail it never got and resume at the
* next track — re-playing the current one would just replay a track
* the listener already heard.
*/
data class RepairQueue(val attempt: Int) : Decision
/** Attempts are exhausted. Report it and stop trying for this track. */ /** Attempts are exhausted. Report it and stop trying for this track. */
data object GiveUp : Decision data object GiveUp : Decision
} }
@@ -124,22 +59,36 @@ class RemoteStallWatchdog {
private var gaveUp: Boolean = false private var gaveUp: Boolean = false
/** /**
* Feed one poll result in, get the action out. See [Poll] for the inputs; * Feed one poll result in, get the action out.
* `nowMs` is a monotonic clock (SystemClock.elapsedRealtime), passed in so *
* tests can drive time. * @param trackUri the renderer's current track URI — identity for the
* per-track attempt budget, so moving to the next track forgives a
* previous one's failures.
* @param statusOk the transport's own status flag: false means the
* renderer is reporting an error rather than merely being stopped.
* @param playIntent the operator's last play/pause intent.
* @param nowMs a monotonic clock (SystemClock.elapsedRealtime), passed in
* so tests can drive time.
*/ */
@Suppress("ReturnCount") // early returns per state are clearer than nesting @Suppress("ReturnCount") // early returns per state are clearer than nesting
fun onPoll(poll: Poll): Decision { fun onPoll(
if (poll.trackUri != trackKey) { trackUri: String,
state: TransportState,
statusOk: Boolean,
playIntent: Boolean,
positionMs: Long,
nowMs: Long,
): Decision {
if (trackUri != trackKey) {
// New track: a fresh attempt budget, and no inherited stall state. // New track: a fresh attempt budget, and no inherited stall state.
trackKey = poll.trackUri trackKey = trackUri
resetStall() resetStall()
attempts = 0 attempts = 0
gaveUp = false gaveUp = false
lastPlayingPositionMs = 0L lastPlayingPositionMs = 0L
} }
if (!poll.playIntent) { if (!playIntent) {
// Stopped because we asked. Not a stall, and the next genuine one // Stopped because we asked. Not a stall, and the next genuine one
// should start from a clean budget. // should start from a clean budget.
resetStall() resetStall()
@@ -148,8 +97,8 @@ class RemoteStallWatchdog {
return Decision.None return Decision.None
} }
if (poll.state == TransportState.PLAYING && poll.statusOk) { if (state == TransportState.PLAYING && statusOk) {
lastPlayingPositionMs = poll.positionMs lastPlayingPositionMs = positionMs
resetStall() resetStall()
// A track that recovered and is playing again has earned back its // A track that recovered and is playing again has earned back its
// budget; a later, unrelated stall on the same track should get // budget; a later, unrelated stall on the same track should get
@@ -158,7 +107,7 @@ class RemoteStallWatchdog {
return Decision.None return Decision.None
} }
val stalled = poll.state == TransportState.STOPPED || !poll.statusOk val stalled = state == TransportState.STOPPED || !statusOk
if (!stalled) { if (!stalled) {
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in // PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
// flight). Neither is a stall; drop the streak so a mid-track // flight). Neither is a stall; drop the streak so a mid-track
@@ -167,14 +116,6 @@ class RemoteStallWatchdog {
return Decision.None return Decision.None
} }
// The queue simply ended. Not a fault, so it must not consume the
// attempt budget or raise an error — the listener heard everything
// they queued.
if (poll.queue == QueueState.COMPLETE) {
resetStall()
return Decision.None
}
stoppedStreak += 1 stoppedStreak += 1
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
if (gaveUp) return Decision.None if (gaveUp) return Decision.None
@@ -183,15 +124,11 @@ class RemoteStallWatchdog {
gaveUp = true gaveUp = true
return Decision.GiveUp return Decision.GiveUp
} }
if (attempts > 0 && poll.nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None if (attempts > 0 && nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
attempts += 1 attempts += 1
lastAttemptAtMs = poll.nowMs lastAttemptAtMs = nowMs
return if (poll.queue == QueueState.TRUNCATED) { return Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
Decision.RepairQueue(attempt = attempts)
} else {
Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
}
} }
/** Forget everything — call when the route changes or playback is torn down. */ /** Forget everything — call when the route changes or playback is torn down. */
@@ -19,13 +19,6 @@ import javax.inject.Singleton
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) { class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
private val api: CastApi = retrofit.create() private val api: CastApi = retrofit.create()
/** suspend fun mint(trackId: String): StreamTokenResponse =
* Mints a URL for a speaker, leveled when the user's setting calls for api.streamToken(StreamTokenRequest(trackId = trackId))
* 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),
)
} }
@@ -1,38 +0,0 @@
package com.fabledsword.minstrel.player
/**
* One reading of what a UPnP renderer says it is doing, taken by the poll
* loop and emitted only when it differs from the previous reading.
*
* Exists for diagnostics. The operator reports the Sonos rapidly
* play-pause-play-pausing at the start of a track, and nothing in the
* diagnostics could see it: `player_state` records source / loading / error
* but not whether we are playing, `track_change` needs the queue index to
* move, and the heartbeat samples once every 45 seconds. A symptom that
* lasts a few seconds and changes no index fell straight through all three.
*
* This is the closest observation point we have to the renderer's own truth
* — the raw GetTransportInfo reading, before the two-poll confirmation and
* the UI's smoothing have had a chance to hide the wobble.
*
* **It samples at the poll cadence (1 Hz).** If the real oscillation is
* faster than that, what lands here is an aliased jagged sequence rather
* than the true waveform. That still answers the question that matters —
* whether the renderer is steadily PLAYING or repeatedly leaving that state
* — but it cannot measure the true period. If a captured episode comes back
* looking clean, the next instrument is burst sampling, not this one.
*/
data class TransportObservation(
/** [com.fabledsword.minstrel.player.output.upnp.TransportState] name. */
val state: String,
/** CurrentTransportStatus: false means the renderer reports an error. */
val statusOk: Boolean,
/** The renderer's 1-based queue position at this reading. */
val trackNumber: Int,
/** The renderer's reported position within the track. */
val positionMs: Long,
/** Whether the operator's last intent was to be playing. */
val playIntent: Boolean,
/** Monotonic stamp, so a consumer can measure gaps between readings. */
val atElapsedMs: Long,
)
@@ -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
}
}
@@ -10,9 +10,11 @@ import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.PlayerFactory import com.fabledsword.minstrel.player.PlayerFactory
import com.fabledsword.minstrel.player.RemotePlayerState import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.bareUdn import com.fabledsword.minstrel.player.output.upnp.bareUdn
@@ -59,7 +61,7 @@ data class RouteSnapshot(
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in, * - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth) * wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via * - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [SonosQueueLoader], drive the discovered renderer with * [StreamTokenProvider.mint], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so * AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker * audio yields to the network speaker
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] — * - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
@@ -77,7 +79,7 @@ class OutputPickerController @Inject constructor(
private val upnpDiscovery: UpnpDiscoveryController, private val upnpDiscovery: UpnpDiscoveryController,
private val playerController: PlayerController, private val playerController: PlayerController,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val sonosQueue: SonosQueueLoader, private val streamTokens: StreamTokenProvider,
private val activeUpnpHolder: ActiveUpnpHolder, private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
@@ -171,7 +173,6 @@ class OutputPickerController @Inject constructor(
playerFactory.dropEvents.collect { handleRemoteDrop() } playerFactory.dropEvents.collect { handleRemoteDrop() }
} }
scope.launch { observeQueueChangesForSonosResync() } scope.launch { observeQueueChangesForSonosResync() }
scope.launch { observeQueueRepairRequests() }
scope.launch { observeIdleRevertWhileUpnp() } scope.launch { observeIdleRevertWhileUpnp() }
scope.launch { observeSelectedRouteDisappearance() } scope.launch { observeSelectedRouteDisappearance() }
} }
@@ -254,7 +255,7 @@ class OutputPickerController @Inject constructor(
* setMediaItems override clears holder.active + sets target so the * setMediaItems override clears holder.active + sets target so the
* imminent play() call drops (drops via isLoadingUpnp() = true). Then * imminent play() call drops (drops via isLoadingUpnp() = true). Then
* this collector observes the uiState.queue change and re-runs * this collector observes the uiState.queue change and re-runs
* SonosQueueLoader.load to push the new tracks to Sonos. * loadQueueOnSonos to push the new tracks to Sonos.
* *
* Discrimination: selectUpnp's initial-load path doesn't change * Discrimination: selectUpnp's initial-load path doesn't change
* uiState.queue (the queue was already populated before route * uiState.queue (the queue was already populated before route
@@ -285,71 +286,6 @@ class OutputPickerController @Inject constructor(
} }
} }
/**
* Rebuild the renderer's queue when playback stopped because the renderer
* ran off the end of a queue shorter than ours.
*
* [SonosQueueLoader] tolerates individual AddURIToQueue failures and
* gives up appending after a few consecutive ones
* -- Sonos rate-limits burst adds. Until this existed that left a short
* queue on the renderer and nothing to notice it: the renderer played what
* it had and stopped, and the app went on believing there were forty
* tracks left. [MinstrelForwardingPlayer] now compares GetMediaInfo's
* NrTracks against the local queue and asks for this.
*
* A full reload, not an incremental diff: the renderer's copy is known to
* be wrong, and the diff path reasons from what we *think* it holds, which
* is exactly the assumption that failed. The load re-seeks to the
* current track and plays, so recovery lands where the listener was.
*/
private suspend fun observeQueueRepairRequests() {
playerFactory.queueRepairEvents.collect {
val routeId = selectedUpnpRouteIdInternal.value
?: activeUpnpHolder.active.value?.routeId
if (routeId == null) {
Timber.w("Sonos queue repair skipped: no UPnP route selected")
return@collect
}
val state = playerController.uiState.value
if (state.queue.isEmpty()) {
Timber.w("Sonos queue repair skipped: local queue is empty")
return@collect
}
// Resume on the track AFTER the current one. The renderer stopped
// because it finished the last track it had; the local cursor is
// synced to that track, so reloading at it would replay something
// the listener just heard. The next one is what they never got.
val resumeAt = (state.queueIndex + 1).coerceAtMost(state.queue.size - 1)
repairSonosQueue(routeId, state.queue, resumeAt)
}
}
private suspend fun repairSonosQueue(
routeId: String,
queue: List<TrackRef>,
currentIndex: Int,
) = selectUpnpMutex.withLock {
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
val transport = upnpDiscovery.transportFor(routeId)
if (upnpRoute == null || transport == null) {
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
queue.size, outputRoute.name, currentIndex,
)
runCatching {
sonosQueue.load(transport, outputRoute, queue, currentIndex)
}.onFailure { e ->
// Leave the route active: the renderer is reachable enough to have
// told us its queue length, so dropping to local would be a harsher
// remedy than letting the next stall re-decide.
Timber.w(e, "Sonos queue repair failed on %s", outputRoute.name)
}
}
/** /**
* Bring Sonos's native queue back in sync with the local queue after a * Bring Sonos's native queue back in sync with the local queue after a
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue * mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
@@ -377,7 +313,7 @@ class OutputPickerController @Inject constructor(
return@withLock return@withLock
} }
val handledIncrementally = runCatching { val handledIncrementally = runCatching {
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue) tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e -> }.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload") Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false false
@@ -401,7 +337,7 @@ class OutputPickerController @Inject constructor(
val rendering = renderingClientFor(routeId) val rendering = renderingClientFor(routeId)
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name) Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
runCatching { runCatching {
sonosQueue.load(transport, outputRoute, newQueue, newCurrentIndex) loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
activeUpnpHolder.set( activeUpnpHolder.set(
ActiveUpnp( ActiveUpnp(
routeId = routeId, routeId = routeId,
@@ -418,6 +354,98 @@ class OutputPickerController @Inject constructor(
} }
} }
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
private suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
newQueue: List<TrackRef>,
): Boolean {
val newIds = newQueue.map { it.id }
if (oldIds == newIds) return true
val prefixLen = commonPrefixLength(oldIds, newIds)
val suffixLen = commonSuffixLength(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val ref = newQueue[prefixLen + i]
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
return limit
}
/** /**
* Called when the active UPnP route drops unexpectedly (the poll loop's * Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -511,7 +539,7 @@ class OutputPickerController @Inject constructor(
* 1. Pause local so the user doesn't keep hearing local audio. * 1. Pause local so the user doesn't keep hearing local audio.
* 2. Set target early so ForwardingPlayer drops transport taps * 2. Set target early so ForwardingPlayer drops transport taps
* while the 17-second queue load is in progress. * while the 17-second queue load is in progress.
* 3. Wire active LAST (after the queue load) so SOAP commands * 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
* are never routed to a half-loaded Sonos queue. * are never routed to a half-loaded Sonos queue.
*/ */
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock { private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
@@ -552,7 +580,7 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session. // taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id) activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching { runCatching {
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex) loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
// Wire active LAST -- SOAP path is now safe to use. // Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set( activeUpnpHolder.set(
ActiveUpnp( ActiveUpnp(
@@ -669,6 +697,108 @@ class OutputPickerController @Inject constructor(
return if (i >= 0) segments.getOrNull(i + 1) else null return if (i >= 0) segments.getOrNull(i + 1) else null
} }
private suspend fun loadQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
initialBatch.size, currentIndex, queue.size,
)
initialBatch.forEachIndexed { idx, ref ->
val token = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
val remaining = queue.drop(initialEnd)
if (remaining.isNotEmpty()) {
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
}
}
/**
* Background-append tracks after activation. Runs concurrently with
* Sonos playback. Cancels if the user disconnects from this route
* (active.routeId changes or becomes null). Tolerates individual
* AddURIToQueue failures — log and continue so some tracks loaded
* is better than zero tracks loaded.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
tracks: List<TrackRef>,
startPosition: Int,
) {
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
tracks.size, startPosition + 1,
)
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
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 = streamTokens.mint(ref.id)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
}
private fun renderingClientFor(routeId: String): RenderingControlClient? { private fun renderingClientFor(routeId: String): RenderingControlClient? {
val rcUrl = upnpDiscovery.routes.value val rcUrl = upnpDiscovery.routes.value
@@ -700,6 +830,9 @@ class OutputPickerController @Inject constructor(
} }
private companion object { private companion object {
const val EXTEND_ABORT_AFTER_FAILURES = 3
const val EXTEND_THROTTLE_MS = 50L
// 5 minutes of continuous non-playing on a UPnP route before we // 5 minutes of continuous non-playing on a UPnP route before we
// revert to the phone speaker, so a stale Sonos selection can't make // revert to the phone speaker, so a stale Sonos selection can't make
// a later "tap play" do nothing. // a later "tap play" do nothing.
@@ -1,368 +0,0 @@
package com.fabledsword.minstrel.player.output
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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Owns the shape of a Sonos renderer's native queue: loading it, growing it,
* diffing it against local queue mutations, and — the part that was missing —
* confirming the renderer actually took what we sent.
*
* Split out of [OutputPickerController], which is about *which route is
* selected*. How many tracks the renderer is holding is a separate concern
* 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(
@ApplicationScope private val scope: CoroutineScope,
private val streamTokens: StreamTokenProvider,
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,
queue: List<TrackRef>,
currentIndex: Int,
) {
Timber.w("UPnP select: clear queue on %s", route.name)
transport.removeAllTracksFromQueue()
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
val initialBatch = queue.subList(0, initialEnd)
Timber.w(
"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)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = idx + 1,
)
}
val coordinatorUdn = route.id.bareUdn()
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
transport.setAVTransportURI(queueUri, "")
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
transport.seekToTrack(currentIndex + 1)
Timber.w("UPnP select: Play")
transport.play()
Timber.w("UPnP select: initial done; backgrounding remainder")
// 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)
}
verifyQueueLength(transport, route, queue)
}
}
/**
* Background-append tracks after activation. Runs concurrently with Sonos
* playback. Cancels if the user disconnects from this route (active.routeId
* changes or becomes null). Correctness of the result is [verifyQueueLength]'s
* job, not this function's.
*/
private suspend fun extendQueueOnSonos(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
startPosition: Int,
) {
val count = queue.size - startPosition
Timber.w(
"UPnP extend: appending %d tracks starting at position %d",
count, startPosition + 1,
)
val succeeded = appendTracksToQueue(transport, route, queue, startPosition)
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, count)
}
/**
* Confirm the renderer holds as many tracks as we sent, and append the
* tail it dropped.
*
* [appendTracksToQueue] tolerates individual failures and gives up after
* [EXTEND_ABORT_AFTER_FAILURES] consecutive ones, because Sonos rate-limits
* burst adds (logcat 2026-06-04: 33 consecutive failures once offset 39 was
* reached). That is the right call in the moment — some tracks loaded beats
* none — but it used to be the end of the story, and the renderer was left
* holding a queue shorter than ours with nothing aware of it. It then
* played what it had and stopped, which looked exactly like playback dying
* for no reason.
*
* Sonos appends sequentially, so a short queue means a missing tail: taking
* `fullQueue.drop(nrTracks)` is the gap. Bounded at [VERIFY_ROUNDS] passes
* so a renderer that refuses to grow can't spin here forever.
*/
@Suppress("ReturnCount") // each bail-out is a distinct reason to stop verifying
private suspend fun verifyQueueLength(
transport: AVTransportClient,
route: OutputRoute,
fullQueue: List<TrackRef>,
) {
repeat(VERIFY_ROUNDS) { round ->
if (activeUpnpHolder.active.value?.routeId != route.id) return
val nrTracks = runCatching { transport.getMediaInfo().nrTracks }
.getOrElse { e ->
Timber.w(e, "UPnP verify: GetMediaInfo failed on %s", route.name)
return
}
// 0 means the renderer told us nothing usable, not that its queue
// is empty. Guessing "empty" here would re-send the whole queue to
// a renderer that is playing it perfectly well.
if (nrTracks <= 0) {
Timber.w("UPnP verify: no usable NrTracks from %s; skipping", route.name)
return
}
if (nrTracks >= fullQueue.size) {
Timber.w("UPnP verify: renderer holds %d tracks, queue intact", nrTracks)
return
}
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,
)
appendTracksToQueue(transport, route, fullQueue, 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.
* 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.
*/
private suspend fun appendTracksToQueue(
transport: AVTransportClient,
route: OutputRoute,
queue: List<TrackRef>,
startPosition: Int,
): Int {
var consecutiveFailures = 0
var succeeded = 0
var aborted = false
for (i in 0 until queue.size - startPosition) {
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)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = startPosition + i + 1,
)
}
if (outcome.isSuccess) {
consecutiveFailures = 0
succeeded += 1
// Throttle the burst so we don't tickle Sonos's burst-add
// rejection -- logcat 2026-06-04 showed 33 consecutive
// failures clustered at ~10ms intervals once offset 39 was
// reached, which looks like a rate-limit kicking in. The
// delay is small enough that extending 100 tracks adds
// only ~5s to background work that's already async.
delay(EXTEND_THROTTLE_MS)
} else {
consecutiveFailures += 1
val e = outcome.exceptionOrNull()
val detail = (e as? SoapFaultException)?.let {
"code=${it.code} desc=${it.description}"
} ?: e?.message
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
Timber.w(
"UPnP extend: aborting after %d consecutive failures",
consecutiveFailures,
)
aborted = true
}
}
}
}
return succeeded
}
/**
* Diff-based incremental Sonos queue sync. Returns true when the new
* queue can be produced from the old one with a remove-then-insert at
* the same middle slice -- the common-prefix and common-suffix portions
* stay untouched, and the current Sonos track must lie in the preserved
* prefix (otherwise the diff would orphan playback). Returns false to
* signal the caller to fall back to a full reload.
*/
suspend fun tryIncrementalResync(
transport: AVTransportClient,
oldIds: List<String>,
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(
oldIds.subList(prefixLen, oldIds.size),
newIds.subList(prefixLen, newIds.size),
)
val removedCount = oldIds.size - prefixLen - suffixLen
val addedCount = newIds.size - prefixLen - suffixLen
// Sonos's current track number is 1-based; compare against the
// preserved-prefix range as 0-based. If the current track is in
// the removed slice, incremental can't preserve playback -- caller
// falls back to full rebuild.
val currentSonosIdx0 = remoteState.trackNumber - 1
val canApply = currentSonosIdx0 in 0 until prefixLen
if (canApply) {
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
} else {
Timber.w(
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
currentSonosIdx0,
prefixLen,
)
}
return canApply
}
private suspend fun applyQueueDiff(
transport: AVTransportClient,
newQueue: List<TrackRef>,
prefixLen: Int,
removedCount: Int,
addedCount: Int,
) {
if (removedCount > 0) {
Timber.w(
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
prefixLen + 1,
removedCount,
)
transport.removeTrackRangeFromQueue(
startingIndex = prefixLen + 1,
numberOfTracks = removedCount,
)
}
if (addedCount == 0) return
Timber.w(
"Sonos incremental: AddURIToQueue x%d starting at position %d",
addedCount,
prefixLen + 1,
)
for (i in 0 until addedCount) {
val token = mint(newQueue, prefixLen + i)
transport.addURIToQueue(
uri = token.url,
mime = token.mime,
title = token.title,
enqueuedURIPosition = prefixLen + i + 1,
)
if (i > 0) delay(EXTEND_THROTTLE_MS)
}
}
/**
* 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) {
if (a[i] != b[i]) return i
}
return limit
}
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
val limit = minOf(a.size, b.size)
for (i in 0 until limit) {
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
}
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)
// 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
// 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
}
}
@@ -211,34 +211,6 @@ class AVTransportClient(
) )
} }
/**
* What the renderer believes it is holding: how many tracks are in its
* queue, and the URI the transport is pointed at.
*
* We load the queue with AddURIToQueue and, until this existed, never
* read it back — so a partially-applied load was invisible. Sonos
* rate-limits burst adds (logcat 2026-06-04: 33 consecutive failures once
* offset 39 was reached), and [OutputPickerController]'s extend loop gives
* up after a few of those and leaves a short queue behind. The renderer
* then plays what it actually has and stops, correctly, at an end the app
* did not know existed.
*
* NrTracks is the cheap authoritative answer, so queue truncation becomes
* something we can detect and repair rather than infer.
*/
suspend fun getMediaInfo(): MediaInfo {
val result = soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "GetMediaInfo",
args = mapOf("InstanceID" to "0"),
)
return MediaInfo(
nrTracks = result["NrTracks"]?.toIntOrNull() ?: 0,
currentUri = result["CurrentURI"].orEmpty(),
)
}
suspend fun getTransportInfo(): TransportInfo { suspend fun getTransportInfo(): TransportInfo {
val result = soap.call( val result = soap.call(
controlUrl = controlUrl, controlUrl = controlUrl,
@@ -326,14 +298,6 @@ data class PositionInfo(
val trackDurationMs: Long, val trackDurationMs: Long,
) )
/**
* [nrTracks] is the renderer's own count of its queue — 0 when it reports
* nothing, which callers must read as "unknown", never as "empty". A
* renderer that does not implement GetMediaInfo usefully must not be
* mistaken for one with an empty queue.
*/
data class MediaInfo(val nrTracks: Int, val currentUri: String)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN } enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
/** /**
@@ -109,11 +109,8 @@ private fun MiniCover(coverUrl: String, contentDescription: String) {
* NowPlayingScreen via [onExpandClick]. * NowPlayingScreen via [onExpandClick].
* *
* Layout (Column): * Layout (Column):
* - Slim seek slider pinned at the top (4dp track) * - Slim seek slider at the top (4dp track)
* - Row: cover | title/artist column | like | prev | play/pause | next. * - 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.
* *
* No kebab on the mini bar (operator 2026-06-01): the full kebab * No kebab on the mini bar (operator 2026-06-01): the full kebab
* surface lives on NowPlayingScreen, and dropping it from the mini * surface lives on NowPlayingScreen, and dropping it from the mini
@@ -167,12 +164,6 @@ fun MiniPlayer(
durationMs = state.durationMs, durationMs = state.durationMs,
) )
MiniRow( 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, track = track,
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
isUpnpLoading = state.isUpnpLoading, isUpnpLoading = state.isUpnpLoading,
@@ -214,7 +205,6 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
@Composable @Composable
@Suppress("LongParameterList") @Suppress("LongParameterList")
private fun MiniRow( private fun MiniRow(
modifier: Modifier,
track: TrackRef, track: TrackRef,
isPlaying: Boolean, isPlaying: Boolean,
isUpnpLoading: Boolean, isUpnpLoading: Boolean,
@@ -226,7 +216,7 @@ private fun MiniRow(
onToggleLike: () -> Unit, onToggleLike: () -> Unit,
) { ) {
Row( Row(
modifier = modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, 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.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.theme.LocalActionColors import com.fabledsword.minstrel.theme.LocalActionColors
import kotlin.math.roundToInt
/* /*
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435) * A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
@@ -277,7 +278,8 @@ private fun Modifier.queueReorderDrag(
onOffsetChange(offset) onOffsetChange(offset)
}, },
onDragEnd = { 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) if (target != index) onMove(index, target)
offset = 0f offset = 0f
onOffsetChange(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.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy 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.message
import com.fabledsword.minstrel.update.data.stage import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -37,14 +37,6 @@ sealed interface UpdateCheckResult {
data class AboutUiState( data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME, 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 isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE, val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null, val installMessage: String? = null,
@@ -53,9 +45,8 @@ data class AboutUiState(
/** /**
* Backs the About card's update controls. "Check for updates" calls * Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus this build via * [UpdateRepository.getLatest], compares versus the build's
* [isUpdateAvailable] — on the ordering key where the server reports one, * VERSION_NAME via [isVersionNewer], and reports the terminal state.
* on the name otherwise — and reports the terminal state.
* When an update is available, [install] downloads the APK via * When an update is available, [install] downloads the APK via
* [ApkInstaller] and installs it — routing the user to the "install * [ApkInstaller] and installs it — routing the user to the "install
* unknown apps" settings page first when that permission hasn't been * 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() val state: StateFlow<AboutUiState> = internal.asStateFlow()
fun checkForUpdates() { fun checkForUpdates() {
if (internal.value.isChecking || !internal.value.selfUpdates) return if (internal.value.isChecking) return
viewModelScope.launch { viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) } internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion val installed = internal.value.installedVersion
val installedCode = internal.value.installedCode
val result = runCatching { repository.getLatest() } val result = runCatching { repository.getLatest() }
.map { latest -> .map { latest ->
if ( if (isVersionNewer(latest.version, installed)) {
isUpdateAvailable(
serverCode = latest.code,
serverName = latest.version,
installedCode = installedCode,
installedName = installed,
)
) {
UpdateCheckResult.UpdateAvailable(latest) UpdateCheckResult.UpdateAvailable(latest)
} else { } else {
UpdateCheckResult.Latest 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 { try {
repository.changePassword(current = s.current, next = s.next) repository.changePassword(current = s.current, next = s.next)
internal.update { internal.update {
PasswordUiState(message = "Password changed. Your other devices have been signed out.") PasswordUiState(message = "Password changed.")
} }
} catch ( } catch (
@Suppress("TooGenericExceptionCaught") e: Throwable, @Suppress("TooGenericExceptionCaught") e: Throwable,
@@ -45,7 +45,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.ChevronRight import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.LogOut
@@ -53,7 +52,6 @@ import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shield import com.composables.icons.lucide.Shield
import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.nav.Settings as SettingsRoute import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl import com.fabledsword.minstrel.nav.ServerUrl
@@ -100,7 +98,6 @@ fun SettingsScreen(
themeMode = themeMode, themeMode = themeMode,
onPickTheme = themeVm::setThemeMode, onPickTheme = themeVm::setThemeMode,
onNavToRequests = { navController.navigate(Requests) }, onNavToRequests = { navController.navigate(Requests) },
onNavToNotifications = { navController.navigate(NotificationSettings) },
onNavToAdmin = { navController.navigate(Admin) }, onNavToAdmin = { navController.navigate(Admin) },
onToggleDiagnostics = viewModel::setDiagnosticsOptOut, onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
onSignOutClick = { showSignOutConfirm = true }, onSignOutClick = { showSignOutConfirm = true },
@@ -124,7 +121,6 @@ private fun SettingsList(
themeMode: ThemeMode, themeMode: ThemeMode,
onPickTheme: (ThemeMode) -> Unit, onPickTheme: (ThemeMode) -> Unit,
onNavToRequests: () -> Unit, onNavToRequests: () -> Unit,
onNavToNotifications: () -> Unit,
onNavToAdmin: () -> Unit, onNavToAdmin: () -> Unit,
onToggleDiagnostics: (Boolean) -> Unit, onToggleDiagnostics: (Boolean) -> Unit,
onSignOutClick: () -> Unit, onSignOutClick: () -> Unit,
@@ -148,12 +144,6 @@ private fun SettingsList(
subtitle = "Track what you've asked Minstrel to add", subtitle = "Track what you've asked Minstrel to add",
onClick = onNavToRequests, onClick = onNavToRequests,
) )
NavTile(
icon = Lucide.Bell,
title = "Notifications",
subtitle = "What reaches you, and where",
onClick = onNavToNotifications,
)
if (state.isAdmin) { if (state.isAdmin) {
NavTile( NavTile(
icon = Lucide.Shield, icon = Lucide.Shield,
@@ -171,7 +161,6 @@ private fun SettingsList(
onToggle = onToggleDiagnostics, onToggle = onToggleDiagnostics,
) )
} }
NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme) AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard() StorageCard()
AboutCard() AboutCard()
@@ -393,14 +382,6 @@ private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
@Composable @Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) { 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) UpdateCheckLine(result = state.result)
Button( Button(
onClick = viewModel::checkForUpdates, onClick = viewModel::checkForUpdates,
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthController import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,7 +28,6 @@ data class SettingsState(
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val authController: AuthController, private val authController: AuthController,
private val authStore: AuthStore, private val authStore: AuthStore,
private val notifications: NotificationsRepository,
) : ViewModel() { ) : ViewModel() {
private val transient = MutableStateFlow(TransientState()) private val transient = MutableStateFlow(TransientState())
@@ -72,8 +70,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
transient.update { it.copy(isSigningOut = true) } transient.update { it.copy(isSigningOut = true) }
authController.signOut() 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) } 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthController import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -23,7 +22,6 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
@HiltViewModel @HiltViewModel
class AppBarActionsViewModel @Inject constructor( class AppBarActionsViewModel @Inject constructor(
authController: AuthController, authController: AuthController,
notifications: NotificationsRepository,
) : ViewModel() { ) : ViewModel() {
val isAdmin: StateFlow<Boolean> = authController.currentUser val isAdmin: StateFlow<Boolean> = authController.currentUser
.map { it?.isAdmin == true } .map { it?.isAdmin == true }
@@ -32,12 +30,4 @@ class AppBarActionsViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false, 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 package com.fabledsword.minstrel.shared.widgets
import androidx.compose.foundation.layout.Row 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -17,7 +14,6 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.House import com.composables.icons.lucide.House
import com.composables.icons.lucide.LibraryBig import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide 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.Discover
import com.fabledsword.minstrel.nav.Home import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Library import com.fabledsword.minstrel.nav.Library
import com.fabledsword.minstrel.nav.Notifications
import com.fabledsword.minstrel.nav.Playlists import com.fabledsword.minstrel.nav.Playlists
import com.fabledsword.minstrel.nav.Search as SearchRoute import com.fabledsword.minstrel.nav.Search as SearchRoute
import com.fabledsword.minstrel.nav.Settings as SettingsRoute import com.fabledsword.minstrel.nav.Settings as SettingsRoute
@@ -55,7 +50,6 @@ fun MainAppBarActions(
viewModel: AppBarActionsViewModel = hiltViewModel(), viewModel: AppBarActionsViewModel = hiltViewModel(),
) { ) {
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle() val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
Row { Row {
if (currentRouteName != Home::class.qualifiedName) { if (currentRouteName != Home::class.qualifiedName) {
IconButton(onClick = { navController.navigate(Home) }) { IconButton(onClick = { navController.navigate(Home) }) {
@@ -72,47 +66,10 @@ fun MainAppBarActions(
Icon(Lucide.SearchIcon, contentDescription = "Search") Icon(Lucide.SearchIcon, contentDescription = "Search")
} }
} }
if (currentRouteName != Notifications::class.qualifiedName) {
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
}
OverflowMenu(navController = navController, isAdmin = isAdmin) 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 @Composable
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) { private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@@ -2,45 +2,72 @@ package com.fabledsword.minstrel.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.fabledsword.minstrel.R import com.fabledsword.minstrel.R
/** /**
* Bundled typefaces, vendored into res/font by tools/vendor-fonts.py. * Google Fonts provider — fetches font files via Play Services Fonts at
* * runtime, caches them across launches. Matches the Flutter client's
* These were fetched at runtime through the Play Services font provider until * `google_fonts` package behaviour (no bundled .ttf files in either tree).
* 2026-09-09. That is a network dependency for rendering, and a deployed
* instance is not guaranteed one — the provider is also absent entirely on
* devices without Play Services, where the app silently fell back to the
* platform default and stopped looking like Minstrel. Bundling costs ~0.86 MB
* of APK and removes both failure modes.
* *
* Per FabledSword design system: * Per FabledSword design system:
* - Fraunces — display + headline (mythic serif) * - Fraunces — display + headline (mythic serif)
* - Inter — body + label (clean sans for UI text) * - Inter — body + label (clean sans for UI text)
* - JetBrains Mono — technical / monospace * - JetBrains Mono — technical / monospace
* Weights are restricted to 400 (regular) and 500 (medium) only. * Weights are restricted to 400 (regular) and 500 (medium) only.
*
* Each res/font entry is a single static instance, not a variable font: the
* weight declared beside it here must match the file's own OS/2
* usWeightClass, which the vendoring script asserts on download.
*/ */
private val GoogleFontProvider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs,
)
private val FrauncesFont = GoogleFont("Fraunces")
private val InterFont = GoogleFont("Inter")
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
private val Fraunces = FontFamily( private val Fraunces = FontFamily(
Font(R.font.fraunces_regular, FontWeight.W400, FontStyle.Normal), Font(
Font(R.font.fraunces_medium, FontWeight.W500, FontStyle.Normal), googleFont = FrauncesFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W400,
style = FontStyle.Normal,
),
Font(
googleFont = FrauncesFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W500,
style = FontStyle.Normal,
),
) )
private val Inter = FontFamily( private val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.W400, FontStyle.Normal), Font(
Font(R.font.inter_medium, FontWeight.W500, FontStyle.Normal), googleFont = InterFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W400,
style = FontStyle.Normal,
),
Font(
googleFont = InterFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W500,
style = FontStyle.Normal,
),
) )
private val JetBrainsMono = FontFamily( private val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.W400, FontStyle.Normal), Font(
googleFont = JetBrainsMonoFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W400,
style = FontStyle.Normal,
),
) )
/** /**
@@ -19,8 +19,7 @@ private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
/** /**
* Drives the shell's soft "update available" banner. Polls * Drives the shell's soft "update available" banner. Polls
* `/api/client/version` at launch + every 24h and, when the bundled * `/api/client/version` at launch + every 24h and, when the bundled
* APK outranks this build — by ordering key where the server reports one, * APK is strictly newer than this build, exposes its [UpdateInfo] so
* by name otherwise — exposes its [UpdateInfo] so
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an * [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
* install. Mirrors Flutter's `ClientUpdateController`. * 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 * restart re-shows it, which is acceptable nudging for v1 (matches
* Flutter). Server 404 / network errors stay silent. Constructed at * Flutter). Server 404 / network errors stay silent. Constructed at
* launch via the construct-the-singleton trick in `MinstrelApplication`. * 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 @Singleton
class UpdateBannerController @Inject constructor( class UpdateBannerController @Inject constructor(
@@ -48,10 +44,6 @@ class UpdateBannerController @Inject constructor(
}.stateIn(scope, SharingStarted.Eagerly, null) }.stateIn(scope, SharingStarted.Eagerly, null)
init { init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch { scope.launch {
while (true) { while (true) {
runOnce() runOnce()
@@ -66,13 +58,6 @@ class UpdateBannerController @Inject constructor(
private suspend fun runOnce() { private suspend fun runOnce() {
val info = runCatching { repository.getLatest() }.getOrNull() ?: return val info = runCatching { repository.getLatest() }.getOrNull() ?: return
latest.value = info.takeIf { latest.value = info.takeIf { isVersionNewer(it.version, BuildConfig.VERSION_NAME) }
isUpdateAvailable(
serverCode = it.code,
serverName = it.version,
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
)
}
} }
} }
@@ -21,39 +21,10 @@ class UpdateRepository @Inject constructor(retrofit: Retrofit) {
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo( private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
version = version, version = version,
code = code,
channel = channel,
apkUrl = apkUrl, apkUrl = apkUrl,
sizeBytes = sizeBytes, 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 * True when [server] is strictly newer than [installed]. Mirrors
* Flutter's `isVersionNewer` — splits both strings on `.`, parses * 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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

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