Compare commits
48
Commits
f3b124f9b2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0192c0744c | ||
|
|
be642cd53f | ||
|
|
7529266f5b | ||
|
|
7d5dcd454e | ||
|
|
3fa5d9fc3a | ||
|
|
fa75bd08d7 | ||
|
|
de41169ccb | ||
|
|
b1e55adc14 | ||
|
|
5dc6aa9c2f | ||
|
|
ec1e59921b | ||
|
|
7ec8a82871 | ||
|
|
b61474744b | ||
|
|
1aba1a41e6 | ||
|
|
0d0b04bff0 | ||
|
|
165c87047e | ||
|
|
05bc85f2b8 | ||
|
|
d1fe3758d5 | ||
|
|
664b129947 | ||
|
|
c9fa18ecb7 | ||
|
|
3077ed75fe | ||
|
|
97e84a79eb | ||
|
|
a29ceaaeab | ||
|
|
54b2720d76 | ||
|
|
fe142e3451 | ||
|
|
3bdfa94f39 | ||
|
|
22ea27efff | ||
|
|
09bd71629a | ||
|
|
c83670d216 | ||
|
|
1e9aed214b | ||
|
|
3638d1d822 | ||
|
|
18618bd135 | ||
|
|
d2985f3841 | ||
|
|
f70df9f827 | ||
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+40
-103
@@ -2,9 +2,9 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (freshly-built stable APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# That is the whole tag map.
|
||||
@@ -41,6 +41,12 @@ name: release
|
||||
# image in the same run from the same commit, and the channel cannot go
|
||||
# stale against its own artifact.
|
||||
#
|
||||
# Every branch build carries the app built from its own commit, so a merge to
|
||||
# main is all it takes to put a new app in front of stable phones: `:latest`
|
||||
# serves it from /api/client/apk and the in-app update offers it. A release
|
||||
# is an optional bookmark — a named point in history with the APK attached
|
||||
# for download — never a step the app has to wait for.
|
||||
#
|
||||
# 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
|
||||
@@ -56,20 +62,14 @@ name: release
|
||||
# :latest is updated by every main push AND every tag push, so it always
|
||||
# reflects the newest blessed image.
|
||||
#
|
||||
# APK pipeline: on tag pushes the android-release job builds + signs the
|
||||
# Android APK and uploads it as a workflow artifact. The image-release
|
||||
# job declares `needs: android-release`, so the docker image cannot
|
||||
# start building until the APK is guaranteed-ready — no polling, no
|
||||
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||
# its own job (release-assets), behind the test gate below.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# AND the version sidecar published beside it — the recorded values, not
|
||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||
# keep bundling their own freshly-built APK.
|
||||
# APK pipeline: on every push — dev, main and tags alike — the
|
||||
# android-release job builds + signs the Android APK and uploads it as a
|
||||
# workflow artifact. The image-release job declares `needs: android-release`
|
||||
# and requires its success, so the docker image cannot start building until
|
||||
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
|
||||
# and no image that moves a channel without an app inside it. Attaching the
|
||||
# APK to the gitea Release is its own job (release-assets), tags only, behind
|
||||
# the test gate below.
|
||||
#
|
||||
# THE GATE. Every verifying lane lives in this file —
|
||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||
@@ -400,11 +400,14 @@ jobs:
|
||||
# ------------------------------------------------------------ artifacts --
|
||||
|
||||
android-release:
|
||||
name: Build signed APK (releases and dev)
|
||||
# Also builds on `dev`, which is what makes a test channel possible at
|
||||
# all: without it the only way to get a build onto a phone is to cut a
|
||||
# release, which quietly turns `main` into the staging area.
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||
name: Build signed APK
|
||||
# Builds on every ref, so each channel's image carries the app from its
|
||||
# own commit: `dev` for the test channel, `main` for stable phones, and a
|
||||
# tag for the copy attached to its Release.
|
||||
if: >-
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
|| github.ref == 'refs/heads/dev'
|
||||
|| github.ref == 'refs/heads/main'
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
@@ -452,7 +455,7 @@ jobs:
|
||||
# The derivation lives in ci/version.sh, not here, so it can be
|
||||
# executed by a test on every push. Anything inline in this file is
|
||||
# unverifiable until a release is already running.
|
||||
out="$(ci/version.sh HEAD)"
|
||||
out="$(ci/version.sh --apk HEAD)"
|
||||
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The channel is a property of the LANE, not of the commit, which is
|
||||
@@ -601,9 +604,9 @@ jobs:
|
||||
path: release-apk/
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
# Tag releases only. A dev build has no Release to hang assets on and
|
||||
# does not need one — the :dev image bundles the APK, and the server
|
||||
# serves it from /api/client/apk like any other.
|
||||
# Tag releases only. A branch build has no Release to hang assets on
|
||||
# and does not need one — its image bundles the APK, and the server
|
||||
# serves it from /api/client/apk.
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
@@ -667,10 +670,9 @@ jobs:
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# Every lane must have SUCCEEDED, each named here. Then the
|
||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||
# pushes skip android-release and bundle the latest release's APK
|
||||
# instead, so for main alone a skipped android-release is expected.
|
||||
# Every lane must have SUCCEEDED, each named here, and so must the APK
|
||||
# build: every ref bundles the APK built from its own commit, so an
|
||||
# image without one would move a channel with no app for phones to take.
|
||||
needs: [go, integration, web, android, govulncheck, android-release]
|
||||
if: >-
|
||||
${{
|
||||
@@ -680,8 +682,7 @@ jobs:
|
||||
&& needs.web.result == 'success'
|
||||
&& needs.android.result == 'success'
|
||||
&& needs.govulncheck.result == 'success'
|
||||
&& (needs.android-release.result == 'success'
|
||||
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
||||
&& needs.android-release.result == 'success' }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
@@ -747,9 +748,10 @@ jobs:
|
||||
# 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.
|
||||
# :latest moves here so the server hands out the same APK build
|
||||
# that is attached to the Release: a phone that installed from the
|
||||
# Release page and one that updated in-app hold one build, with one
|
||||
# ordering key. Cut a release on main's tip, as :latest follows it.
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
@@ -782,12 +784,8 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||
# takes the "Bundle latest release APK" path below, because it is the
|
||||
# one ref that moves a channel without building an APK of its own.
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# android-release just produced this, on every ref.
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||
# out of the bundle). v8 runs on node24, which every CI-runner image
|
||||
@@ -798,9 +796,7 @@ jobs:
|
||||
path: client/
|
||||
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
# All three pulled from android-release's outputs so the sidecar the
|
||||
@@ -821,65 +817,6 @@ jobs:
|
||||
cat client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# the sidecar published beside it, so what the server reports is what
|
||||
# that build actually recorded rather than something re-derived here.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release or APK asset can be resolved. That
|
||||
# degradation only actually works because the greps below carry
|
||||
# `|| true`; under the runner's default pipefail a non-matching grep
|
||||
# kills the step instead of falling through to the empty-case branch.
|
||||
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
# `|| true` on every one of these, and it is load-bearing rather
|
||||
# than defensive habit. The runner already invokes this shell as
|
||||
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
||||
# exits non-zero even though `head` succeeded — and the step dies at
|
||||
# the assignment, before ever reaching the `if` written to handle the
|
||||
# empty case. Every "degrades gracefully" branch below is unreachable
|
||||
# without this.
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
|
||||
# Take the version the release RECORDED rather than recomputing it.
|
||||
# Re-deriving it from the tagged commit would keep the formula in two
|
||||
# files that must stay in step, and could only ever recover the name —
|
||||
# the ordering key is build-time minutes and exists nowhere 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
|
||||
echo "::notice::bundled release APK from ${TAG}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||
|
||||
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
@@ -19,10 +20,10 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
|
||||
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
|
||||
import com.fabledsword.minstrel.player.AudioPrefetcher
|
||||
import com.fabledsword.minstrel.player.CoverPrefetcher
|
||||
import com.fabledsword.minstrel.player.LikedAudioPinner
|
||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -120,6 +121,21 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — CacheBudgetSync's init block
|
||||
* feeds the audio cache's evictor the liked set and the Storage caps.
|
||||
* Without this @Inject the evictor would never arm, and the cache
|
||||
* would grow without a limit.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — LikedAudioPinner's init block
|
||||
* downloads liked tracks into the Liked songs budget. Without this
|
||||
* @Inject, liking a track would not keep it offline.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — AudioPrefetcher's init
|
||||
* block subscribes to PlayerController.uiState +
|
||||
@@ -138,14 +154,6 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
* /api/client/version at launch + every 24h and drives the shell's
|
||||
* soft "update available" banner. Without this @Inject the poll
|
||||
* loop would never start and the banner would never surface.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — FreshnessSweeper's init
|
||||
* block starts a 30-min sweep loop that re-fetches cached
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
import java.util.TreeSet
|
||||
|
||||
/** Which budget a cached track's bytes count against. */
|
||||
enum class CacheBucket { LIKED, RECENT }
|
||||
|
||||
/**
|
||||
* The bookkeeping behind [TwoBucketCacheEvictor]: which budget each cached
|
||||
* span counts against, how full each budget is, and which span to drop
|
||||
* next. It knows nothing of Media3, so it is tested directly.
|
||||
*
|
||||
* A span belongs to [CacheBucket.LIKED] when its key (the track id) is in
|
||||
* the liked set, and to [CacheBucket.RECENT] otherwise. A track that is
|
||||
* liked and also recently played is therefore charged to Liked only; the
|
||||
* two budgets never count the same bytes. Each bucket drops its own
|
||||
* least-recently-used span first, so filling one never evicts the other.
|
||||
*
|
||||
* Nothing is evicted until [setLiked] has been called once. Before then
|
||||
* every span would read as Recently played, and trimming at the Recently
|
||||
* played cap would throw away liked audio at startup.
|
||||
*
|
||||
* Not thread-safe; the evictor serialises every call.
|
||||
*/
|
||||
class BucketLedger<S : Any>(
|
||||
private val keyOf: (S) -> String,
|
||||
private val lengthOf: (S) -> Long,
|
||||
lruOrder: Comparator<S>,
|
||||
) {
|
||||
private val spans = mapOf(
|
||||
CacheBucket.LIKED to TreeSet(lruOrder),
|
||||
CacheBucket.RECENT to TreeSet(lruOrder),
|
||||
)
|
||||
private val bytes = mutableMapOf(CacheBucket.LIKED to 0L, CacheBucket.RECENT to 0L)
|
||||
private val spansByKey = HashMap<String, MutableSet<S>>()
|
||||
private val bucketByKey = HashMap<String, CacheBucket>()
|
||||
private var liked: Set<String> = emptySet()
|
||||
private var armed = false
|
||||
|
||||
/** Cap on Liked bytes; 0 means unlimited. */
|
||||
var likedCapBytes: Long = 0L
|
||||
|
||||
/** Cap on Recently played bytes; 0 means unlimited. */
|
||||
var recentCapBytes: Long = 0L
|
||||
|
||||
fun bucketOf(key: String): CacheBucket =
|
||||
if (key in liked) CacheBucket.LIKED else CacheBucket.RECENT
|
||||
|
||||
fun bytesIn(bucket: CacheBucket): Long = bytes.getValue(bucket)
|
||||
|
||||
/** Bytes the Liked budget can still take; [Long.MAX_VALUE] when unlimited. */
|
||||
fun likedRoomBytes(): Long = when {
|
||||
likedCapBytes <= 0L -> Long.MAX_VALUE
|
||||
else -> (likedCapBytes - bytesIn(CacheBucket.LIKED)).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
fun add(span: S) {
|
||||
val key = keyOf(span)
|
||||
val bucket = bucketByKey[key] ?: bucketOf(key).also { bucketByKey[key] = it }
|
||||
if (spans.getValue(bucket).add(span)) {
|
||||
bytes[bucket] = bytesIn(bucket) + lengthOf(span)
|
||||
spansByKey.getOrPut(key) { HashSet() }.add(span)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(span: S) {
|
||||
val key = keyOf(span)
|
||||
val bucket = bucketByKey[key] ?: return
|
||||
if (spans.getValue(bucket).remove(span)) {
|
||||
bytes[bucket] = bytesIn(bucket) - lengthOf(span)
|
||||
}
|
||||
val forKey = spansByKey[key] ?: return
|
||||
forKey.remove(span)
|
||||
if (forKey.isEmpty()) {
|
||||
spansByKey.remove(key)
|
||||
bucketByKey.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the liked set and move every cached track whose bucket
|
||||
* changed: a newly liked track's bytes leave Recently played for Liked,
|
||||
* an unliked track's go the other way. Arms eviction.
|
||||
*/
|
||||
fun setLiked(ids: Set<String>) {
|
||||
liked = ids
|
||||
armed = true
|
||||
val moves = bucketByKey.filter { (key, bucket) -> bucketOf(key) != bucket }.keys.toList()
|
||||
for (key in moves) {
|
||||
val from = bucketByKey.getValue(key)
|
||||
val to = bucketOf(key)
|
||||
for (span in spansByKey[key].orEmpty()) {
|
||||
if (spans.getValue(from).remove(span)) {
|
||||
bytes[from] = bytesIn(from) - lengthOf(span)
|
||||
spans.getValue(to).add(span)
|
||||
bytes[to] = bytesIn(to) + lengthOf(span)
|
||||
}
|
||||
}
|
||||
bucketByKey[key] = to
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The span to drop next, or null when both budgets fit. [pendingKey] and
|
||||
* [pendingBytes] describe a write about to start, so room is made for it
|
||||
* in its own bucket first. Recently played is trimmed before Liked.
|
||||
*/
|
||||
fun nextEviction(pendingKey: String? = null, pendingBytes: Long = 0L): S? {
|
||||
if (!armed) return null
|
||||
val pendingBucket = pendingKey?.let(::bucketOf)
|
||||
return listOf(CacheBucket.RECENT, CacheBucket.LIKED).firstNotNullOfOrNull { bucket ->
|
||||
val incoming = if (bucket == pendingBucket) pendingBytes.coerceAtLeast(0L) else 0L
|
||||
oldestIfOver(bucket, incoming)
|
||||
}
|
||||
}
|
||||
|
||||
private fun oldestIfOver(bucket: CacheBucket, incoming: Long): S? {
|
||||
val cap = if (bucket == CacheBucket.LIKED) likedCapBytes else recentCapBytes
|
||||
val over = cap > 0L && bytesIn(bucket) + incoming > cap
|
||||
return if (over) spans.getValue(bucket).firstOrNull() else null
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Keeps [TwoBucketCacheEvictor] in step with the signed-in user's liked
|
||||
* tracks and the Storage caps. A like moves that track's cached audio into
|
||||
* the Liked budget, an unlike moves it out, and a cap change trims at once.
|
||||
*
|
||||
* The liked set only flows once a user is signed in, and the evictor drops
|
||||
* nothing until it has one, so liked audio is never trimmed as Recently
|
||||
* played while the session is still loading.
|
||||
*
|
||||
* Eager-constructed via the construct-the-singleton trick in
|
||||
* `MinstrelApplication`.
|
||||
*/
|
||||
@Singleton
|
||||
class CacheBudgetSync @Inject constructor(
|
||||
likes: LikesRepository,
|
||||
authStore: AuthStore,
|
||||
evictor: TwoBucketCacheEvictor,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) {
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
combine(likes.observeSignedInLikedTrackIds(), authStore.cacheSettings, ::Pair)
|
||||
.collect { (ids, settings) ->
|
||||
evictor.update(ids, settings.likedCapBytes, settings.rollingCapBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
/**
|
||||
* Defaults for the audio cache; the user's Storage settings override them.
|
||||
*
|
||||
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
|
||||
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
|
||||
* cap alone.
|
||||
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
|
||||
* evicted least-recently-used first.
|
||||
*
|
||||
* The values are generous so first-time installs work well without
|
||||
* configuration.
|
||||
*/
|
||||
data class CacheConfig(
|
||||
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
|
||||
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
|
||||
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -9,17 +9,17 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
|
||||
* User-tunable audio cache settings. Persisted as a JSON
|
||||
* blob on the auth_session single-row table via [AuthStore].
|
||||
*
|
||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
||||
* unlimited. Nothing enforces it yet: SimpleCache evicts by
|
||||
* [rollingCapBytes] alone, and the Storage card hides this control.
|
||||
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
|
||||
* 0 means unlimited. Effective at next app start (SimpleCache is
|
||||
* built once per process).
|
||||
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
|
||||
* liked tracks. 0 means unlimited.
|
||||
* - [rollingCapBytes]: the Recently played budget, for everything else
|
||||
* streamed or prefetched. 0 means unlimited.
|
||||
* A track that is liked and also recently played counts against Liked
|
||||
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
|
||||
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
||||
* pre-downloads.
|
||||
* - [cacheLikedTracks]: when true, liking a track should also pin its
|
||||
* audio to the liked-cache bucket. Nothing reads it yet, and the
|
||||
* Storage card hides this control.
|
||||
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
|
||||
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
|
||||
* Wi-Fi, most recently liked first, while the budget has room.
|
||||
*
|
||||
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
|
||||
*/
|
||||
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.media3.datasource.cache.CacheEvictor
|
||||
import androidx.media3.datasource.cache.CacheSpan
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Bytes held against each audio-cache budget, and how much more the Liked
|
||||
* budget can take ([Long.MAX_VALUE] when it is unlimited).
|
||||
*/
|
||||
data class CacheUsage(val likedBytes: Long, val recentBytes: Long, val likedRoomBytes: Long)
|
||||
|
||||
/**
|
||||
* The audio cache's eviction policy: two budgets, Liked songs and Recently
|
||||
* played, each with its own cap and its own least-recently-used order (see
|
||||
* [BucketLedger]). SimpleCache takes exactly one evictor, so this one
|
||||
* carries both budgets.
|
||||
*
|
||||
* [CacheBudgetSync] feeds it the signed-in user's liked set and the caps
|
||||
* from Storage settings, so a like, an unlike or a cap change applies at
|
||||
* once, without restarting the app.
|
||||
*
|
||||
* Locking: SimpleCache calls every [CacheEvictor] method while holding its
|
||||
* own monitor. Outside calls take the cache's monitor first and the
|
||||
* ledger's second, the same order as those callbacks, so the two never
|
||||
* deadlock.
|
||||
*/
|
||||
@Singleton
|
||||
class TwoBucketCacheEvictor @Inject constructor() : CacheEvictor {
|
||||
private val ledger = BucketLedger<CacheSpan>(
|
||||
keyOf = { it.key },
|
||||
lengthOf = { it.length },
|
||||
lruOrder = compareBy<CacheSpan> { it.lastTouchTimestamp }.then(naturalOrder()),
|
||||
)
|
||||
|
||||
@Volatile private var cache: Cache? = null
|
||||
|
||||
/** Called once the SimpleCache exists, so updates can trim it. */
|
||||
fun attach(cache: Cache) {
|
||||
this.cache = cache
|
||||
}
|
||||
|
||||
/** Apply a new liked set and caps, then trim whatever no longer fits. */
|
||||
fun update(likedIds: Set<String>, likedCapBytes: Long, recentCapBytes: Long) {
|
||||
val c = cache
|
||||
val apply = {
|
||||
ledger.likedCapBytes = likedCapBytes
|
||||
ledger.recentCapBytes = recentCapBytes
|
||||
ledger.setLiked(likedIds)
|
||||
}
|
||||
if (c == null) {
|
||||
synchronized(ledger) { apply() }
|
||||
} else {
|
||||
synchronized(c) {
|
||||
synchronized(ledger) {
|
||||
apply()
|
||||
evict(c, pendingKey = null, pendingBytes = 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun usage(): CacheUsage = synchronized(ledger) {
|
||||
CacheUsage(
|
||||
likedBytes = ledger.bytesIn(CacheBucket.LIKED),
|
||||
recentBytes = ledger.bytesIn(CacheBucket.RECENT),
|
||||
likedRoomBytes = ledger.likedRoomBytes(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun requiresCacheSpanTouches(): Boolean = true
|
||||
|
||||
override fun onCacheInitialized() = Unit
|
||||
|
||||
override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) {
|
||||
synchronized(ledger) {
|
||||
evict(cache, key, if (length == C.LENGTH_UNSET.toLong()) 0L else length)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
|
||||
synchronized(ledger) {
|
||||
ledger.add(span)
|
||||
evict(cache, pendingKey = null, pendingBytes = 0L)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
|
||||
synchronized(ledger) { ledger.remove(span) }
|
||||
}
|
||||
|
||||
override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {
|
||||
synchronized(ledger) {
|
||||
ledger.remove(oldSpan)
|
||||
ledger.add(newSpan)
|
||||
evict(cache, pendingKey = null, pendingBytes = 0L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun evict(cache: Cache, pendingKey: String?, pendingBytes: Long) {
|
||||
while (true) {
|
||||
val span = ledger.nextEviction(pendingKey, pendingBytes) ?: return
|
||||
cache.removeSpan(span)
|
||||
// removeSpan reports back through onSpanRemoved; removing here as
|
||||
// well guarantees the loop advances even if it did not.
|
||||
ledger.remove(span)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-12
@@ -4,18 +4,10 @@ import androidx.room.TypeConverter
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Source category for a cached audio file in `audio_cache_index`.
|
||||
*
|
||||
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
|
||||
* - `AUTO_LIKED`: auto-cached because the track is liked
|
||||
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
|
||||
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
|
||||
* - `INCIDENTAL`: cached as a side effect of playback streaming
|
||||
*
|
||||
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
|
||||
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
|
||||
* today, and SimpleCache evicts by plain LRU, so nothing applies the
|
||||
* order yet.
|
||||
* How a row reached `audio_cache_index`. `CacheIndexer` writes
|
||||
* `INCIDENTAL` (the track was played); the other values are stored names
|
||||
* no writer uses. Eviction does not read this: the audio cache's two
|
||||
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
|
||||
*/
|
||||
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
|
||||
|
||||
|
||||
-18
@@ -4,7 +4,6 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.fabledsword.minstrel.cache.db.CacheSource
|
||||
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
|
||||
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
|
||||
suspend fun trackIdsByRecency(): List<String>
|
||||
|
||||
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
|
||||
@Query(
|
||||
"SELECT * FROM audio_cache_index " +
|
||||
"WHERE source = :source " +
|
||||
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
|
||||
)
|
||||
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
|
||||
|
||||
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
|
||||
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
|
||||
suspend fun totalBytes(): Long
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
|
||||
)
|
||||
suspend fun bytesBySource(source: CacheSource): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(row: AudioCacheIndexEntity)
|
||||
|
||||
|
||||
+7
-3
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CachedLikeDao {
|
||||
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
|
||||
/**
|
||||
* The user's liked track IDs, most recently liked first. The audio
|
||||
* cache's Liked budget and the liked-audio downloads read this.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT entityId FROM cached_likes " +
|
||||
"WHERE userId = :userId AND entityType = 'track'",
|
||||
"WHERE userId = :userId AND entityType = 'track' " +
|
||||
"ORDER BY likedAt DESC",
|
||||
)
|
||||
fun observeLikedTrackIds(userId: String): Flow<List<String>>
|
||||
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"SELECT entityId FROM cached_likes " +
|
||||
|
||||
Vendored
+5
-7
@@ -10,13 +10,11 @@ import kotlinx.datetime.Instant
|
||||
* One row per track that has played through the audio cache (see
|
||||
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
|
||||
*
|
||||
* The `source` column is meant to drive a 2-bucket eviction
|
||||
* (incidental first, then auto-prefetch, manual last). Nothing evicts
|
||||
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
|
||||
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
|
||||
* played it; drives the offline "recently played" ordering and is the
|
||||
* tiebreaker for LRU eviction within a source bucket. Nullable on
|
||||
* brand-new rows until the first post-cache play touches them.
|
||||
* `source` says how the row arrived (see [CacheSource]); eviction does
|
||||
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
|
||||
* when the user last played the track, which orders the offline
|
||||
* "recently played" pool. Nullable on brand-new rows until the first
|
||||
* post-cache play touches them.
|
||||
*/
|
||||
@Entity(tableName = "audio_cache_index")
|
||||
data class AudioCacheIndexEntity(
|
||||
|
||||
+29
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Whether the network the device is using now is unmetered: Wi-Fi or
|
||||
* Ethernet, but not mobile data or a hotspot Android marks metered.
|
||||
* Downloads the user did not start (the liked-song downloads) wait for
|
||||
* this.
|
||||
*/
|
||||
val unmetered: Flow<Boolean> = callbackFlow {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(false)
|
||||
}
|
||||
}
|
||||
cm.registerDefaultNetworkCallback(callback)
|
||||
trySend(isActiveNetworkUnmetered())
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun isActiveNetworkUnmetered(): Boolean {
|
||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||
return caps != null &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
private fun hasActiveNetwork(): Boolean {
|
||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||
return caps != null &&
|
||||
|
||||
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||
import com.fabledsword.minstrel.update.ui.UpdateNotices
|
||||
import com.fabledsword.minstrel.update.ui.UpdateViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor(
|
||||
fun HomeScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
updates: UpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -575,7 +578,11 @@ fun HomeScreen(
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refreshFromPull() },
|
||||
onRefresh = {
|
||||
// A pull asks about a newer app too, so one shows up at once.
|
||||
updates.check()
|
||||
viewModel.refreshFromPull()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -590,6 +597,7 @@ fun HomeScreen(
|
||||
// hide behind an "Updating your mixes…" wipe that stays up
|
||||
// until the screen has actually stopped moving.
|
||||
UpdatingVeil(visible = updating)
|
||||
UpdateNotices(updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,20 @@ import com.fabledsword.minstrel.models.AlbumRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Cache-first reads + offline-first writes for likes.
|
||||
@@ -117,6 +123,24 @@ class LikesRepository @Inject constructor(
|
||||
fun observeLikedTrackIds(): Flow<Set<String>> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
||||
|
||||
/**
|
||||
* The liked track ids of whoever is signed in, most recently liked
|
||||
* first, following sign-in and account changes. Emits nothing while
|
||||
* signed out, so a long-lived collector never mistakes the anonymous
|
||||
* empty set for "no likes".
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun observeSignedInLikedTrackIdsNewestFirst(): Flow<List<String>> =
|
||||
authController.currentUser
|
||||
.map { it?.id }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.flatMapLatest { userId -> likeDao.observeLikedTrackIdsNewestFirst(userId) }
|
||||
|
||||
/** [observeSignedInLikedTrackIdsNewestFirst] as a set. */
|
||||
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
|
||||
observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() }
|
||||
|
||||
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||
suspend fun likedTrackIds(): Set<String> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||
@@ -166,14 +190,21 @@ class LikesRepository @Inject constructor(
|
||||
* device would stay on the Liked tab forever. The replaceAllForUser
|
||||
* DAO method runs a transactional delete-then-insert so the local
|
||||
* set is exactly what the server reports.
|
||||
*
|
||||
* The server lists each kind most recently liked first but sends no
|
||||
* times, so the rows are stamped a millisecond apart in that order;
|
||||
* `likedAt` then sorts the way the server does.
|
||||
*/
|
||||
suspend fun refreshIds() {
|
||||
val uid = currentUserId()
|
||||
val wire = api.ids()
|
||||
val now = Clock.System.now()
|
||||
fun rowsOf(type: String, ids: List<String>) =
|
||||
ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) }
|
||||
val rows = mutableListOf<CachedLikeEntity>()
|
||||
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
|
||||
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
|
||||
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
|
||||
rows += rowsOf(ENTITY_ARTIST, wire.artistIds)
|
||||
rows += rowsOf(ENTITY_ALBUM, wire.albumIds)
|
||||
rows += rowsOf(ENTITY_TRACK, wire.trackIds)
|
||||
likeDao.replaceAllForUser(uid, rows)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models
|
||||
* verbatim rather than validated, so an unexpected value is shown rather
|
||||
* than dropped.
|
||||
*
|
||||
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
|
||||
* [apkUrl] is server-relative (e.g. `/api/client/apk`). [sha256] is the
|
||||
* served file's digest, in hex; empty from a server that predates it, and
|
||||
* then a download is judged whole by its size alone.
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
@@ -23,4 +25,5 @@ data class UpdateInfo(
|
||||
val channel: String?,
|
||||
val apkUrl: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
)
|
||||
|
||||
@@ -26,4 +26,5 @@ data class UpdateInfoWire(
|
||||
val channel: String? = null,
|
||||
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
|
||||
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||
val sha256: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.CacheWriter
|
||||
import androidx.media3.datasource.cache.ContentMetadata
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
// The Liked budget must have room for a track at CD bitrate (1411 kbps)
|
||||
// before one is downloaded, so a download never pushes out liked audio that
|
||||
// is already on the phone. A track of unknown length is assumed to be ten
|
||||
// minutes.
|
||||
private const val CD_BYTES_PER_SECOND = 176_400L
|
||||
private const val UNKNOWN_DURATION_SEC = 600
|
||||
|
||||
/**
|
||||
* Keeps liked songs on the phone: when "Keep liked songs offline" is on,
|
||||
* every liked track's whole audio is downloaded into the cache, where
|
||||
* [TwoBucketCacheEvictor] counts it against the Liked songs budget.
|
||||
*
|
||||
* - Most recently liked first, one track at a time, so the newest likes get
|
||||
* the room and the server is never asked for more than one file at once.
|
||||
* - Only on an unmetered network (Wi-Fi or Ethernet) while the server is
|
||||
* reachable. A like made on mobile data downloads once the phone is back
|
||||
* on Wi-Fi.
|
||||
* - Stops when the Liked budget cannot fit another track. Liked tracks
|
||||
* played after that are still cached as they stream.
|
||||
* - Any change (a like, an unlike, the switch, the network) restarts the
|
||||
* pass, cancelling a download in flight. Tracks already fully on the
|
||||
* phone are skipped without touching the network, and a track whose
|
||||
* download failed is tried again on the next pass.
|
||||
*
|
||||
* Downloads are keyed by track id exactly like playback and
|
||||
* [AudioPrefetcher], so the player reads what this writes.
|
||||
*
|
||||
* Eager-constructed via the construct-the-singleton trick in
|
||||
* `MinstrelApplication`.
|
||||
*/
|
||||
@Singleton
|
||||
class LikedAudioPinner @Inject constructor(
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
likes: LikesRepository,
|
||||
authStore: AuthStore,
|
||||
connectivity: ConnectivityObserver,
|
||||
serverHealth: NetworkStatusController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val evictor: TwoBucketCacheEvictor,
|
||||
private val trackDao: CachedTrackDao,
|
||||
okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val cacheDataSourceFactory: CacheDataSource.Factory =
|
||||
CacheDataSource.Factory()
|
||||
.setCache(playerFactory.simpleCache)
|
||||
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
|
||||
private data class Pass(val likedNewestFirst: List<String>, val enabled: Boolean, val canDownload: Boolean)
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
combine(
|
||||
likes.observeSignedInLikedTrackIdsNewestFirst(),
|
||||
authStore.cacheSettings.map { it.cacheLikedTracks },
|
||||
connectivity.unmetered,
|
||||
serverHealth.state.map { it == ServerHealth.Healthy || it == ServerHealth.Unstable },
|
||||
) { liked, enabled, unmetered, reachable ->
|
||||
Pass(liked, enabled, unmetered && reachable)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { pass ->
|
||||
if (pass.enabled && pass.canDownload) pinAll(pass.likedNewestFirst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pinAll(likedNewestFirst: List<String>) {
|
||||
for (trackId in likedNewestFirst) {
|
||||
val track = downloadable(trackId)
|
||||
if (track != null) {
|
||||
val durationSec = track.durationSec.takeIf { it > 0 } ?: UNKNOWN_DURATION_SEC
|
||||
if (evictor.usage().likedRoomBytes < durationSec * CD_BYTES_PER_SECOND) return
|
||||
runCatching { runInterruptible { download(track.id, track.streamUrl) } }
|
||||
.onFailure { Timber.d(it, "liked audio: download of %s failed; next pass retries", track.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The track, when it still needs downloading and the server can serve it. */
|
||||
private suspend fun downloadable(trackId: String): TrackRef? {
|
||||
if (isFullyCached(trackId)) return null
|
||||
val track = trackDao.getById(trackId)?.toDomain()
|
||||
return track?.takeIf { !it.unavailable && it.streamUrl.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun isFullyCached(trackId: String): Boolean {
|
||||
val cache = playerFactory.simpleCache
|
||||
val length = ContentMetadata.getContentLength(cache.getContentMetadata(trackId))
|
||||
return length != C.LENGTH_UNSET.toLong() && cache.isCached(trackId, 0, length)
|
||||
}
|
||||
|
||||
private fun download(trackId: String, streamUrl: String) {
|
||||
val dataSpec = DataSpec.Builder()
|
||||
.setUri(Uri.parse(resolveServerUrl(streamUrl) ?: streamUrl))
|
||||
.setKey(trackId)
|
||||
.build()
|
||||
CacheWriter(cacheDataSourceFactory.createDataSource(), dataSpec, null, null).cache()
|
||||
}
|
||||
}
|
||||
+184
-59
@@ -14,6 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.QueueMode
|
||||
import com.fabledsword.minstrel.player.output.SingleTrackLoader
|
||||
import com.fabledsword.minstrel.player.output.streamUriTrackId
|
||||
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
|
||||
@@ -57,14 +60,21 @@ import timber.log.Timber
|
||||
* wraps that callback into a SharedFlow consumed by the NowPlaying
|
||||
* surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||
* track the renderer is actually playing, matched by track identity
|
||||
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||
* index drift -- see [syncLocalCursorToRemote].
|
||||
* Queue mode ([ActiveUpnp.queueMode]):
|
||||
* - NATIVE_QUEUE (Sonos): OutputPickerController loads the full queue
|
||||
* into Sonos's native queue via ClearQueue + AddURIToQueue, then points
|
||||
* the transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively.
|
||||
* - SINGLE_TRACK (any other UPnP / DLNA renderer): the renderer holds one
|
||||
* track. Skip/prev/seekTo point it at the track the local cursor moved
|
||||
* to, and each poll lets [SingleTrackLoader] follow or drive the advance
|
||||
* to the next track and supply the queue position the renderer cannot.
|
||||
*
|
||||
* PollLoop keeps the local cursor aligned to the track the renderer is
|
||||
* actually playing, matched by track identity (the id in
|
||||
* GetPositionInfo's TrackURI) so it survives queue-reload index drift --
|
||||
* see [syncLocalCursorToRemote].
|
||||
*/
|
||||
class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
@@ -72,6 +82,7 @@ class MinstrelForwardingPlayer(
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val singleTrack: SingleTrackLoader,
|
||||
private val events: RemoteEvents = RemoteEvents(),
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -205,24 +216,56 @@ class MinstrelForwardingPlayer(
|
||||
// drops via isLoadingUpnp() = true; the OutputPickerController
|
||||
// observes the uiState.queue change and runs the resync (re-clears
|
||||
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
||||
//
|
||||
// The SAME tracks set again (play on the album already queued, from
|
||||
// another track) is not a new queue: the renderer already holds it, and
|
||||
// the uiState collector -- which keys on the track ids -- would never see
|
||||
// a change to resync on, leaving the cast stuck "loading". It is a move
|
||||
// within the queue, so the renderer is pointed at the new track and the
|
||||
// play() that follows goes through.
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
||||
val sameQueue = holdsSameItems(mediaItems)
|
||||
super.setMediaItems(mediaItems)
|
||||
markPendingResyncIfRemote()
|
||||
afterQueueSet(sameQueue)
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
||||
val sameQueue = holdsSameItems(mediaItems)
|
||||
super.setMediaItems(mediaItems, resetPosition)
|
||||
markPendingResyncIfRemote()
|
||||
afterQueueSet(sameQueue)
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
||||
val sameQueue = holdsSameItems(mediaItems)
|
||||
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||
markPendingResyncIfRemote()
|
||||
afterQueueSet(sameQueue)
|
||||
}
|
||||
|
||||
private fun markPendingResyncIfRemote() {
|
||||
val wasActive = holder.active.value ?: return
|
||||
private fun holdsSameItems(mediaItems: List<MediaItem>): Boolean =
|
||||
mediaItems.size == delegate.mediaItemCount &&
|
||||
mediaItems.indices.all { mediaItems[it].mediaId == delegate.getMediaItemAt(it).mediaId }
|
||||
|
||||
private fun afterQueueSet(sameQueue: Boolean) {
|
||||
val active = holder.active.value ?: return
|
||||
if (!sameQueue) {
|
||||
markPendingResync(active)
|
||||
return
|
||||
}
|
||||
val index = delegate.currentMediaItemIndex
|
||||
val positionMs = delegate.currentPosition.coerceAtLeast(0L)
|
||||
Timber.w(
|
||||
"setMediaItems with the queue %s already holds -- moving to track %d",
|
||||
active.routeName,
|
||||
index + 1,
|
||||
)
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> startSingleTrack(active, index, positionMs)
|
||||
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, index, positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markPendingResync(wasActive: ActiveUpnp) {
|
||||
Timber.w(
|
||||
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
||||
wasActive.routeName,
|
||||
@@ -319,18 +362,18 @@ class MinstrelForwardingPlayer(
|
||||
return
|
||||
}
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
retryTransport {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}.onFailure { handleTransportFailure(active, it) }
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
|
||||
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, mediaItemIndex, positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun seekNativeQueue(active: ActiveUpnp, index: Int, positionMs: Long) {
|
||||
sendNativeQueueCommand(active) {
|
||||
active.avTransport.seekToTrack(index + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,12 +392,9 @@ class MinstrelForwardingPlayer(
|
||||
// then delegate to Sonos Next. PollLoop reconciles cursor via
|
||||
// Track index if they diverge.
|
||||
super.seekToNextMediaItem()
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
|
||||
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.next() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,11 +412,56 @@ class MinstrelForwardingPlayer(
|
||||
// Super first for immediate local cursor advance (UI feedback);
|
||||
// then delegate to Sonos Previous. PollLoop reconciles.
|
||||
super.seekToPreviousMediaItem()
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
|
||||
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.previous() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a track change to a Sonos native queue. The local cursor has
|
||||
* already moved; the pending window holds the poll's cursor sync off
|
||||
* until Sonos reports the new track, so it can't undo the press.
|
||||
*/
|
||||
private fun sendNativeQueueCommand(active: ActiveUpnp, command: suspend () -> Unit) {
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { active.avTransport.previous() } }
|
||||
runCatching { retryTransport { command() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a one-track renderer at [index] of the queue, the track the local
|
||||
* cursor was just moved to. The same track as the renderer's is a seek
|
||||
* within it, so next on the last track (the cursor cannot move) sends
|
||||
* nothing rather than restarting the track. The seek after a new track is
|
||||
* best-effort: a renderer still loading the URI may refuse it, and the
|
||||
* track playing from the start beats it not playing.
|
||||
*/
|
||||
private fun playSingleTrackAt(active: ActiveUpnp, index: Int, positionMs: Long = 0L) {
|
||||
if (index == singleTrack.currentIndex) {
|
||||
if (positionMs > 0L) seekTo(positionMs)
|
||||
} else {
|
||||
startSingleTrack(active, index, positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSingleTrack(active: ActiveUpnp, index: Int, positionMs: Long) {
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { retryTransport { singleTrack.playIndex(active.avTransport, index) } }
|
||||
.onSuccess {
|
||||
if (positionMs > 0L) {
|
||||
runCatching { active.avTransport.seek(positionMs) }
|
||||
.onFailure { Timber.w(it, "UPnP seek after track change refused") }
|
||||
}
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
@@ -611,6 +696,14 @@ class MinstrelForwardingPlayer(
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
val info = active.avTransport.getPositionInfo()
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
val single = pollSingleTrack(active, info, transport)
|
||||
// The loader just started the next track, so this reading describes
|
||||
// the one that ended. Report the new track from its start and leave
|
||||
// the cursor to the new index; the next poll reads it for real.
|
||||
val advanced = single?.advanced == true
|
||||
val rendererTrack = single?.trackNumber ?: info.track
|
||||
val trackUri = if (advanced) "" else info.trackUri
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
|
||||
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
|
||||
@@ -618,15 +711,52 @@ class MinstrelForwardingPlayer(
|
||||
// seekTo -- the poll's reported position is stale until Sonos finishes
|
||||
// processing the Seek SOAP. Other fields still refresh from the poll.
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
|
||||
durationMs = info.trackDurationMs,
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
positionMs = when {
|
||||
advanced -> 0L
|
||||
inSeekAckWindow -> remoteState.positionMs
|
||||
else -> info.relTimeMs
|
||||
},
|
||||
durationMs = durationFor(info, single),
|
||||
trackUri = trackUri,
|
||||
trackNumber = rendererTrack,
|
||||
)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
events.onRendererTrack(info.track)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
syncLocalCursorToRemote(sonosTrack = rendererTrack, trackUri = trackUri)
|
||||
if (single == null) events.onRendererTrack(info.track)
|
||||
if (advanced) {
|
||||
applyTransportState(TransportState.PLAYING)
|
||||
} else {
|
||||
applyTransportState(transport.state)
|
||||
observeTransport(transport, info)
|
||||
checkForStall(active, info.trackUri, transport)
|
||||
}
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-track renderer reports Track=1 whatever it plays; the loader
|
||||
* knows the queue position, and may start the next track from here.
|
||||
* Null for a Sonos, whose native queue answers for itself.
|
||||
*/
|
||||
private suspend fun pollSingleTrack(
|
||||
active: ActiveUpnp,
|
||||
info: PositionInfo,
|
||||
transport: TransportInfo,
|
||||
): SingleTrackLoader.PollOutcome? =
|
||||
if (active.queueMode == QueueMode.SINGLE_TRACK) {
|
||||
singleTrack.onPoll(
|
||||
transport = active.avTransport,
|
||||
trackUri = info.trackUri,
|
||||
state = transport.state,
|
||||
positionMs = info.relTimeMs,
|
||||
durationMs = info.trackDurationMs,
|
||||
playIntent = remoteState.lastPlayIntent,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun applyTransportState(state: TransportState) {
|
||||
when (state) {
|
||||
TransportState.PLAYING -> {
|
||||
nonPlayingPollStreak = 0
|
||||
remoteState.applyTransportPlaying()
|
||||
@@ -643,13 +773,18 @@ class MinstrelForwardingPlayer(
|
||||
remoteState.applyTransportStopped()
|
||||
}
|
||||
}
|
||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
||||
TransportState.TRANSITIONING, TransportState.NO_MEDIA, TransportState.UNKNOWN -> Unit
|
||||
}
|
||||
observeTransport(transport, info)
|
||||
checkForStall(active, info.trackUri, transport)
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* The track's duration as the renderer reports it. A one-track renderer
|
||||
* that reports none (some leave TrackDuration at 0:00:00) falls back to
|
||||
* the library's duration, so the scrubber has an end to run to.
|
||||
*/
|
||||
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
|
||||
if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs
|
||||
|
||||
/**
|
||||
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||
*
|
||||
@@ -711,7 +846,7 @@ class MinstrelForwardingPlayer(
|
||||
)
|
||||
// Tell the user and the admin inbox. Silence here would be the
|
||||
// original bug: playback simply ends and nobody finds out.
|
||||
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
|
||||
streamUriTrackId(trackUri)?.let { handler.post { events.onStalled(it) } }
|
||||
}
|
||||
RemoteStallWatchdog.Decision.None -> Unit
|
||||
}
|
||||
@@ -763,6 +898,9 @@ class MinstrelForwardingPlayer(
|
||||
): RemoteStallWatchdog.QueueState {
|
||||
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
|
||||
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
|
||||
// A one-track renderer has no queue length to ask for; the loader
|
||||
// knows whether it just played out the queue's last track.
|
||||
if (active.queueMode == QueueMode.SINGLE_TRACK) return singleTrack.queueState()
|
||||
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
|
||||
@@ -834,7 +972,7 @@ class MinstrelForwardingPlayer(
|
||||
* Must run on the application looper -- reads the delegate timeline.
|
||||
*/
|
||||
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||
val byId = streamUriTrackId(trackUri)?.let { id ->
|
||||
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||
}
|
||||
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||
@@ -860,20 +998,7 @@ class MinstrelForwardingPlayer(
|
||||
return best
|
||||
}
|
||||
|
||||
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||
private fun trackIdFromStreamUri(uri: String): String? {
|
||||
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||
if (start < 0) return null
|
||||
val idStart = start + TRACKS_PATH_MARKER.length
|
||||
val idEnd = uri.indexOf('/', idStart)
|
||||
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Path segment that precedes the track-id in a stream URI
|
||||
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||
// back to a delegate MediaItem by identity.
|
||||
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
const val NON_PLAYING_CONFIRM = 2
|
||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.cache.CacheDataSink
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
@@ -20,10 +19,11 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
|
||||
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.SingleTrackLoader
|
||||
import com.fabledsword.minstrel.player.output.SonosQueueLoader
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -42,8 +42,8 @@ import javax.inject.Singleton
|
||||
*
|
||||
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
|
||||
* the cached-track ids, the offline pools, the prefetcher and the
|
||||
* Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the
|
||||
* rolling cap; there is no separate liked-track bucket yet.
|
||||
* Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked
|
||||
* songs and Recently played.
|
||||
*
|
||||
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
||||
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
|
||||
@@ -56,21 +56,22 @@ import javax.inject.Singleton
|
||||
class PlayerFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val cacheConfig: CacheConfig,
|
||||
private val cacheEvictor: TwoBucketCacheEvictor,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
private val authStore: AuthStore,
|
||||
private val replayGains: ReplayGainStore,
|
||||
private val sonosQueue: SonosQueueLoader,
|
||||
private val singleTrack: SingleTrackLoader,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
val simpleCache: SimpleCache = SimpleCache(
|
||||
cacheDir,
|
||||
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
|
||||
cacheEvictor,
|
||||
StandaloneDatabaseProvider(context),
|
||||
)
|
||||
).also(cacheEvictor::attach)
|
||||
|
||||
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
||||
// of drop events (rapid SOAP failures during a single tear-down) surfaces
|
||||
@@ -122,6 +123,7 @@ class PlayerFactory @Inject constructor(
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
singleTrack = singleTrack,
|
||||
events = MinstrelForwardingPlayer.RemoteEvents(
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't
|
||||
* need a @Provides here; this module provides the configuration values
|
||||
* (CacheConfig) it depends on, and any player-related singletons that
|
||||
* aren't @Inject-constructible.
|
||||
*
|
||||
* MinstrelPlayerService injects PlayerFactory and calls `build()` from
|
||||
* `onCreate`.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlayerModule {
|
||||
|
||||
/**
|
||||
* CacheConfig snapshot from the user's persisted CacheSettings.
|
||||
* SimpleCache is constructed once per process, so changes via the
|
||||
* Settings UI take effect on next app launch (not mid-flight).
|
||||
* AuthStore's StateFlow hydrates async from Room — when the
|
||||
* factory @Provides runs, the value is either the persisted
|
||||
* setting (if Room finished first) or CacheSettings.DEFAULT.
|
||||
* Worst case the user's tuning lands on next-next launch, which
|
||||
* is acceptable for a single-user device pref.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCacheConfig(authStore: AuthStore): CacheConfig {
|
||||
val s = authStore.cacheSettings.value
|
||||
return CacheConfig(
|
||||
likedCapBytes = s.likedCapBytes,
|
||||
rollingCapBytes = s.rollingCapBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,31 @@ import javax.inject.Singleton
|
||||
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
|
||||
* from [OutputPickerController] -- the picker writes; the forwarding
|
||||
* player reads. Null = no UPnP active (local ExoPlayer path).
|
||||
*
|
||||
* [queueMode] says how the renderer is fed tracks, which decides what a
|
||||
* skip sends and where the poll reads the queue position from.
|
||||
*/
|
||||
data class ActiveUpnp(
|
||||
val routeId: String,
|
||||
val routeName: String,
|
||||
val avTransport: AVTransportClient,
|
||||
val rendering: RenderingControlClient?,
|
||||
val queueMode: QueueMode,
|
||||
)
|
||||
|
||||
enum class QueueMode {
|
||||
/** A Sonos holds the whole queue: [SonosQueueLoader]. */
|
||||
NATIVE_QUEUE,
|
||||
|
||||
/** Any other renderer holds one track at a time: [SingleTrackLoader]. */
|
||||
SINGLE_TRACK,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun forRoute(isSonos: Boolean): QueueMode = if (isSonos) NATIVE_QUEUE else SINGLE_TRACK
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ActiveUpnpHolder @Inject constructor() {
|
||||
|
||||
|
||||
+146
-50
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -58,10 +58,11 @@ data class RouteSnapshot(
|
||||
* Selection branches on [OutputRoute.Protocol]:
|
||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||
* wired, Bluetooth)
|
||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||
* [SonosQueueLoader], drive the discovered renderer with
|
||||
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
||||
* audio yields to the network speaker
|
||||
* - [OutputRoute.Protocol.UPNP] — pause local playback so audio yields
|
||||
* to the network speaker, then hand the renderer the queue: a Sonos
|
||||
* takes all of it through [SonosQueueLoader]; any other UPnP / DLNA
|
||||
* renderer takes one track at a time through [SingleTrackLoader]
|
||||
* (see [QueueMode])
|
||||
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||
* not implemented; selecting one does nothing.
|
||||
*/
|
||||
@@ -73,6 +74,7 @@ class OutputPickerController @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val sonosQueue: SonosQueueLoader,
|
||||
private val singleTrack: SingleTrackLoader,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val okHttp: OkHttpClient,
|
||||
@@ -330,6 +332,12 @@ class OutputPickerController @Inject constructor(
|
||||
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
|
||||
return@withLock
|
||||
}
|
||||
if (!upnpRoute.isSonos) {
|
||||
// Only a native queue can be truncated; a one-track renderer
|
||||
// never asks for this.
|
||||
Timber.w("Sonos queue repair skipped: %s holds no queue", upnpRoute.name)
|
||||
return@withLock
|
||||
}
|
||||
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
|
||||
Timber.w(
|
||||
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
|
||||
@@ -371,23 +379,31 @@ class OutputPickerController @Inject constructor(
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
return@withLock
|
||||
}
|
||||
val handledIncrementally = runCatching {
|
||||
if (!upnpRoute.isSonos) {
|
||||
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
|
||||
return@withLock
|
||||
}
|
||||
// The incremental diff is for a queue changed under a cast that kept
|
||||
// running (play next, an append, a removal): it leaves the playing
|
||||
// track alone. A queue REPLACED through setMediaItems has already
|
||||
// cleared active, and the listener asked for its start track and for
|
||||
// it to play -- both of which the diff would ignore, and with active
|
||||
// never restored the app would think it had gone back to the phone
|
||||
// while the speaker played on. That case reloads in full below.
|
||||
val stillActive = activeUpnpHolder.active.value?.routeId == routeId
|
||||
val handledIncrementally = stillActive && runCatching {
|
||||
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
|
||||
}.getOrElse { e ->
|
||||
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
|
||||
false
|
||||
}
|
||||
if (handledIncrementally) {
|
||||
// Active was never cleared on the incremental path; clear any
|
||||
// target that markPendingResyncIfRemote set (it didn't, for
|
||||
// incremental cases that don't go through setMediaItems, but
|
||||
// belt-and-suspenders).
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return@withLock
|
||||
}
|
||||
// Full rebuild: ensure active is cleared so transport calls drop
|
||||
// (markPendingResyncIfRemote may already have done this on the
|
||||
// setMediaItems path).
|
||||
// (the forwarding player has already done this on the setMediaItems
|
||||
// path).
|
||||
if (activeUpnpHolder.active.value != null) {
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(routeId)
|
||||
@@ -403,6 +419,7 @@ class OutputPickerController @Inject constructor(
|
||||
routeName = outputRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
queueMode = QueueMode.NATIVE_QUEUE,
|
||||
),
|
||||
)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
@@ -413,6 +430,57 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-track counterpart of the Sonos resync. A mutation that left the
|
||||
* cast running (play next, radio append, a removal) keeps the current
|
||||
* track playing and only re-arms what follows it. A queue replaced
|
||||
* through setMediaItems -- the forwarding player has already cleared
|
||||
* active, and dropped the play() that came with it -- starts the new
|
||||
* queue at its current index, as the listener asked. So does a mutation
|
||||
* that removed the playing track.
|
||||
*/
|
||||
private suspend fun resyncSingleTrack(
|
||||
upnpRoute: UpnpRoute,
|
||||
transport: AVTransportClient,
|
||||
newQueue: List<TrackRef>,
|
||||
newCurrentIndex: Int,
|
||||
) {
|
||||
val stillActive = activeUpnpHolder.active.value?.routeId == upnpRoute.id
|
||||
if (stillActive && requeueKeepsTrack(transport, newQueue)) {
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return
|
||||
}
|
||||
if (activeUpnpHolder.active.value != null) {
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(upnpRoute.id)
|
||||
}
|
||||
Timber.w("UPnP single-track resync: starting track %d on %s", newCurrentIndex + 1, upnpRoute.name)
|
||||
runCatching {
|
||||
singleTrack.load(transport, newQueue, newCurrentIndex)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = upnpRoute.id,
|
||||
routeName = upnpRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = renderingClientFor(upnpRoute.id),
|
||||
queueMode = QueueMode.SINGLE_TRACK,
|
||||
),
|
||||
)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
remoteState.setPlayIntent(true)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP single-track resync failed -- dropping to local")
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the playing track survived the change and was kept playing. */
|
||||
private suspend fun requeueKeepsTrack(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
|
||||
runCatching { singleTrack.requeue(transport, newQueue) }
|
||||
.onFailure { Timber.w(it, "UPnP single-track requeue failed; restarting the queue") }
|
||||
.getOrDefault(false)
|
||||
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||
@@ -496,11 +564,10 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the UPnP renderer using Sonos native queue mode:
|
||||
* clear the device's queue, load every track from our local queue
|
||||
* via AddURIToQueue, point the transport at the queue URI, seek to
|
||||
* the current index, and play. Wrapped in `runCatching` — SOAP
|
||||
* failure abandons the selection cleanly.
|
||||
* Hand the renderer the local queue and play it. A Sonos gets its native
|
||||
* queue loaded ([SonosQueueLoader]); any other renderer is started on the
|
||||
* current track with the next one armed ([SingleTrackLoader]). Wrapped in
|
||||
* `runCatching` — SOAP failure abandons the selection cleanly.
|
||||
*
|
||||
* Order of operations is deliberate:
|
||||
* 1. Pause local so the user doesn't keep hearing local audio.
|
||||
@@ -531,6 +598,7 @@ class OutputPickerController @Inject constructor(
|
||||
return@withLock
|
||||
}
|
||||
val rendering = renderingClientFor(effectiveRoute.id)
|
||||
val queueMode = queueModeFor(effectiveRoute.id)
|
||||
// Pause local before flipping UI state -- user shouldn't keep hearing
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
@@ -539,7 +607,12 @@ class OutputPickerController @Inject constructor(
|
||||
// session we got disconnected from), attach to it in place instead of
|
||||
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||
val adopted = tryAdoptRunningSession(
|
||||
AdoptTarget(transport, rendering, effectiveRoute, queueMode),
|
||||
uiState.queue,
|
||||
uiState.queueIndex,
|
||||
)
|
||||
if (adopted) {
|
||||
return@withLock
|
||||
}
|
||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||
@@ -547,7 +620,12 @@ class OutputPickerController @Inject constructor(
|
||||
// taps don't hit Sonos's stale state from a prior session.
|
||||
activeUpnpHolder.setTarget(effectiveRoute.id)
|
||||
runCatching {
|
||||
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
||||
when (queueMode) {
|
||||
QueueMode.NATIVE_QUEUE ->
|
||||
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
||||
QueueMode.SINGLE_TRACK ->
|
||||
singleTrack.load(transport, uiState.queue, uiState.queueIndex)
|
||||
}
|
||||
// Wire active LAST -- SOAP path is now safe to use.
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
@@ -555,6 +633,7 @@ class OutputPickerController @Inject constructor(
|
||||
routeName = effectiveRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
queueMode = queueMode,
|
||||
),
|
||||
)
|
||||
// Selecting a route loads the queue and Plays on the renderer, so
|
||||
@@ -570,38 +649,49 @@ class OutputPickerController @Inject constructor(
|
||||
|
||||
/**
|
||||
* The renderer's current position + transport state, validated as an
|
||||
* adoptable continuation of our local queue: same track id at the same
|
||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||
* adoptable continuation of our local queue. Null means "not ours / not
|
||||
* playing" -> caller reloads.
|
||||
*/
|
||||
private data class AdoptPlan(
|
||||
val localIndex: Int,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val trackUri: String,
|
||||
val trackNumber: Int,
|
||||
val playing: Boolean,
|
||||
)
|
||||
|
||||
/** The renderer a select is about to drive, and how. */
|
||||
private data class AdoptTarget(
|
||||
val transport: AVTransportClient,
|
||||
val rendering: RenderingControlClient?,
|
||||
val route: OutputRoute,
|
||||
val queueMode: QueueMode,
|
||||
)
|
||||
|
||||
/**
|
||||
* Attach to a renderer that is *already* playing our queue (a session the
|
||||
* phone got disconnected from but the speaker kept streaming) instead of
|
||||
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||
* mid-playback on the same track id at the same queue index, syncs the
|
||||
* mid-playback on one of our tracks where our queue has it, syncs the
|
||||
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||
* the caller does the full clear+reload) when the renderer isn't playing
|
||||
* our queue. The local player is already paused by the caller.
|
||||
* skips a beat, and skip/seek now drive it. Returns false (and the caller
|
||||
* does the full load) when the renderer isn't playing our queue. The
|
||||
* local player is already paused by the caller.
|
||||
*/
|
||||
private suspend fun tryAdoptRunningSession(
|
||||
transport: AVTransportClient,
|
||||
rendering: RenderingControlClient?,
|
||||
route: OutputRoute,
|
||||
target: AdoptTarget,
|
||||
localQueue: List<TrackRef>,
|
||||
localIndex: Int,
|
||||
): Boolean {
|
||||
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||
val route = target.route
|
||||
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
|
||||
if (target.queueMode == QueueMode.SINGLE_TRACK) {
|
||||
// The renderer holds only this track; arm the one after it.
|
||||
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
|
||||
}
|
||||
Timber.w(
|
||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||
route.name, plan.trackNumber, plan.positionMs,
|
||||
route.name, plan.localIndex + 1, plan.positionMs,
|
||||
)
|
||||
// active is still null here, so this aligns the *local* cursor without
|
||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||
@@ -611,45 +701,54 @@ class OutputPickerController @Inject constructor(
|
||||
positionMs = plan.positionMs,
|
||||
durationMs = plan.durationMs,
|
||||
trackUri = plan.trackUri,
|
||||
trackNumber = plan.trackNumber,
|
||||
trackNumber = plan.localIndex + 1,
|
||||
)
|
||||
remoteState.setPlayIntent(plan.playing)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = route.id,
|
||||
routeName = route.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
avTransport = target.transport,
|
||||
rendering = target.rendering,
|
||||
queueMode = target.queueMode,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sonos must be on our track at the same 1-based index of its native
|
||||
* queue, since skips drive that queue. A one-track renderer has no index
|
||||
* to agree on: it is ours if it is playing one of our tracks, taken
|
||||
* nearest the local cursor.
|
||||
*/
|
||||
private suspend fun probeAdoptable(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
target: AdoptTarget,
|
||||
localQueue: List<TrackRef>,
|
||||
localIndex: Int,
|
||||
): AdoptPlan? {
|
||||
val probe = runCatching {
|
||||
transport.getTransportInfo() to transport.getPositionInfo()
|
||||
target.transport.getTransportInfo() to target.transport.getPositionInfo()
|
||||
}.getOrElse {
|
||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", target.route.name)
|
||||
return null
|
||||
}
|
||||
val (info, pos) = probe
|
||||
val idx0 = pos.track - 1
|
||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||
val playingId = streamUriTrackId(pos.trackUri)
|
||||
val midPlayback =
|
||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||
val aligned = midPlayback && playingId != null &&
|
||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||
return if (aligned) {
|
||||
val idx0 = when {
|
||||
playingId == null -> null
|
||||
target.queueMode == QueueMode.SINGLE_TRACK ->
|
||||
nearestIndexOf(localQueue, playingId, localIndex)
|
||||
else -> (pos.track - 1).takeIf { it in localQueue.indices && localQueue[it].id == playingId }
|
||||
}
|
||||
return if (midPlayback && idx0 != null) {
|
||||
AdoptPlan(
|
||||
localIndex = idx0,
|
||||
positionMs = pos.relTimeMs,
|
||||
durationMs = pos.trackDurationMs,
|
||||
trackUri = pos.trackUri,
|
||||
trackNumber = pos.track,
|
||||
playing = info.state == TransportState.PLAYING,
|
||||
)
|
||||
} else {
|
||||
@@ -657,12 +756,9 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||
private fun trackIdFromStreamUrl(url: String): String? {
|
||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||
val i = segments.indexOf("tracks")
|
||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||
}
|
||||
/** How the route is fed tracks; a route gone from discovery reads as not-Sonos. */
|
||||
private fun queueModeFor(routeId: String): QueueMode =
|
||||
QueueMode.forRoute(upnpDiscovery.routes.value.firstOrNull { it.id == routeId }?.isSonos == true)
|
||||
|
||||
|
||||
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
||||
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.RemoteStallWatchdog
|
||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.abs
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Drives a UPnP / DLNA renderer that has no queue of its own — anything that
|
||||
* isn't a Sonos. [SonosQueueLoader] hands a Sonos the whole queue; the queue
|
||||
* verbs it uses are Sonos extensions a generic renderer faults on. Here the
|
||||
* app holds the queue and the renderer holds one track at a time:
|
||||
*
|
||||
* - SetAVTransportURI + Play starts a track.
|
||||
* - SetNextAVTransportURI hands over the one after it, so a renderer that
|
||||
* supports it moves on by itself, without a gap. The poll sees the
|
||||
* renderer's URI change to that one and arms the next.
|
||||
* - A renderer without SetNext (it faults) or one that accepts it and
|
||||
* ignores it stops at the end of the track; [TrackEndDetector] tells that
|
||||
* from a stop partway, and the app starts the next track itself.
|
||||
*
|
||||
* The renderer reports Track=1 for whatever it is playing, so the 1-based
|
||||
* queue position everything else keys on comes from here ([PollOutcome]).
|
||||
*
|
||||
* One mutex covers every write, because the poll's auto-advance and the
|
||||
* listener's skip both point the renderer somewhere and must not interleave.
|
||||
*/
|
||||
@Singleton
|
||||
class SingleTrackLoader @Inject constructor(
|
||||
private val streamTokens: StreamTokenProvider,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile private var queue: List<TrackRef> = emptyList()
|
||||
|
||||
/** 0-based index of the track the renderer was last pointed at; -1 when none. */
|
||||
@Volatile var currentIndex: Int = -1
|
||||
private set
|
||||
|
||||
private var currentUri: String = ""
|
||||
private var armedNextIndex: Int = -1
|
||||
private var armedNextUri: String = ""
|
||||
|
||||
// Set when the renderer faults SetNextAVTransportURI: it does not have the
|
||||
// action, and asking again per track would only collect the same fault.
|
||||
// Cleared on every load, since a load may be a different renderer.
|
||||
private var nextUnsupported = false
|
||||
|
||||
// Polls to wait before re-trying a SetNext that failed in transit. Each
|
||||
// try mints a URL from the server, so a failure is not retried every
|
||||
// second.
|
||||
private var armRetryPolls = 0
|
||||
|
||||
// Whether the last poll found the current track played to its end. Read
|
||||
// by [queueState] for the stall watchdog.
|
||||
private var endedOnLastPoll = false
|
||||
|
||||
private val endDetector = TrackEndDetector()
|
||||
|
||||
/**
|
||||
* What a poll learned. [trackNumber] is the 1-based queue position the
|
||||
* renderer is playing (0 when nothing is loaded). [advanced] is true when
|
||||
* this poll started the next track itself, so the reading it was given is
|
||||
* already stale and the caller should not judge a stall from it.
|
||||
* [knownDurationMs] is the library's duration for that track, for a
|
||||
* renderer that reports none.
|
||||
*/
|
||||
data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L)
|
||||
|
||||
/** Take [queue] as the one to play, and start [index] on the renderer. */
|
||||
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
|
||||
mutex.withLock {
|
||||
this.queue = queue
|
||||
nextUnsupported = false
|
||||
start(transport, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start [index] of the queue already held — a skip, a previous, or a tap
|
||||
* on a queue row. Returns false when [index] is outside the queue.
|
||||
*/
|
||||
suspend fun playIndex(transport: AVTransportClient, index: Int): Boolean =
|
||||
mutex.withLock { start(transport, index) }
|
||||
|
||||
/**
|
||||
* Attach to a renderer already playing [index] of [queue] at [uri] — a
|
||||
* session the phone lost touch with and the renderer carried on with —
|
||||
* without restarting it. Only arms the next track.
|
||||
*/
|
||||
suspend fun adopt(transport: AVTransportClient, queue: List<TrackRef>, index: Int, uri: String) =
|
||||
mutex.withLock {
|
||||
this.queue = queue
|
||||
nextUnsupported = false
|
||||
pointedAt(index, uri)
|
||||
armNext(transport)
|
||||
}
|
||||
|
||||
/**
|
||||
* The local queue changed under a playing track (play next, radio
|
||||
* append, a removal). Keep playing the current track and re-arm what
|
||||
* follows it. Returns false when the current track is gone from
|
||||
* [newQueue]; the caller then starts the new queue with [load].
|
||||
*/
|
||||
suspend fun requeue(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
|
||||
mutex.withLock {
|
||||
val playingId = queue.getOrNull(currentIndex)?.id
|
||||
val idx = playingId?.let { nearestIndexOf(newQueue, it, currentIndex) }
|
||||
queue = newQueue
|
||||
if (idx == null) return@withLock false
|
||||
currentIndex = idx
|
||||
// What was armed was chosen from the old queue; SetNext replaces it.
|
||||
armedNextIndex = -1
|
||||
armedNextUri = ""
|
||||
armNext(transport)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll's reading of the renderer. Follows a renderer that moved on by
|
||||
* itself, re-tries arming the next track, and starts the next track when
|
||||
* the current one has played to its end.
|
||||
*/
|
||||
suspend fun onPoll(
|
||||
transport: AVTransportClient,
|
||||
trackUri: String,
|
||||
state: TransportState,
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
playIntent: Boolean,
|
||||
): PollOutcome = mutex.withLock {
|
||||
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
|
||||
val moved = followRenderer(trackUri)
|
||||
armNext(transport)
|
||||
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
|
||||
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
|
||||
val next = currentIndex + 1
|
||||
val advance = endedOnLastPoll && playIntent && next in queue.indices
|
||||
if (advance) {
|
||||
Timber.w("UPnP single-track: track %d ended; starting %d", currentIndex + 1, next + 1)
|
||||
runCatching { start(transport, next) }
|
||||
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
|
||||
}
|
||||
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* For the stall watchdog: a renderer stopped at the end of the queue's
|
||||
* last track has simply finished. Anything else is left for the watchdog
|
||||
* to judge, as it would for a renderer that says nothing.
|
||||
*/
|
||||
fun queueState(): RemoteStallWatchdog.QueueState =
|
||||
if (endedOnLastPoll && currentIndex == queue.lastIndex) {
|
||||
RemoteStallWatchdog.QueueState.COMPLETE
|
||||
} else {
|
||||
RemoteStallWatchdog.QueueState.UNKNOWN
|
||||
}
|
||||
|
||||
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
|
||||
if (index !in queue.indices) return false
|
||||
val token = mint(index)
|
||||
transport.setAVTransportURIWithMetadata(token.url, token.mime, token.title)
|
||||
transport.play()
|
||||
pointedAt(index, token.url)
|
||||
armNext(transport)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun pointedAt(index: Int, uri: String) {
|
||||
currentIndex = index
|
||||
currentUri = uri
|
||||
armedNextIndex = -1
|
||||
armedNextUri = ""
|
||||
armRetryPolls = 0
|
||||
endedOnLastPoll = false
|
||||
endDetector.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update [currentIndex] when the renderer's URI says it is playing
|
||||
* something other than the track we started. The armed next URI is the
|
||||
* common case (it advanced by itself); otherwise the track id in the URI
|
||||
* is looked up, for a renderer that was re-pointed some other way.
|
||||
* Returns true when the index moved.
|
||||
*/
|
||||
private fun followRenderer(trackUri: String): Boolean {
|
||||
val idx = when {
|
||||
trackUri.isEmpty() || trackUri == currentUri -> null
|
||||
trackUri == armedNextUri -> armedNextIndex
|
||||
else -> streamUriTrackId(trackUri)?.let { nearestIndexOf(queue, it, currentIndex + 1) }
|
||||
}
|
||||
if (idx == null || idx == currentIndex) return false
|
||||
Timber.w("UPnP single-track: renderer moved on to track %d", idx + 1)
|
||||
pointedAt(idx, trackUri)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun armNext(transport: AVTransportClient) {
|
||||
val next = currentIndex + 1
|
||||
if (nextUnsupported || next !in queue.indices || next == armedNextIndex) return
|
||||
if (armRetryPolls > 0) {
|
||||
armRetryPolls -= 1
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
val token = mint(next)
|
||||
transport.setNextAVTransportURI(token.url, token.mime, token.title)
|
||||
token.url
|
||||
}.onSuccess { url ->
|
||||
armedNextIndex = next
|
||||
armedNextUri = url
|
||||
}.onFailure { e ->
|
||||
if (e is SoapFaultException) {
|
||||
// The renderer answered: it has no SetNext. The app advances it.
|
||||
Timber.w("UPnP single-track: SetNext refused (%s); advancing from the app", e.code)
|
||||
nextUnsupported = true
|
||||
} else {
|
||||
Timber.w(e, "UPnP single-track: SetNext failed; retrying later")
|
||||
armRetryPolls = ARM_RETRY_POLLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prerender both: the current track is about to be fetched, and the next
|
||||
// one is fetched the moment the renderer moves on.
|
||||
private suspend fun mint(index: Int) = streamTokens.mint(
|
||||
queue[index].id,
|
||||
asAlbum = SonosQueueLoader.playingAsAlbum(queue, index),
|
||||
prerender = true,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ARM_RETRY_POLLS = 10
|
||||
}
|
||||
}
|
||||
|
||||
/** The library's duration of [queue]'s track at [index], or 0 when there is none. */
|
||||
private fun knownDurationMs(queue: List<TrackRef>, index: Int): Long =
|
||||
queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
|
||||
|
||||
/**
|
||||
* Index in [queue] of the track with [id], taking the occurrence nearest
|
||||
* [preferNear] so a track queued twice does not send the cursor across the
|
||||
* queue. Null when [id] is not queued.
|
||||
*/
|
||||
internal fun nearestIndexOf(queue: List<TrackRef>, id: String, preferNear: Int): Int? =
|
||||
queue.indices.filter { queue[it].id == id }.minByOrNull { abs(it - preferNear) }
|
||||
|
||||
/** The track id in a `.../api/tracks/{id}/stream...` URL, or null for any other URL. */
|
||||
internal fun streamUriTrackId(uri: String): String? {
|
||||
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||
if (start < 0) return null
|
||||
val idStart = start + TRACKS_PATH_MARKER.length
|
||||
val idEnd = uri.indexOf('/', idStart)
|
||||
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||
}
|
||||
|
||||
private const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
|
||||
/**
|
||||
* Tells a renderer that finished its track from one that stopped partway.
|
||||
*
|
||||
* A renderer with no queue of its own reports the same STOPPED either way.
|
||||
* Finishing means the app should start the next track; stopping partway is
|
||||
* the stall watchdog's business (the stream died, or someone pressed stop on
|
||||
* the device), and starting the next track then would skip music.
|
||||
*
|
||||
* The test is where the renderer was last seen playing: within [END_SLACK_MS]
|
||||
* of the end counts as finished. The slack covers the 1 s poll cadence plus a
|
||||
* renderer that stops a little short of the duration it reported. A track is
|
||||
* only judged once it has been seen playing, so the brief STOPPED a renderer
|
||||
* passes through while it loads a new URI never reads as an ending.
|
||||
*
|
||||
* Pure state, no SOAP: [SingleTrackLoader] feeds it each poll.
|
||||
*/
|
||||
internal class TrackEndDetector {
|
||||
|
||||
private var sawPlaying = false
|
||||
private var lastPlayingPositionMs = 0L
|
||||
private var durationMs = 0L
|
||||
|
||||
/**
|
||||
* Feed one poll, learn whether the current track has ended.
|
||||
* [reportedDurationMs] is the renderer's TrackDuration, which some
|
||||
* renderers leave at zero; [knownDurationMs] is the library's duration
|
||||
* for the same track, used when the renderer says nothing.
|
||||
*/
|
||||
fun onPoll(
|
||||
state: TransportState,
|
||||
positionMs: Long,
|
||||
reportedDurationMs: Long,
|
||||
knownDurationMs: Long,
|
||||
): Boolean {
|
||||
val duration = reportedDurationMs.takeIf { it > 0L } ?: knownDurationMs
|
||||
if (duration > 0L) durationMs = duration
|
||||
if (state == TransportState.PLAYING) {
|
||||
sawPlaying = true
|
||||
lastPlayingPositionMs = positionMs
|
||||
return false
|
||||
}
|
||||
val stopped = state == TransportState.STOPPED || state == TransportState.NO_MEDIA
|
||||
return stopped &&
|
||||
sawPlaying &&
|
||||
durationMs > 0L &&
|
||||
lastPlayingPositionMs >= durationMs - END_SLACK_MS
|
||||
}
|
||||
|
||||
/** A new track was handed to the renderer: judge it from scratch. */
|
||||
fun reset() {
|
||||
sawPlaying = false
|
||||
lastPlayingPositionMs = 0L
|
||||
durationMs = 0L
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val END_SLACK_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+55
-14
@@ -4,15 +4,21 @@ import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* High-level wrapper for the UPnP AVTransport service.
|
||||
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
|
||||
* GetPositionInfo / GetTransportInfo / queue management
|
||||
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
|
||||
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / Stop /
|
||||
* Seek / GetPositionInfo / GetTransportInfo, plus the Sonos queue
|
||||
* extensions (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous,
|
||||
* SeekToTrack).
|
||||
*
|
||||
* [sonos] says which kind of renderer is on the other end. It shapes the
|
||||
* DIDL-Lite every track is described with, so it is fixed at construction
|
||||
* rather than chosen per call.
|
||||
*/
|
||||
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
|
||||
@Suppress("TooManyFunctions")
|
||||
class AVTransportClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
private val sonos: Boolean,
|
||||
) {
|
||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||
soap.call(
|
||||
@@ -39,15 +45,34 @@ class AVTransportClient(
|
||||
* <res protocolInfo="http-get:*:<mime>:*">...</res>
|
||||
* </item>
|
||||
* </DIDL-Lite>
|
||||
* Generic UPnP renderers tolerate this shape too — there's no
|
||||
* downside to always sending it. Title falls back to "Minstrel"
|
||||
* when the caller doesn't supply one.
|
||||
* The Sonos-only parts are left out for other renderers; see
|
||||
* [buildDidlLite]. Title falls back to "Minstrel" when the caller
|
||||
* doesn't supply one.
|
||||
*/
|
||||
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the renderer the track to play once the current one ends, so it
|
||||
* can move on without a gap and without waiting for us. Optional in the
|
||||
* UPnP spec: a renderer without it faults (401 Invalid Action, or 602
|
||||
* Not Implemented), and the caller then advances the renderer itself.
|
||||
*/
|
||||
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "SetNextAVTransportURI",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"NextURI" to uri,
|
||||
"NextURIMetaData" to buildDidlLite(uri, mime, title),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all tracks from the renderer's queue. UPnP action name is
|
||||
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
|
||||
@@ -247,6 +272,7 @@ class AVTransportClient(
|
||||
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
||||
"STOPPED" -> TransportState.STOPPED
|
||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||
"NO_MEDIA_PRESENT" -> TransportState.NO_MEDIA
|
||||
else -> TransportState.UNKNOWN
|
||||
}
|
||||
// CurrentTransportStatus is the renderer's own verdict on whether it is
|
||||
@@ -267,22 +293,31 @@ class AVTransportClient(
|
||||
// discards our metadata content and regenerates its own with
|
||||
// class=object.item and the URL query string as the title
|
||||
// instead. Match SoCo's pattern.
|
||||
//
|
||||
// Other renderers get plain DIDL with DLNA.ORG_OP=01 in the
|
||||
// protocolInfo, which declares that the stream takes byte-range
|
||||
// requests. Several DLNA renderers (TVs, AV receivers) refuse to
|
||||
// seek without it; the server's streams all serve Range.
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
val fourthField = if (sonos) "*" else DLNA_SEEKABLE
|
||||
return buildString {
|
||||
append("<DIDL-Lite ")
|
||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
|
||||
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"")
|
||||
if (sonos) append(" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"")
|
||||
append(">")
|
||||
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
|
||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||
append("<desc id=\"cdudn\" ")
|
||||
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("RINCON_AssociatedZPUDN")
|
||||
append("</desc>")
|
||||
append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("</res>")
|
||||
if (sonos) {
|
||||
append("<desc id=\"cdudn\" ")
|
||||
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("RINCON_AssociatedZPUDN")
|
||||
append("</desc>")
|
||||
}
|
||||
append("</item>")
|
||||
append("</DIDL-Lite>")
|
||||
}
|
||||
@@ -308,6 +343,7 @@ class AVTransportClient(
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
const val DLNA_SEEKABLE = "DLNA.ORG_OP=01"
|
||||
const val MILLIS_PER_SECOND = 1000L
|
||||
const val SECONDS_PER_MINUTE = 60L
|
||||
const val SECONDS_PER_HOUR = 3600L
|
||||
@@ -330,7 +366,12 @@ data class PositionInfo(
|
||||
*/
|
||||
data class MediaInfo(val nrTracks: Int, val currentUri: String)
|
||||
|
||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||
/**
|
||||
* [NO_MEDIA] is NO_MEDIA_PRESENT: nothing loaded. Several DLNA renderers land
|
||||
* there instead of STOPPED when a track plays out, so a one-track renderer's
|
||||
* ending reads it as a stop; it is not treated as a stall.
|
||||
*/
|
||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, NO_MEDIA, UNKNOWN }
|
||||
|
||||
/**
|
||||
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
|
||||
|
||||
+2
-1
@@ -135,13 +135,14 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
sonos = route.isSonos,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleDiscovery(locationUrl: String) {
|
||||
val route = fetchRoute(locationUrl) ?: return
|
||||
upsertRoute(route)
|
||||
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
|
||||
if (route.isSonos) {
|
||||
refreshSonosTopology(route)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,13 @@ data class UpnpRoute(
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||
)
|
||||
) {
|
||||
/**
|
||||
* Whether this renderer is a Sonos, which is driven through its native
|
||||
* queue. Anything else gets one track at a time — the Sonos queue verbs
|
||||
* are vendor extensions a generic renderer faults on.
|
||||
*/
|
||||
val isSonos: Boolean
|
||||
get() = zoneGroupTopologyControlUrl != null ||
|
||||
manufacturer.contains("Sonos", ignoreCase = true)
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package com.fabledsword.minstrel.settings.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.isUpdateAvailable
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One of three terminal states the Check-for-updates button surfaces.
|
||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||
* installs it via [ApkInstaller].
|
||||
*/
|
||||
sealed interface UpdateCheckResult {
|
||||
data object Idle : UpdateCheckResult
|
||||
data object Latest : UpdateCheckResult
|
||||
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult
|
||||
data class Error(val message: String) : UpdateCheckResult
|
||||
}
|
||||
|
||||
data class AboutUiState(
|
||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
// The value the platform installs by, and therefore the one the update
|
||||
// check must decide on. Held in state rather than read inline so a test
|
||||
// can drive the comparison without a BuildConfig.
|
||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||
// A debug build is signed with this machine's debug key, so the server's
|
||||
// release-signed APK can never install over it. It updates from Android
|
||||
// Studio instead.
|
||||
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
||||
val isChecking: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs the About card's update controls. "Check for updates" calls
|
||||
* [UpdateRepository.getLatest], compares versus this build via
|
||||
* [isUpdateAvailable] — on the ordering key where the server reports one,
|
||||
* on the name otherwise — and reports the terminal state.
|
||||
* When an update is available, [install] downloads the APK via
|
||||
* [ApkInstaller] and installs it — routing the user to the "install
|
||||
* unknown apps" settings page first when that permission hasn't been
|
||||
* granted.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AboutCardViewModel @Inject constructor(
|
||||
private val repository: UpdateRepository,
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow(AboutUiState())
|
||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||
|
||||
fun checkForUpdates() {
|
||||
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||
val installed = internal.value.installedVersion
|
||||
val installedCode = internal.value.installedCode
|
||||
val result = runCatching { repository.getLatest() }
|
||||
.map { latest ->
|
||||
if (
|
||||
isUpdateAvailable(
|
||||
serverCode = latest.code,
|
||||
serverName = latest.version,
|
||||
installedCode = installedCode,
|
||||
installedName = installed,
|
||||
)
|
||||
) {
|
||||
UpdateCheckResult.UpdateAvailable(latest)
|
||||
} else {
|
||||
UpdateCheckResult.Latest
|
||||
}
|
||||
}
|
||||
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
|
||||
internal.update { it.copy(isChecking = false, result = result) }
|
||||
}
|
||||
}
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (internal.value.installStage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
internal.update {
|
||||
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
|
||||
}
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
internal.update {
|
||||
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// The install half now suspends on the platform's verdict, so it
|
||||
// gets its own stage — reporting "Downloading…" through it would
|
||||
// be a lie once a confirm dialog is on screen.
|
||||
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||
val outcome = installer.install(apk)
|
||||
internal.update {
|
||||
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
internal.update {
|
||||
it.copy(
|
||||
installStage = InstallStage.ERROR,
|
||||
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -51,7 +51,6 @@ import com.composables.icons.lucide.ListMusic
|
||||
import com.composables.icons.lucide.LogOut
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shield
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
@@ -61,8 +60,9 @@ import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.theme.ThemeMode
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.ui.AppVersionCard
|
||||
import com.fabledsword.minstrel.update.ui.CheckWhileShown
|
||||
import com.fabledsword.minstrel.update.ui.UpdateViewModel
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -367,112 +367,14 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
|
||||
ThemeMode.DARK -> "Dark"
|
||||
}
|
||||
|
||||
/**
|
||||
* The About card is the app-version card Home's update sheet holds too; on
|
||||
* Settings it also asks the server again while the screen is up.
|
||||
*/
|
||||
@Composable
|
||||
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "About",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Installed: Minstrel ${state.installedVersion} " +
|
||||
"(${BuildConfig.VERSION_CODE})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
UpdateControls(state = state, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (!state.selfUpdates) {
|
||||
Text(
|
||||
text = "Debug build: updates install from Android Studio.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isChecking) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(if (state.isChecking) "Checking…" else "Check for updates")
|
||||
}
|
||||
val available = state.result as? UpdateCheckResult.UpdateAvailable
|
||||
if (available != null) {
|
||||
InstallButton(
|
||||
version = available.info.version,
|
||||
stage = state.installStage,
|
||||
onClick = { viewModel.install(available.info) },
|
||||
)
|
||||
}
|
||||
if (state.installMessage != null) {
|
||||
Text(
|
||||
text = state.installMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = !stage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (stage.isBusy()) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install $version"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonSpinner() {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateCheckLine(result: UpdateCheckResult) {
|
||||
val text = when (result) {
|
||||
UpdateCheckResult.Idle -> return
|
||||
UpdateCheckResult.Latest -> "You're on the latest version."
|
||||
is UpdateCheckResult.UpdateAvailable ->
|
||||
"Update available: ${result.info.version}"
|
||||
is UpdateCheckResult.Error -> "Couldn't check: ${result.message}"
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
|
||||
CheckWhileShown(model)
|
||||
AppVersionCard(model)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.foundation.selection.toggleable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -16,6 +17,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -55,21 +58,26 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
UsageRow(usedBytes = state.usedBytes)
|
||||
// Only the rolling cap is shown. Nothing enforces a separate
|
||||
// liked cap or pins liked tracks yet, and a control that silently
|
||||
// does nothing is worse than none. The prefetch window has no
|
||||
// control either; AudioPrefetcher uses the stored value (default
|
||||
// 5). CacheSettings keeps every field so the controls can return.
|
||||
// Two budgets: a liked track that is also recently played counts
|
||||
// against Liked songs only. The prefetch window has no control;
|
||||
// AudioPrefetcher uses the stored value (default 5).
|
||||
CapDropdown(
|
||||
label = "Cache limit",
|
||||
label = "Liked songs",
|
||||
hint = "${formatBytes(state.likedBytes)} used",
|
||||
value = state.settings.likedCapBytes,
|
||||
onSet = viewModel::setLikedCapBytes,
|
||||
)
|
||||
CapDropdown(
|
||||
label = "Recently played",
|
||||
hint = "${formatBytes(state.recentBytes)} used",
|
||||
value = state.settings.rollingCapBytes,
|
||||
onSet = viewModel::setRollingCapBytes,
|
||||
)
|
||||
Text(
|
||||
text = "Takes effect on the next app launch.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
SwitchRow(
|
||||
label = "Keep liked songs offline",
|
||||
hint = "Downloads on Wi-Fi",
|
||||
checked = state.settings.cacheLikedTracks,
|
||||
onCheckedChange = viewModel::setCacheLikedTracks,
|
||||
)
|
||||
StorageActions(
|
||||
isSyncing = state.isSyncing,
|
||||
@@ -91,41 +99,57 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(usedBytes: Long) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Used",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = formatBytes(usedBytes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) {
|
||||
private fun CapDropdown(label: String, hint: String, value: Long, onSet: (Long) -> Unit) {
|
||||
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
|
||||
?: formatBytes(value)
|
||||
LabeledDropdown(
|
||||
label = label,
|
||||
hint = hint,
|
||||
selectedLabel = selectedLabel,
|
||||
options = CAP_OPTIONS,
|
||||
onSelect = onSet,
|
||||
)
|
||||
}
|
||||
|
||||
/** A setting's name, with one short line under it. */
|
||||
@Composable
|
||||
private fun RowLabel(label: String, hint: String, modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SwitchRow(label: String, hint: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Switch(checked = checked, onCheckedChange = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Label on the left, button-with-current-value on the right that opens
|
||||
* a DropdownMenu of [options]. Used by the cap selectors.
|
||||
* Label and hint on the left, button-with-current-value on the right that
|
||||
* opens a DropdownMenu of [options]. Used by the cap selectors.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T> LabeledDropdown(
|
||||
label: String,
|
||||
hint: String,
|
||||
selectedLabel: String,
|
||||
options: List<Pair<T, String>>,
|
||||
onSelect: (T) -> Unit,
|
||||
@@ -135,12 +159,7 @@ private fun <T> LabeledDropdown(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { expanded = true }) {
|
||||
Text(selectedLabel)
|
||||
DropdownMenu(
|
||||
@@ -227,7 +246,7 @@ private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatBytes(n: Long): String {
|
||||
internal fun formatBytes(n: Long): String {
|
||||
if (n <= 0L) return "0 B"
|
||||
return when {
|
||||
n < KIB -> "$n B"
|
||||
|
||||
+12
-12
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
|
||||
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
|
||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
import com.fabledsword.minstrel.player.PlayerFactory
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -18,25 +19,25 @@ import javax.inject.Inject
|
||||
|
||||
data class StorageUiState(
|
||||
val settings: CacheSettings = CacheSettings.DEFAULT,
|
||||
val usedBytes: Long = 0L,
|
||||
val likedBytes: Long = 0L,
|
||||
val recentBytes: Long = 0L,
|
||||
val isSyncing: Boolean = false,
|
||||
val isClearing: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs the Storage section of the Settings screen. Exposes the
|
||||
* persisted CacheSettings + the current SimpleCache usage, plus
|
||||
* actions for "Sync now" and "Clear cache".
|
||||
* persisted CacheSettings and how much of each audio budget (Liked
|
||||
* songs, Recently played) is in use, plus "Sync now" and "Clear cache".
|
||||
*
|
||||
* Settings changes go through AuthStore.setCacheSettings (write-
|
||||
* through to Room). The cap settings only take effect on next app
|
||||
* launch because SimpleCache is constructed once per process; the
|
||||
* UI surfaces that caveat (next commit).
|
||||
* through to Room), and the evictor applies a new cap at once.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class StorageViewModel @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val cacheEvictor: TwoBucketCacheEvictor,
|
||||
private val syncController: SyncController,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -47,9 +48,10 @@ class StorageViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
authStore.cacheSettings.collect { s ->
|
||||
internal.update { it.copy(settings = s) }
|
||||
// A smaller cap trims the cache, so re-read the usage.
|
||||
refreshUsage()
|
||||
}
|
||||
}
|
||||
refreshUsage()
|
||||
}
|
||||
|
||||
fun setLikedCapBytes(bytes: Long) {
|
||||
@@ -108,13 +110,11 @@ class StorageViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */
|
||||
/** Re-reads each budget's usage from the evictor. Cheap: two longs. */
|
||||
private fun refreshUsage() {
|
||||
viewModelScope.launch {
|
||||
val used = withContext(Dispatchers.IO) {
|
||||
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L)
|
||||
}
|
||||
internal.update { it.copy(usedBytes = used) }
|
||||
val usage = withContext(Dispatchers.IO) { cacheEvictor.usage() }
|
||||
internal.update { it.copy(likedBytes = usage.likedBytes, recentBytes = usage.recentBytes) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
|
||||
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
||||
import com.fabledsword.minstrel.update.ui.UpdateBanner
|
||||
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
|
||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||
|
||||
@@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
|
||||
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed
|
||||
* through to MiniPlayer's tap-handler. No NavHostController parameter
|
||||
* because nothing inside the shell currently navigates - if a banner
|
||||
* (UpdateBanner / VersionTooOldBanner / ConnectionErrorBanner) ever
|
||||
* (VersionTooOldBanner / ConnectionErrorBanner) ever
|
||||
* grows tap-to-navigate, plumb a NavHostController in at that point.
|
||||
*/
|
||||
@Composable
|
||||
@@ -97,12 +96,10 @@ fun ShellScaffold(
|
||||
// the status bar — no double inset, and no banner drawing under it.
|
||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||
// Banner slot. VersionTooOldBanner appears when the server's
|
||||
// min_client_version exceeds our build; UpdateBanner softly
|
||||
// nudges an available (newer-than-installed) APK; and
|
||||
// ConnectionErrorBanner shows when the device loses usable
|
||||
// internet.
|
||||
// min_client_version exceeds our build, and ConnectionErrorBanner
|
||||
// shows when the device loses usable internet. A newer app is not
|
||||
// a banner: Home tells of it in a snackbar (UpdateNotices).
|
||||
VersionTooOldBanner()
|
||||
UpdateBanner()
|
||||
ConnectionErrorBanner()
|
||||
// The window is edge-to-edge, so something has to own the
|
||||
// bottom navigation-bar inset, and the shell claims it once
|
||||
|
||||
@@ -63,34 +63,3 @@ object FabledSwordFlatTokens {
|
||||
val radiusLg = 12.dp
|
||||
val radiusXl = 16.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated alias (dark surfaces + flat) that binds to dark-mode values
|
||||
* regardless of system mode. Nothing reads it; UI code reads
|
||||
* `LocalFabledSwordTheme.current`.
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use LocalFabledSwordTheme.current — see FabledSwordTheme.kt",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
object FabledSwordTokens {
|
||||
val obsidian = FabledSwordDarkTokens.obsidian
|
||||
val iron = FabledSwordDarkTokens.iron
|
||||
val slate = FabledSwordDarkTokens.slate
|
||||
val pewter = FabledSwordDarkTokens.pewter
|
||||
val parchment = FabledSwordDarkTokens.parchment
|
||||
val vellum = FabledSwordDarkTokens.vellum
|
||||
val ash = FabledSwordDarkTokens.ash
|
||||
val moss = FabledSwordFlatTokens.moss
|
||||
val bronze = FabledSwordFlatTokens.bronze
|
||||
val oxblood = FabledSwordFlatTokens.oxblood
|
||||
val warning = FabledSwordFlatTokens.warning
|
||||
val error = FabledSwordFlatTokens.error
|
||||
val info = FabledSwordFlatTokens.info
|
||||
val accent = FabledSwordFlatTokens.accent
|
||||
val onAction = FabledSwordFlatTokens.onAction
|
||||
val radiusSm = FabledSwordFlatTokens.radiusSm
|
||||
val radiusMd = FabledSwordFlatTokens.radiusMd
|
||||
val radiusLg = FabledSwordFlatTokens.radiusLg
|
||||
val radiusXl = FabledSwordFlatTokens.radiusXl
|
||||
}
|
||||
|
||||
@@ -6,24 +6,13 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val APK_FILENAME = "minstrel-update.apk"
|
||||
|
||||
/**
|
||||
* Downloads the server-bundled APK and installs it over ourselves.
|
||||
*
|
||||
* The download goes through the shared [OkHttpClient] so it inherits
|
||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||
* Installs a downloaded APK over ourselves. [UpdateRepository] brings it
|
||||
* down; [SelfUpdateSession] streams it from there into a
|
||||
* [android.content.pm.PackageInstaller] session.
|
||||
*
|
||||
* On Android O+ the user must have granted "install unknown apps"
|
||||
@@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk"
|
||||
@Singleton
|
||||
class ApkInstaller @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val session: SelfUpdateSession,
|
||||
) {
|
||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("http://placeholder.invalid$apkUrl")
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Download failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: throw IOException("Empty download body")
|
||||
val file = File(context.cacheDir, APK_FILENAME)
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the system will accept an install intent without a permission detour. */
|
||||
fun canInstall(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class UpdateUiState(
|
||||
/** Null when there is nothing newer to offer. */
|
||||
val offer: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
val progress: Float = 0f,
|
||||
val problem: String? = null,
|
||||
/** Asking the server which app it offers. */
|
||||
val checking: Boolean = false,
|
||||
/** Why the last ask got no answer, when it did not. */
|
||||
val unchecked: String? = null,
|
||||
/** The offer is already downloaded and checked: Install is all that is left. */
|
||||
val ready: Boolean = false,
|
||||
)
|
||||
|
||||
/** A newer build to tell the listener of, and whether it is already here to install. */
|
||||
data class UpdateNews(val offer: UpdateInfo, val ready: Boolean)
|
||||
|
||||
/**
|
||||
* The one record of whether a newer app is on offer and where installing it
|
||||
* has got to, shared by Home (which tells of it) and the About card (in
|
||||
* Settings, and in the sheet Home's news opens). A singleton because each
|
||||
* screen gets its own view model, and an install started from one must
|
||||
* show on the other.
|
||||
*
|
||||
* Only the platform's answer retires an offer: declined, failed or refused,
|
||||
* it stays on screen. A newer build is fetched ahead on Wi-Fi
|
||||
* ([UpdateScheduler]), so the listener is usually told it is ready and
|
||||
* Install is one tap. Home is told at most once a day for each build
|
||||
* ([UpdateTold]).
|
||||
*/
|
||||
@Singleton
|
||||
class AppUpdates @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val updates: UpdateRepository,
|
||||
private val installer: ApkInstaller,
|
||||
private val told: UpdateTold,
|
||||
private val fetching: UpdateScheduler,
|
||||
) {
|
||||
private val mutable = MutableStateFlow(UpdateUiState())
|
||||
val state: StateFlow<UpdateUiState> = mutable.asStateFlow()
|
||||
|
||||
private val telling = Channel<UpdateNews>(Channel.BUFFERED)
|
||||
|
||||
/** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */
|
||||
val news: Flow<UpdateNews> = telling.receiveAsFlow()
|
||||
|
||||
private var asking: Job? = null
|
||||
private var tells = false
|
||||
|
||||
/**
|
||||
* Asks the server which app it offers: when Home or Settings opens, on a
|
||||
* pull, and every few minutes while one is up. A newer one being fetched
|
||||
* or installed is never taken off the screen by a later answer.
|
||||
*/
|
||||
fun check(): Job {
|
||||
asking?.takeIf { it.isActive }?.let { return it }
|
||||
return scope.launch { refresh() }.also { asking = it }
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
mutable.update { it.copy(checking = true) }
|
||||
val offer = updates.check()
|
||||
mutable.update { now ->
|
||||
val busy = now.stage != InstallStage.IDLE
|
||||
when (offer) {
|
||||
is Offer.Newer -> now.copy(offer = if (busy) now.offer else offer.info, unchecked = null)
|
||||
Offer.Current -> now.copy(offer = if (busy) now.offer else null, unchecked = null)
|
||||
is Offer.Problem -> now.copy(unchecked = offer.message)
|
||||
}.copy(checking = false)
|
||||
}
|
||||
fetchOrTidy(offer)
|
||||
lookForReady()
|
||||
tellIfDue()
|
||||
}
|
||||
|
||||
/** A newer build is fetched ahead; once none is offered, a package kept for one is only space. */
|
||||
private suspend fun fetchOrTidy(offer: Offer) {
|
||||
if (mutable.value.stage != InstallStage.IDLE) return
|
||||
when (offer) {
|
||||
is Offer.Newer -> fetching.fetchAhead()
|
||||
Offer.Current -> updates.tidy()
|
||||
is Offer.Problem -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Home is up: what is on offer now is told, if it is due. */
|
||||
fun tellHere() {
|
||||
tells = true
|
||||
tellIfDue()
|
||||
}
|
||||
|
||||
private fun tellIfDue() {
|
||||
val now = mutable.value
|
||||
val offer = now.offer
|
||||
if (!tells || offer == null || now.stage != InstallStage.IDLE) return
|
||||
val key = buildKey(offer.code, offer.version)
|
||||
val at = System.currentTimeMillis()
|
||||
if (dueToSay(key, told.build(), told.at(), at)) {
|
||||
told.told(key, at)
|
||||
telling.trySend(UpdateNews(offer, now.ready))
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the offer has been downloaded meanwhile, perhaps in the background. */
|
||||
fun lookForReady() {
|
||||
mutable.update { it.copy(ready = it.offer?.let(updates::ready) != null) }
|
||||
}
|
||||
|
||||
fun mayInstall(): Boolean = installer.canInstall()
|
||||
|
||||
/** Sends the listener to allow installs; the update carries on when they come back. */
|
||||
fun askToInstall() {
|
||||
mutable.update { it.copy(problem = ALLOW) }
|
||||
installer.requestInstallPermission()
|
||||
}
|
||||
|
||||
/** Came back from allowing installs without allowing them. */
|
||||
fun refused() {
|
||||
mutable.update { it.copy(problem = REFUSED) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the offer (or takes the one already here) and installs it.
|
||||
* Runs in the app's scope, so leaving the screen does not stop it.
|
||||
*/
|
||||
fun fetchAndInstall() {
|
||||
val offer = mutable.value.offer ?: return
|
||||
if (mutable.value.stage != InstallStage.IDLE) return
|
||||
mutable.update { it.copy(stage = InstallStage.DOWNLOADING, progress = 0f, problem = null) }
|
||||
scope.launch {
|
||||
val download = updates.download(offer) { done -> mutable.update { it.copy(progress = done) } }
|
||||
when (download) {
|
||||
is Download.Problem -> mutable.update {
|
||||
it.copy(stage = InstallStage.IDLE, problem = download.message)
|
||||
}
|
||||
is Download.Ready -> {
|
||||
mutable.update { it.copy(ready = true) }
|
||||
install(download.apk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun install(apk: File) {
|
||||
mutable.update { it.copy(stage = InstallStage.INSTALLING) }
|
||||
// On a successful silent install this never returns: the process is replaced.
|
||||
val outcome = installer.install(apk)
|
||||
mutable.update { it.copy(stage = InstallStage.IDLE, problem = outcome.message()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ALLOW = "Allow Minstrel to install apps. The update carries on when you come back."
|
||||
const val REFUSED =
|
||||
"Minstrel is still not allowed to install apps, so nothing was installed. Tap Update to try again."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What coming back to the app does to an update the listener asked for and
|
||||
* was sent to allow installs for.
|
||||
*/
|
||||
internal enum class AfterAsking {
|
||||
/** Allowed now: carry on with the update they already asked for. */
|
||||
FETCH,
|
||||
|
||||
/** Came back without allowing it. The offer stays; they are told. */
|
||||
REFUSED,
|
||||
|
||||
/** Nothing asked, something already under way, or the offer not known yet. */
|
||||
WAIT,
|
||||
}
|
||||
|
||||
internal fun afterAsking(asked: Boolean, mayInstall: Boolean, offered: Boolean, busy: Boolean): AfterAsking =
|
||||
when {
|
||||
!asked || busy || !offered -> AfterAsking.WAIT
|
||||
mayInstall -> AfterAsking.FETCH
|
||||
else -> AfterAsking.REFUSED
|
||||
}
|
||||
@@ -26,37 +26,22 @@ sealed interface InstallOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||
* Settings About card.
|
||||
* Where an install has got to, for the card that shows it (in Settings and
|
||||
* in the sheet Home's update news opens).
|
||||
*
|
||||
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
|
||||
* the platform's verdict and can sit on a confirm dialog indefinitely, so
|
||||
* one combined state would leave the UI claiming "Downloading…" through it.
|
||||
* A failure is not a stage: it returns to IDLE with the reason beside it,
|
||||
* and the offer stays so the listener can try again.
|
||||
*/
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||
|
||||
/** True while an install is underway and a second tap should do nothing. */
|
||||
fun InstallStage.isBusy(): Boolean =
|
||||
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||
|
||||
/**
|
||||
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||
* be a lie with a red tint.
|
||||
*/
|
||||
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||
}
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING }
|
||||
|
||||
/**
|
||||
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||
*
|
||||
* Lives beside the outcome rather than in either UI package because two
|
||||
* separate screens surface the same verdicts and must not drift — the same
|
||||
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||
* outside the UI layer.
|
||||
* Lives beside the outcome rather than in the UI package, the same reasoning
|
||||
* that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the
|
||||
* UI layer.
|
||||
*/
|
||||
fun InstallOutcome.message(): String? = when (this) {
|
||||
InstallOutcome.Installed -> null
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Drives the shell's soft "update available" banner. Polls
|
||||
* `/api/client/version` at launch + every 24h and, when the bundled
|
||||
* APK outranks this build — by ordering key where the server reports one,
|
||||
* by name otherwise — exposes its [UpdateInfo] so
|
||||
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
|
||||
* install.
|
||||
*
|
||||
* Dismissals are kept in memory and keyed by version, so dismissing
|
||||
* one release's banner still lets a later release re-surface — and a
|
||||
* restart re-shows it, which is acceptable nudging. Server 404 /
|
||||
* network errors stay silent. Constructed at
|
||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||
*
|
||||
* A debug build never polls: it is signed with a local debug key, so the
|
||||
* server's release-signed APK could never install over it.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateBannerController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val repository: UpdateRepository,
|
||||
) {
|
||||
private val latest = MutableStateFlow<UpdateInfo?>(null)
|
||||
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** The available update, or null when none / dismissed / unreachable. */
|
||||
val available: StateFlow<UpdateInfo?> =
|
||||
combine(latest, dismissed) { info, seen ->
|
||||
info?.takeIf { it.version !in seen }
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
if (!BuildConfig.DEBUG) startPolling()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
runOnce()
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss(version: String) {
|
||||
dismissed.value = dismissed.value + version
|
||||
}
|
||||
|
||||
private suspend fun runOnce() {
|
||||
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
|
||||
latest.value = info.takeIf {
|
||||
isUpdateAvailable(
|
||||
serverCode = it.code,
|
||||
serverName = it.version,
|
||||
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
||||
installedName = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Brings the newer app down before the listener is asked to install it, so
|
||||
* Install is one tap rather than a wait. It never installs and shows
|
||||
* nothing: whether to update stays the listener's.
|
||||
*
|
||||
* It asks the server again rather than being told what to fetch. WorkManager
|
||||
* may run it hours after it was asked for, when Wi-Fi comes, and by then the
|
||||
* server may offer another build.
|
||||
*/
|
||||
@HiltWorker
|
||||
class UpdateFetchWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val updates: UpdateRepository,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result = when (val offer = updates.check()) {
|
||||
// A server out of reach is a phone away from home; the next check asks for this again.
|
||||
is Offer.Problem -> Result.success()
|
||||
Offer.Current -> {
|
||||
updates.tidy()
|
||||
Result.success()
|
||||
}
|
||||
is Offer.Newer -> when (updates.download(offer.info) {}) {
|
||||
is Download.Ready -> Result.success()
|
||||
// A dropped connection is worth another go after WorkManager's
|
||||
// backoff, which also outlasts the server's one-download-a-minute
|
||||
// limit; a few, not forever.
|
||||
is Download.Problem -> if (runAttemptCount < TRIES) Result.retry() else Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRIES = 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the platform to fetch the newer app when it is free to: on an
|
||||
* unmetered network, with the battery and storage not low. Nothing turns
|
||||
* this onto mobile data, because the listener's Update still downloads at
|
||||
* once over whatever network there is; this only saves the wait.
|
||||
*
|
||||
* Kept rather than replaced, so a check every few minutes never restarts a
|
||||
* download already under way.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateScheduler @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
fun fetchAhead() {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
FETCHING,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<UpdateFetchWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FETCHING = "update-fetch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.content.Context
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import retrofit2.Retrofit
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object UpdateModule {
|
||||
/**
|
||||
* The download goes through the shared [OkHttpClient] so it inherits the
|
||||
* auth cookie and the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). A refusal, such as the
|
||||
* server's one-download-a-minute limit, reads as an IOException, which
|
||||
* the repository reports and the fetch worker retries after a backoff.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUpdateRepository(
|
||||
@ApplicationContext context: Context,
|
||||
retrofit: Retrofit,
|
||||
okHttpClient: OkHttpClient,
|
||||
): UpdateRepository = UpdateRepository(
|
||||
api = retrofit.create(ClientVersionApi::class.java),
|
||||
apk = ApkSource { apkUrl ->
|
||||
withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url("http://placeholder.invalid$apkUrl").build()
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw IOException("Download failed: HTTP ${response.code}")
|
||||
}
|
||||
response.body ?: throw IOException("Empty download body")
|
||||
}
|
||||
},
|
||||
dir = File(context.cacheDir, "update"),
|
||||
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
||||
installedName = BuildConfig.VERSION_NAME,
|
||||
offers = !BuildConfig.DEBUG,
|
||||
legacyApk = File(context.cacheDir, "minstrel-update.apk"),
|
||||
)
|
||||
}
|
||||
+191
-19
@@ -1,21 +1,158 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection.HTTP_NOT_FOUND
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** What asking the server for its app came to. */
|
||||
sealed interface Offer {
|
||||
/** The server's app is another build than this one, and installs over it. */
|
||||
data class Newer(val info: UpdateInfo) : Offer
|
||||
|
||||
/** This is the server's app, or the same app built again, or the server has none to offer. */
|
||||
data object Current : Offer
|
||||
|
||||
/** The server could not be asked. [message] says why, for the About card. */
|
||||
data class Problem(val message: String) : Offer
|
||||
}
|
||||
|
||||
/** What a download came to. */
|
||||
sealed interface Download {
|
||||
data class Ready(val apk: File) : Download
|
||||
data class Problem(val message: String) : Download
|
||||
}
|
||||
|
||||
/** Opens the server's APK for reading. The caller closes the body. */
|
||||
fun interface ApkSource {
|
||||
suspend fun open(apkUrl: String): ResponseBody
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin facade over `/api/client/version`, used by the About card's
|
||||
* Check for updates button and by [UpdateBannerController].
|
||||
* Whether the server offers a newer app than this one, and fetching it.
|
||||
*
|
||||
* One way a package arrives, whether [UpdateFetchWorker] brought it down
|
||||
* ahead on Wi-Fi or the listener pressed Update: [download]. The two take
|
||||
* turns, and each looks first for what the other already brought, so
|
||||
* nothing is downloaded twice. A package only gets its build's name once
|
||||
* it checked out against the size and digest the server gave, so a file at
|
||||
* that name is that build, whole ([ready]).
|
||||
*
|
||||
* A debug build is never offered one ([offers] false): it is signed with the
|
||||
* debug key, so the platform would refuse the release build over it anyway.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateRepository @Inject constructor(retrofit: Retrofit) {
|
||||
private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java)
|
||||
class UpdateRepository(
|
||||
private val api: ClientVersionApi,
|
||||
private val apk: ApkSource,
|
||||
private val dir: File,
|
||||
private val installedCode: Long,
|
||||
private val installedName: String,
|
||||
private val offers: Boolean,
|
||||
// A download left by older builds of the app, which nothing else removes.
|
||||
private val legacyApk: File? = null,
|
||||
) {
|
||||
private val turns = Mutex()
|
||||
|
||||
suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain()
|
||||
suspend fun check(): Offer {
|
||||
if (!offers) return Offer.Current
|
||||
return try {
|
||||
val info = api.getVersion().toDomain()
|
||||
if (isUpdateAvailable(info.code, info.version, installedCode, installedName)) {
|
||||
Offer.Newer(info)
|
||||
} else {
|
||||
Offer.Current
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
// 404 is the server saying it bundles no app: nothing to offer.
|
||||
if (e.code() == HTTP_NOT_FOUND) Offer.Current else Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||
} catch (e: IOException) {
|
||||
Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||
} catch (e: SerializationException) {
|
||||
Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** The package for [info], when it is already here and whole. */
|
||||
fun ready(info: UpdateInfo): File? =
|
||||
waiting(info).takeIf { it.isFile && it.length() == info.sizeBytes }
|
||||
|
||||
/**
|
||||
* The server's app, checked against the size and digest the server gave,
|
||||
* so what is handed to the installer is the build that was offered, all
|
||||
* of it. [progress] is called with 0 to 1 as it arrives.
|
||||
*/
|
||||
suspend fun download(info: UpdateInfo, progress: (Float) -> Unit): Download =
|
||||
turns.withLock { ready(info)?.let { Download.Ready(it) } ?: fetch(info, progress) }
|
||||
|
||||
/** Drops every package kept here: none is on offer any more. */
|
||||
suspend fun tidy() {
|
||||
turns.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
legacyApk?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to a part file and gives it the build's name only once it
|
||||
* checks out, so a download cut off partway is never taken for one that
|
||||
* finished. Any package for another build goes then.
|
||||
*/
|
||||
private suspend fun fetch(info: UpdateInfo, progress: (Float) -> Unit): Download {
|
||||
val target = waiting(info)
|
||||
val part = File(dir, target.name + PART)
|
||||
return try {
|
||||
val digest = withContext(Dispatchers.IO) {
|
||||
save(apk.open(info.apkUrl), part, info.sizeBytes, progress)
|
||||
}
|
||||
withContext(Dispatchers.IO) { settle(info, part, target, digest) }
|
||||
} catch (e: CancellationException) {
|
||||
part.delete()
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
part.delete()
|
||||
Download.Problem("Couldn't download the update: ${ErrorCopy.fromThrowable(e)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun settle(info: UpdateInfo, part: File, target: File, digest: String): Download {
|
||||
// A server that predates the digest leaves the size as the only check.
|
||||
val whole = part.length() == info.sizeBytes &&
|
||||
(info.sha256.isEmpty() || digest.equals(info.sha256, ignoreCase = true))
|
||||
return if (whole && part.renameTo(target)) {
|
||||
dir.listFiles()?.filter { it != target }?.forEach { it.delete() }
|
||||
Download.Ready(target)
|
||||
} else {
|
||||
part.delete()
|
||||
Download.Problem("The download did not match the app the server offered. Try again.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the package for [info] waits: named for its build. */
|
||||
private fun waiting(info: UpdateInfo): File =
|
||||
File(dir, "minstrel-${info.code ?: info.version.replace(UNSAFE, "_")}.apk")
|
||||
|
||||
private companion object {
|
||||
const val PART = ".part"
|
||||
val UNSAFE = Regex("[^A-Za-z0-9._-]")
|
||||
}
|
||||
}
|
||||
|
||||
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
||||
@@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
||||
channel = channel,
|
||||
apkUrl = apkUrl,
|
||||
sizeBytes = sizeBytes,
|
||||
sha256 = sha256,
|
||||
)
|
||||
|
||||
/** Writes [body] to [file], reporting progress against [size], and returns the SHA-256 written, in hex. */
|
||||
internal fun save(body: ResponseBody, file: File, size: Long, progress: (Float) -> Unit): String {
|
||||
file.parentFile?.mkdirs()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
// Closing the stream closes the body.
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output -> copy(input, output, digest) { written -> report(written, size, progress) } }
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun copy(input: InputStream, output: OutputStream, digest: MessageDigest, written: (Long) -> Unit) {
|
||||
val buffer = ByteArray(BUFFER_BYTES)
|
||||
var total = 0L
|
||||
var read = input.read(buffer)
|
||||
while (read >= 0) {
|
||||
output.write(buffer, 0, read)
|
||||
digest.update(buffer, 0, read)
|
||||
total += read
|
||||
written(total)
|
||||
read = input.read(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun report(written: Long, size: Long, progress: (Float) -> Unit) {
|
||||
if (size > 0) progress((written.toFloat() / size).coerceAtMost(1f))
|
||||
}
|
||||
|
||||
private const val BUFFER_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* True when [server] should be offered over the installed build.
|
||||
* True when [serverName] / [serverCode] should be offered over the installed
|
||||
* build. Two questions, each asked of the value that answers it.
|
||||
*
|
||||
* **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. Comparing NAMES
|
||||
* while the platform installs by `versionCode` keeps nothing consistent
|
||||
* between the two orderings: it can offer a build Android then refuses as
|
||||
* a downgrade, or stay quiet about one it would take.
|
||||
* **May it be installed over this one** is the ordering key's, whenever the
|
||||
* server sends one: it is the same value Android's package installer
|
||||
* compares, so an offer made on it is an install the platform will take.
|
||||
*
|
||||
* 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" —
|
||||
* **Is it another app at all** is the name's, compared for equality only.
|
||||
* Every server image builds the app again at a later minute, so the code
|
||||
* rises with a CI re-run or a change outside the shipped set while the app
|
||||
* itself does not; asking only the first question offered the installed app
|
||||
* to itself after each one.
|
||||
*
|
||||
* Name ordering survives only as the fallback for a server that predates the
|
||||
* code. 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.
|
||||
*/
|
||||
@@ -48,7 +220,7 @@ fun isUpdateAvailable(
|
||||
installedName: String,
|
||||
): Boolean =
|
||||
if (serverCode != null) {
|
||||
serverCode > installedCode
|
||||
serverCode > installedCode && serverName != installedName
|
||||
} else {
|
||||
isVersionNewer(serverName, installedName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The build Home last told the listener of, and when: on disk, so an app
|
||||
* Android reopened does not say the same news again.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateTold @Inject constructor(@ApplicationContext context: Context) {
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun build(): String = prefs.getString(BUILD, "").orEmpty()
|
||||
|
||||
fun at(): Long = prefs.getLong(AT, 0L)
|
||||
|
||||
fun told(build: String, at: Long) {
|
||||
prefs.edit().putString(BUILD, build).putLong(AT, at).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "update_told"
|
||||
const val BUILD = "build"
|
||||
const val AT = "at"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a build is told under: its code, or its name from a server that
|
||||
* sends no code.
|
||||
*/
|
||||
internal fun buildKey(code: Long?, version: String): String = code?.toString() ?: version
|
||||
|
||||
/**
|
||||
* Whether a build on offer is said again: a build not yet told of is news at
|
||||
* once; the same one again a day later, until it is installed. A clock that
|
||||
* went backwards says it rather than hiding an update for a day.
|
||||
*/
|
||||
internal fun dueToSay(build: String, toldBuild: String, toldAt: Long, now: Long): Boolean {
|
||||
val since = now - toldAt
|
||||
return build != toldBuild || since < 0 || since >= SAY_AGAIN_AFTER_MS
|
||||
}
|
||||
|
||||
/** What Home's snackbar says of an offer; its button opens the sheet. */
|
||||
internal fun toldWords(version: String, ready: Boolean): String =
|
||||
if (ready) {
|
||||
"Version $version of the app is downloaded and ready."
|
||||
} else {
|
||||
"Version $version of the app is on your server."
|
||||
}
|
||||
|
||||
/** The snackbar's button: Install when there is nothing left to download. */
|
||||
internal fun toldAction(ready: Boolean): String = if (ready) "Install" else "Update"
|
||||
|
||||
internal const val SAY_AGAIN_AFTER_MS = 24L * 60 * 60 * 1000
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.settings.ui.formatBytes
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateUiState
|
||||
|
||||
/**
|
||||
* The app's own version, the newer one when the server has it, and a check
|
||||
* that is always there. Settings shows it as its About card, and the sheet
|
||||
* Home's update news opens holds the very same card, so there is one place
|
||||
* the app installs from. Back from allowing installs, the update the
|
||||
* listener asked for carries on.
|
||||
*/
|
||||
@Composable
|
||||
fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
|
||||
LifecycleResumeEffect(model) {
|
||||
model.resumed()
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "About",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Installed: Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val offer = state.offer
|
||||
if (offer != null) {
|
||||
Offered(offer, state, model::update)
|
||||
} else {
|
||||
Text(
|
||||
text = versionWords(state, selfUpdates = !BuildConfig.DEBUG),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
state.problem?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(
|
||||
onClick = model::check,
|
||||
enabled = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE,
|
||||
) { Text(if (state.checking) "Checking…" else "Check for updates") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The newer build, and the one button that fetches and installs it. */
|
||||
@Composable
|
||||
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
|
||||
Text(offeredWords(offer, state.ready), style = MaterialTheme.typography.bodyMedium)
|
||||
when (state.stage) {
|
||||
InstallStage.IDLE -> Button(onClick = update, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.ready) "Install" else "Update")
|
||||
}
|
||||
InstallStage.DOWNLOADING ->
|
||||
LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||
InstallStage.INSTALLING -> Text("Installing…", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this app is the newest, as far as the server has said. */
|
||||
internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): String = when {
|
||||
// Its own package and the debug key: the server's app would install beside it, never over it.
|
||||
!selfUpdates -> "Debug build: updates install from Android Studio."
|
||||
state.checking -> "Checking for a newer version…"
|
||||
state.unchecked != null -> "Couldn't check for a newer version: ${state.unchecked}"
|
||||
else -> "This is the newest version."
|
||||
}
|
||||
|
||||
/** The newer build, and whether it is already downloaded. */
|
||||
internal fun offeredWords(offer: UpdateInfo, ready: Boolean): String =
|
||||
if (ready) {
|
||||
"A new version of the app is downloaded and ready: ${describe(offer)}"
|
||||
} else {
|
||||
"A new version of the app is on your server: ${describe(offer)}"
|
||||
}
|
||||
|
||||
/** The name, the channel when it is the test line, and the size. */
|
||||
internal fun describe(offer: UpdateInfo): String {
|
||||
val channel = if (offer.channel == "dev") " (test build)" else ""
|
||||
return "${offer.version}$channel · ${formatBytes(offer.sizeBytes)}"
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.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.composables.icons.lucide.Download
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
/**
|
||||
* Shell-level soft banner that nudges an available update. Renders
|
||||
* nothing until [UpdateBannerController] surfaces a newer bundled APK
|
||||
* (and the user hasn't dismissed that version): a download glyph,
|
||||
* "Update Minstrel · {version} available", an Install button
|
||||
* (download → system installer), and a dismiss X. Uses an understated
|
||||
* surface tone, not the alarming error color reserved for
|
||||
* `VersionTooOldBanner`.
|
||||
*
|
||||
* [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
|
||||
* exposes no byte progress, so the downloading state is an
|
||||
* indeterminate bar.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val info = state.info
|
||||
AnimatedVisibility(
|
||||
visible = info != null,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
// info can flip to null during the exit animation; hold the last
|
||||
// non-null so the collapsing banner keeps its content.
|
||||
val shown = info ?: return@AnimatedVisibility
|
||||
BannerBody(
|
||||
info = shown,
|
||||
stage = state.stage,
|
||||
message = state.message,
|
||||
onInstall = { viewModel.install(shown) },
|
||||
onDismiss = { viewModel.dismiss(shown.version) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerBody(
|
||||
info: UpdateInfo,
|
||||
stage: InstallStage,
|
||||
message: String?,
|
||||
onInstall: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||
if (stage.isBusy()) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerRow(
|
||||
info: UpdateInfo,
|
||||
stage: InstallStage,
|
||||
onInstall: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Download,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = if (stage == InstallStage.ERROR) {
|
||||
"Update failed"
|
||||
} else {
|
||||
"Update Minstrel · ${info.version} available"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||
// Downloading and installing are separate words because they're now
|
||||
// separate waits — the install half suspends on the platform, which
|
||||
// may be sitting on a confirm dialog.
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Lucide.X,
|
||||
contentDescription = "Dismiss",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
data class UpdateBannerUiState(
|
||||
val info: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||
* the About card's flow (route to "install unknown apps" settings first
|
||||
* when the permission is missing).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateBannerViewModel @Inject constructor(
|
||||
private val controller: UpdateBannerController,
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||
|
||||
val uiState: StateFlow<UpdateBannerUiState> =
|
||||
combine(controller.available, installState) { info, install ->
|
||||
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = UpdateBannerUiState(),
|
||||
)
|
||||
|
||||
fun dismiss(version: String) = controller.dismiss(version)
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (installState.value.stage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.IDLE,
|
||||
"Allow installs for Minstrel, then tap Install again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// Await the platform's verdict rather than firing an intent and
|
||||
// assuming it worked. On a silent install this suspends until
|
||||
// the process is replaced, so the line below is only reached
|
||||
// when the install did NOT simply succeed.
|
||||
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||
val outcome = installer.install(apk)
|
||||
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.fabledsword.minstrel.update.data.toldAction
|
||||
import com.fabledsword.minstrel.update.data.toldWords
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** How often a screen on show asks the server about a newer app by itself. */
|
||||
private const val CHECK_EVERY_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* A newer app, told on Home (after Librarian's and Gallery's update tray): a
|
||||
* snackbar at the foot of the screen, never a dialog, and its button opens a
|
||||
* sheet holding the very card Settings shows, so there is one place the app
|
||||
* installs from. Each build is told at most once a day. The sheet stays open
|
||||
* through a trip to allow installs, which is where the card carries on from.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.UpdateNotices(model: UpdateViewModel) {
|
||||
val notices = remember { SnackbarHostState() }
|
||||
var updating by rememberSaveable { mutableStateOf(false) }
|
||||
CheckWhileShown(model)
|
||||
LaunchedEffect(model) {
|
||||
model.tellHere()
|
||||
model.news.collect { news ->
|
||||
val tapped = notices.showSnackbar(
|
||||
message = toldWords(news.offer.version, news.ready),
|
||||
actionLabel = toldAction(news.ready),
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
if (tapped == SnackbarResult.ActionPerformed) updating = true
|
||||
}
|
||||
}
|
||||
SnackbarHost(notices, Modifier.align(Alignment.BottomCenter).padding(12.dp))
|
||||
if (updating) UpdateSheet(model) { updating = false }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UpdateSheet(model: UpdateViewModel, dismiss: () -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = dismiss) {
|
||||
AppVersionCard(
|
||||
model,
|
||||
Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server about a newer app when the screen opens, as soon as the
|
||||
* app comes back to the front, and every [CHECK_EVERY_MS] while it is on
|
||||
* screen. A screen in the background never asks.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckWhileShown(model: UpdateViewModel) {
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(model, lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
while (true) {
|
||||
model.check()
|
||||
delay(CHECK_EVERY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.update.data.AfterAsking
|
||||
import com.fabledsword.minstrel.update.data.AppUpdates
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateNews
|
||||
import com.fabledsword.minstrel.update.data.UpdateUiState
|
||||
import com.fabledsword.minstrel.update.data.afterAsking
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A screen's handle on [AppUpdates]. What it adds is the one thing that
|
||||
* belongs to the screen: that the listener pressed Update and was sent to
|
||||
* allow installs. That is kept in [saved], so it outlives the process
|
||||
* Android may restart from the settings page, and the update carries on
|
||||
* when they come back ([resumed]) instead of asking them to tap again.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateViewModel @Inject constructor(
|
||||
private val updates: AppUpdates,
|
||||
private val saved: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<UpdateUiState> = updates.state
|
||||
val news: Flow<UpdateNews> = updates.news
|
||||
|
||||
/** Asks the server again; once the answer is in, an update asked for before a restart carries on. */
|
||||
fun check() {
|
||||
viewModelScope.launch {
|
||||
updates.check().join()
|
||||
carryOn()
|
||||
}
|
||||
}
|
||||
|
||||
fun tellHere() = updates.tellHere()
|
||||
|
||||
/** The app is in front again, perhaps back from allowing installs. */
|
||||
fun resumed() {
|
||||
updates.lookForReady()
|
||||
carryOn()
|
||||
}
|
||||
|
||||
fun update() {
|
||||
val now = state.value
|
||||
when {
|
||||
now.offer == null || now.stage != InstallStage.IDLE -> Unit
|
||||
!updates.mayInstall() -> {
|
||||
saved[ASKED] = true
|
||||
updates.askToInstall()
|
||||
}
|
||||
else -> updates.fetchAndInstall()
|
||||
}
|
||||
}
|
||||
|
||||
private fun carryOn() {
|
||||
val now = state.value
|
||||
val next = afterAsking(
|
||||
asked = saved.get<Boolean>(ASKED) == true,
|
||||
mayInstall = updates.mayInstall(),
|
||||
offered = now.offer != null,
|
||||
busy = now.stage != InstallStage.IDLE,
|
||||
)
|
||||
when (next) {
|
||||
AfterAsking.WAIT -> Unit
|
||||
AfterAsking.REFUSED -> {
|
||||
saved[ASKED] = false
|
||||
updates.refused()
|
||||
}
|
||||
AfterAsking.FETCH -> {
|
||||
saved[ASKED] = false
|
||||
updates.fetchAndInstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The listener asked for the update and was sent to allow installs. */
|
||||
const val ASKED = "update.asked_to_allow"
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.fabledsword.minstrel.cache.audiocache
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* The two audio-cache budgets: Liked songs and Recently played. A track
|
||||
* that is liked and also played counts against Liked only, and each budget
|
||||
* drops its own least-recently-used audio first.
|
||||
*/
|
||||
class BucketLedgerTest {
|
||||
private data class Span(val key: String, val position: Long, val length: Long, val touched: Long)
|
||||
|
||||
private fun ledger(likedCap: Long = 0L, recentCap: Long = 0L) = BucketLedger<Span>(
|
||||
keyOf = { it.key },
|
||||
lengthOf = { it.length },
|
||||
lruOrder = compareBy<Span> { it.touched }.thenBy { it.key }.thenBy { it.position },
|
||||
).apply {
|
||||
likedCapBytes = likedCap
|
||||
recentCapBytes = recentCap
|
||||
}
|
||||
|
||||
/** Drop spans the way the evictor does, until both budgets fit. */
|
||||
private fun BucketLedger<Span>.drain(): List<Span> {
|
||||
val dropped = mutableListOf<Span>()
|
||||
while (true) {
|
||||
val span = nextEviction() ?: return dropped
|
||||
remove(span)
|
||||
dropped += span
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a liked track's bytes count against Liked only`() {
|
||||
val l = ledger()
|
||||
l.setLiked(setOf("liked"))
|
||||
l.add(Span("liked", 0, 300, touched = 1))
|
||||
l.add(Span("other", 0, 200, touched = 2))
|
||||
|
||||
assertEquals(300, l.bytesIn(CacheBucket.LIKED))
|
||||
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filling Recently played drops its oldest audio and never liked audio`() {
|
||||
val l = ledger(likedCap = 1_000, recentCap = 250)
|
||||
l.setLiked(setOf("liked"))
|
||||
val liked = Span("liked", 0, 100, touched = 0)
|
||||
val oldest = Span("a", 0, 100, touched = 1)
|
||||
l.add(liked)
|
||||
l.add(oldest)
|
||||
l.add(Span("b", 0, 100, touched = 2))
|
||||
l.add(Span("c", 0, 100, touched = 3))
|
||||
|
||||
assertEquals(listOf(oldest), l.drain())
|
||||
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
|
||||
assertEquals(100, l.bytesIn(CacheBucket.LIKED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filling Liked drops its least recently played liked audio`() {
|
||||
val l = ledger(likedCap = 150, recentCap = 1_000)
|
||||
l.setLiked(setOf("x", "y"))
|
||||
val older = Span("x", 0, 100, touched = 1)
|
||||
l.add(older)
|
||||
l.add(Span("y", 0, 100, touched = 2))
|
||||
l.add(Span("recent", 0, 500, touched = 0))
|
||||
|
||||
assertEquals(listOf(older), l.drain())
|
||||
assertEquals(500, l.bytesIn(CacheBucket.RECENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `liking a cached track moves its bytes into Liked`() {
|
||||
val l = ledger(likedCap = 1_000, recentCap = 150)
|
||||
l.setLiked(emptySet())
|
||||
l.add(Span("t", 0, 100, touched = 1))
|
||||
l.add(Span("t", 100, 50, touched = 2))
|
||||
l.add(Span("u", 0, 100, touched = 3))
|
||||
assertEquals(250, l.bytesIn(CacheBucket.RECENT))
|
||||
|
||||
l.setLiked(setOf("t"))
|
||||
|
||||
assertEquals(150, l.bytesIn(CacheBucket.LIKED))
|
||||
assertEquals(100, l.bytesIn(CacheBucket.RECENT))
|
||||
assertEquals(emptyList(), l.drain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unliking moves the bytes to Recently played, which then trims`() {
|
||||
val l = ledger(likedCap = 1_000, recentCap = 150)
|
||||
l.setLiked(setOf("t"))
|
||||
val unliked = Span("t", 0, 100, touched = 1)
|
||||
l.add(unliked)
|
||||
l.add(Span("u", 0, 100, touched = 2))
|
||||
|
||||
l.setLiked(emptySet())
|
||||
|
||||
assertEquals(listOf(unliked), l.drain())
|
||||
assertEquals(0, l.bytesIn(CacheBucket.LIKED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shrinking a cap trims at once`() {
|
||||
val l = ledger(recentCap = 1_000)
|
||||
l.setLiked(emptySet())
|
||||
l.add(Span("a", 0, 100, touched = 1))
|
||||
l.add(Span("b", 0, 100, touched = 2))
|
||||
assertEquals(emptyList(), l.drain())
|
||||
|
||||
l.recentCapBytes = 100
|
||||
|
||||
assertEquals(listOf("a"), l.drain().map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cap of 0 never evicts`() {
|
||||
val l = ledger(likedCap = 0, recentCap = 0)
|
||||
l.setLiked(setOf("liked"))
|
||||
repeat(50) { l.add(Span("k$it", 0, 1_000_000, touched = it.toLong())) }
|
||||
l.add(Span("liked", 0, 1_000_000, touched = 99))
|
||||
|
||||
assertNull(l.nextEviction())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing is evicted before the liked set is known`() {
|
||||
val l = ledger(likedCap = 100, recentCap = 100)
|
||||
l.add(Span("maybe-liked", 0, 500, touched = 1))
|
||||
|
||||
assertNull(l.nextEviction())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a write about to start makes room in its own bucket`() {
|
||||
val l = ledger(likedCap = 1_000, recentCap = 200)
|
||||
l.setLiked(setOf("liked"))
|
||||
l.add(Span("a", 0, 150, touched = 1))
|
||||
|
||||
assertNull(l.nextEviction(pendingKey = "liked", pendingBytes = 100))
|
||||
assertEquals("a", l.nextEviction(pendingKey = "new", pendingBytes = 100)?.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Liked room is what the Liked cap has left`() {
|
||||
val l = ledger(likedCap = 500)
|
||||
l.setLiked(setOf("x"))
|
||||
l.add(Span("x", 0, 200, touched = 1))
|
||||
|
||||
assertEquals(300, l.likedRoomBytes())
|
||||
l.likedCapBytes = 0
|
||||
assertEquals(Long.MAX_VALUE, l.likedRoomBytes())
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* The pieces that let a renderer without a queue be driven from ours: which
|
||||
* renderers get which mode, and mapping the URI it reports back to a queue
|
||||
* position.
|
||||
*/
|
||||
class SingleTrackQueueTest {
|
||||
|
||||
private fun t(id: String) = TrackRef(id = id, title = "", albumId = "", artistId = "")
|
||||
|
||||
private fun route(manufacturer: String, zgt: Boolean = false) = UpnpRoute(
|
||||
id = "uuid:x",
|
||||
name = "Room",
|
||||
manufacturer = manufacturer,
|
||||
modelName = "",
|
||||
avTransportControlUrl = "http://192.168.0.2:1400/AVTransport/Control".toHttpUrl(),
|
||||
renderingControlUrl = null,
|
||||
zoneGroupTopologyControlUrl = if (zgt) "http://192.168.0.2:1400/ZGT/Control".toHttpUrl() else null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a Sonos is driven through its native queue`() {
|
||||
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("Sonos, Inc.").isSonos))
|
||||
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("", zgt = true).isSonos))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `any other renderer is driven one track at a time`() {
|
||||
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("Yamaha Corporation").isSonos))
|
||||
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("").isSonos))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the track id is read from a stream URL, leveled or not`() {
|
||||
assertEquals("abc", streamUriTrackId("http://h:8080/api/tracks/abc/stream.flac?t=tok"))
|
||||
assertEquals("abc", streamUriTrackId("http://h/api/tracks/abc/leveled.flac?t=tok"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a URL that is not ours has no track id`() {
|
||||
assertNull(streamUriTrackId("x-rincon-queue:RINCON_ABC#0"))
|
||||
assertNull(streamUriTrackId(""))
|
||||
assertNull(streamUriTrackId("http://h/api/tracks/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track queued twice resolves to the copy nearest the cursor`() {
|
||||
val queue = listOf(t("a"), t("b"), t("a"), t("c"), t("a"))
|
||||
assertEquals(2, nearestIndexOf(queue, "a", preferNear = 3))
|
||||
assertEquals(0, nearestIndexOf(queue, "a", preferNear = 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track not in the queue has no index`() {
|
||||
assertNull(nearestIndexOf(listOf(t("a")), "z", preferNear = 0))
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A one-track renderer says STOPPED both when a track plays out and when it
|
||||
* stops partway; only the first should start the next track.
|
||||
*/
|
||||
class TrackEndDetectorTest {
|
||||
|
||||
private val d = TrackEndDetector()
|
||||
|
||||
private fun poll(state: TransportState, positionMs: Long, durationMs: Long = 200_000L, known: Long = 0L) =
|
||||
d.onPoll(state, positionMs, durationMs, known)
|
||||
|
||||
@Test
|
||||
fun `a stop just after the last playing reading near the end is an ending`() {
|
||||
poll(TransportState.PLAYING, 198_000L)
|
||||
assertTrue(poll(TransportState.STOPPED, 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stop partway through is not an ending`() {
|
||||
poll(TransportState.PLAYING, 90_000L)
|
||||
assertFalse(poll(TransportState.STOPPED, 90_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stop a renderer passes through while loading a track is not an ending`() {
|
||||
// Short track, never seen playing yet: without the playing guard,
|
||||
// position 0 would already be within the slack of the end.
|
||||
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 3_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no media present after a track plays out is an ending`() {
|
||||
poll(TransportState.PLAYING, 199_500L)
|
||||
assertTrue(poll(TransportState.NO_MEDIA, 0L, durationMs = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pause near the end is not an ending`() {
|
||||
poll(TransportState.PLAYING, 199_000L)
|
||||
assertFalse(poll(TransportState.PAUSED, 199_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library duration stands in for a renderer that reports none`() {
|
||||
poll(TransportState.PLAYING, 178_000L, durationMs = 0L, known = 180_000L)
|
||||
assertTrue(poll(TransportState.STOPPED, 0L, durationMs = 0L, known = 180_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no duration from anywhere nothing is judged an ending`() {
|
||||
poll(TransportState.PLAYING, 5_000L, durationMs = 0L)
|
||||
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset forgets the track that was playing`() {
|
||||
poll(TransportState.PLAYING, 199_000L)
|
||||
d.reset()
|
||||
assertFalse(poll(TransportState.STOPPED, 0L))
|
||||
}
|
||||
}
|
||||
+40
-1
@@ -21,7 +21,7 @@ class AVTransportClientTest {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
|
||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl, sonos = true)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -229,6 +229,45 @@ class AVTransportClientTest {
|
||||
assertEquals(0, client.getMediaInfo().nrTracks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setNextAVTransportURI sends NextURI with its metadata`() = runTest {
|
||||
server.enqueue(emptyResponse("SetNextAVTransportURI"))
|
||||
client.setNextAVTransportURI(
|
||||
uri = "http://example.com/api/tracks/t2/stream.flac",
|
||||
mime = "audio/flac",
|
||||
title = "Next",
|
||||
)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("SetNextAVTransportURI")) { "body: $body" }
|
||||
assertTrue(body.contains("<NextURI>http://example.com/api/tracks/t2/stream.flac</NextURI>")) {
|
||||
"body: $body"
|
||||
}
|
||||
assertTrue(body.contains("<NextURIMetaData>")) { "body: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Sonos gets the Rincon metadata it requires`() = runTest {
|
||||
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||
client.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("RINCON_AssociatedZPUDN")) { "body: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a generic renderer gets plain DIDL that declares the stream seekable`() = runTest {
|
||||
val generic = AVTransportClient(
|
||||
SoapClient(OkHttpClient()),
|
||||
server.url("/MediaRenderer/AVTransport/Control"),
|
||||
sonos = false,
|
||||
)
|
||||
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||
generic.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertFalse(body.contains("RINCON")) { "body: $body" }
|
||||
assertFalse(body.contains("rinconnetworks")) { "body: $body" }
|
||||
assertTrue(body.contains("audio/flac:DLNA.ORG_OP=01")) { "body: $body" }
|
||||
}
|
||||
|
||||
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Back from the "install unknown apps" page, an update that was asked for carries on. */
|
||||
class AfterAskingTest {
|
||||
@Test
|
||||
fun `allowed now, the update carries on`() {
|
||||
assertEquals(AfterAsking.FETCH, afterAsking(asked = true, mayInstall = true, offered = true, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back without allowing it, the listener is told and the offer stays`() {
|
||||
assertEquals(AfterAsking.REFUSED, afterAsking(asked = true, mayInstall = false, offered = true, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing asked, nothing happens`() {
|
||||
assertEquals(AfterAsking.WAIT, afterAsking(asked = false, mayInstall = true, offered = true, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offer not known yet, or an install under way, waits`() {
|
||||
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = false, busy = false))
|
||||
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = true, busy = true))
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The fetch worker and the listener's Update both download through here; a
|
||||
* package only counts as ready once it is the whole build that was offered.
|
||||
*/
|
||||
class UpdateRepositoryTest {
|
||||
@TempDir
|
||||
lateinit var dir: File
|
||||
|
||||
private val apkBytes = "the newer app".toByteArray()
|
||||
private val sha = MessageDigest.getInstance("SHA-256").digest(apkBytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun wire(code: Long? = 200L, version: String = "2026.10.26.1200", sha256: String = sha) = UpdateInfoWire(
|
||||
version = version,
|
||||
code = code,
|
||||
sizeBytes = apkBytes.size.toLong(),
|
||||
sha256 = sha256,
|
||||
)
|
||||
|
||||
private var downloads = 0
|
||||
|
||||
private fun repo(
|
||||
version: () -> UpdateInfoWire = { wire() },
|
||||
body: () -> ByteArray = { apkBytes },
|
||||
offers: Boolean = true,
|
||||
) = UpdateRepository(
|
||||
api = object : ClientVersionApi {
|
||||
override suspend fun getVersion(): UpdateInfoWire = version()
|
||||
},
|
||||
apk = ApkSource {
|
||||
downloads += 1
|
||||
body().toResponseBody()
|
||||
},
|
||||
dir = dir,
|
||||
installedCode = 100L,
|
||||
installedName = "2026.10.20.0900",
|
||||
offers = offers,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a newer build is offered`() = runTest {
|
||||
assertIs<Offer.Newer>(repo().check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same app built again at a later minute is not offered`() = runTest {
|
||||
assertEquals(Offer.Current, repo(version = { wire(version = "2026.10.20.0900") }).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server with no app to offer is not a problem`() = runTest {
|
||||
val none = repo(version = { throw HttpException(Response.error<Any>(404, "".toResponseBody())) })
|
||||
assertEquals(Offer.Current, none.check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server out of reach is a problem, not an all-clear`() = runTest {
|
||||
assertIs<Offer.Problem>(repo(version = { throw IOException("unreachable") }).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a debug build is never offered one`() = runTest {
|
||||
assertEquals(Offer.Current, repo(offers = false).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a build already fetched is installed from disk without a second download`() = runTest {
|
||||
val r = repo()
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
assertIs<Download.Ready>(r.download(info) {})
|
||||
assertNotNull(r.ready(info))
|
||||
val again = r.download(info) {}
|
||||
assertIs<Download.Ready>(again)
|
||||
assertEquals(1, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a download cut off partway is not taken for the build`() = runTest {
|
||||
val r = repo(body = { apkBytes.copyOf(5) })
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
assertIs<Download.Problem>(r.download(info) {})
|
||||
assertNull(r.ready(info))
|
||||
assertTrue(dir.listFiles().orEmpty().isEmpty(), "the part file is removed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a download that does not match the digest is not taken for the build`() = runTest {
|
||||
val r = repo(body = { "the other app".toByteArray() }) // same length, other bytes
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
assertIs<Download.Problem>(r.download(info) {})
|
||||
assertNull(r.ready(info))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tidy drops a package kept for a build no longer offered`() = runTest {
|
||||
val r = repo()
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
r.download(info) {}
|
||||
r.tidy()
|
||||
assertNull(r.ready(info))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Home tells of each build at most once a day, and says whether it is ready. */
|
||||
class UpdateToldTest {
|
||||
private val day = SAY_AGAIN_AFTER_MS
|
||||
private val t0 = 1_000_000_000L
|
||||
|
||||
@Test
|
||||
fun `a build not yet told of is told at once`() {
|
||||
assertTrue(dueToSay("3523900", toldBuild = "3523847", toldAt = t0, now = t0 + 1))
|
||||
assertTrue(dueToSay("3523900", toldBuild = "", toldAt = 0L, now = t0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same build is not told again within a day`() {
|
||||
assertFalse(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day - 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same build is told again the day after`() {
|
||||
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock set back tells rather than hiding the update`() {
|
||||
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 - 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a build is keyed by its code, or by its name from a server with none`() {
|
||||
assertEquals("3523900", buildKey(3523900L, "2026.09.10.1432"))
|
||||
assertEquals("2026.09.10.1432", buildKey(null, "2026.09.10.1432"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snackbar says whether the update is already here`() {
|
||||
assertEquals(
|
||||
"Version 2026.10.26.1200 of the app is on your server.",
|
||||
toldWords("2026.10.26.1200", ready = false),
|
||||
)
|
||||
assertEquals(
|
||||
"Version 2026.10.26.1200 of the app is downloaded and ready.",
|
||||
toldWords("2026.10.26.1200", ready = true),
|
||||
)
|
||||
assertEquals("Update", toldAction(ready = false))
|
||||
assertEquals("Install", toldAction(ready = true))
|
||||
}
|
||||
}
|
||||
+15
@@ -62,6 +62,21 @@ class UpdateVersioningTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every server image builds the app again at a later minute, so a CI
|
||||
* re-run, or a change outside the shipped set, raises the code while the
|
||||
* app is the same one. The name says so, and it is not offered.
|
||||
*/
|
||||
@Test
|
||||
fun `the same app built again at a later minute is not offered`() {
|
||||
assertFalse(
|
||||
isUpdateAvailable(
|
||||
serverCode = 3523900, serverName = "2026.09.10.1432",
|
||||
installedCode = 3523847, installedName = "2026.09.10.1432",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the name when the server reports no ordering key`() {
|
||||
assertTrue(
|
||||
|
||||
+41
-11
@@ -3,11 +3,17 @@
|
||||
# Derives the three values a build is stamped with, and the tag that names it.
|
||||
#
|
||||
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
|
||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
|
||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED,
|
||||
# or SHIPPED_APK with --apk)
|
||||
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
||||
# tag=v<name> what a release of this commit must be called
|
||||
#
|
||||
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
|
||||
# Usage: ci/version.sh [--apk] [<commit-ish>] (default HEAD)
|
||||
#
|
||||
# --apk names the Android app from android/ alone. The app compares this
|
||||
# name with the one it is running, and a different name is what
|
||||
# offers an update — so a name that moved for a server-only commit
|
||||
# would offer every phone a rebuild of the app it already has.
|
||||
#
|
||||
# This exists as a script rather than inline workflow YAML for one reason:
|
||||
# release.yml only runs on `main` and on tags, so anything living inside it is
|
||||
@@ -30,6 +36,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
|
||||
|
||||
apk=false
|
||||
if [ "${1:-}" = "--apk" ]; then
|
||||
apk=true
|
||||
shift
|
||||
fi
|
||||
readonly REF="${1:-HEAD}"
|
||||
|
||||
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||
@@ -48,14 +60,17 @@ readonly REF="${1:-HEAD}"
|
||||
# wrongly included → version moves when nothing shipped. Cosmetic noise in
|
||||
# a string nobody sorts.
|
||||
#
|
||||
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
|
||||
# is shorter than it looks like it should be. The server image ships cmd/,
|
||||
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
|
||||
# android/. Neither ships the other's sources — but excluding android/ here
|
||||
# would stop an Android-only commit from moving the APK's OWN version, which
|
||||
# is the dangerous direction. So this is the union: exclude only what ships in
|
||||
# NEITHER, and accept that an Android commit also nudges the server's reported
|
||||
# version. Over-inclusion across the two, which is the harmless direction.
|
||||
# THIS LIST NAMES THE SERVER IMAGE, and it is shorter than it looks like it
|
||||
# should be. The image ships cmd/, internal/, shared/, web/,
|
||||
# config.example.yaml and client/ — and client/ holds the APK, so android/
|
||||
# stays in: an Android-only commit changes what the image carries. Exclude
|
||||
# only what ships in neither artifact; over-inclusion is the harmless
|
||||
# direction here.
|
||||
#
|
||||
# The APK itself is named from SHIPPED_APK (below), where over-inclusion is
|
||||
# NOT harmless: the app offers an update whenever the name differs from its
|
||||
# own, so a name moved by a server-only commit offers every phone a rebuild
|
||||
# of the app it already has.
|
||||
#
|
||||
# The family's other repos (roundtable / roundtable-android) each keep a
|
||||
# tighter list because they are separate repos with one artifact apiece. Do
|
||||
@@ -100,9 +115,24 @@ readonly SHIPPED=(
|
||||
':!web/vitest.setup.ts'
|
||||
)
|
||||
|
||||
# What the APK is built from: android/ and nothing else (the Gradle build
|
||||
# reads no path outside it). Still a denylist within it, for the same reason
|
||||
# as above — a new directory under android/ counts until named here.
|
||||
readonly SHIPPED_APK=(
|
||||
android
|
||||
':!android/app/src/test' # JVM unit tests
|
||||
':!android/*.md'
|
||||
)
|
||||
|
||||
if [ "${apk}" = true ]; then
|
||||
shipped=("${SHIPPED_APK[@]}")
|
||||
else
|
||||
shipped=("${SHIPPED[@]}")
|
||||
fi
|
||||
|
||||
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||
if [ -z "${commit_epoch}" ]; then
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${shipped[@]}")"
|
||||
# Loudly, on purpose. A silent fallback here is the landmine this whole
|
||||
# script exists to avoid: a plausible-looking version that is quietly wrong,
|
||||
# on a green run. Realistically this means a shallow clone (no commit in
|
||||
|
||||
@@ -17,8 +17,11 @@ package api
|
||||
// client treats 404 as "no update channel available."
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -62,6 +65,41 @@ var (
|
||||
clientAPKLastDownloadMu sync.Mutex
|
||||
)
|
||||
|
||||
// clientAPKSum is the bundled APK's sha256, kept until the file changes
|
||||
// under it (another size or modification time), so a version check every
|
||||
// few minutes from every phone does not re-read tens of megabytes each time.
|
||||
type clientAPKSum struct {
|
||||
size int64
|
||||
mod time.Time
|
||||
hex string
|
||||
}
|
||||
|
||||
var (
|
||||
clientAPKSumCache clientAPKSum
|
||||
clientAPKSumCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// clientAPKDigest returns the sha256 of the APK at path, in hex.
|
||||
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
|
||||
clientAPKSumCacheMu.Lock()
|
||||
defer clientAPKSumCacheMu.Unlock()
|
||||
c := clientAPKSumCache
|
||||
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
|
||||
return c.hex, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
|
||||
return clientAPKSumCache.hex, nil
|
||||
}
|
||||
|
||||
func testResetClientAPKRateLimit() {
|
||||
clientAPKLastDownloadMu.Lock()
|
||||
defer clientAPKLastDownloadMu.Unlock()
|
||||
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
APKURL string `json:"apk_url"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
// SHA256 is worked out from the file being served, not copied from the
|
||||
// sidecar, so it cannot disagree with the bytes the phone receives. The
|
||||
// app downloads ahead on Wi-Fi and installs later; this is how it knows
|
||||
// the file it kept is the whole build that was offered.
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleClientVersion returns the bundled Android client version + a
|
||||
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sum, err := clientAPKDigest(apkPath, stat)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, clientVersionResponse{
|
||||
Version: strings.TrimSpace(sidecar.Name),
|
||||
Code: sidecar.Code,
|
||||
Channel: strings.TrimSpace(sidecar.Channel),
|
||||
APKURL: "/api/client/apk",
|
||||
SizeBytes: stat.Size(),
|
||||
SHA256: sum,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
if resp.SizeBytes != size {
|
||||
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
||||
}
|
||||
want := sha256.Sum256([]byte("fake apk content"))
|
||||
if resp.SHA256 != hex.EncodeToString(want[:]) {
|
||||
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// The digest is cached by size and modification time; a new APK dropped in
|
||||
// place must be answered with its own digest, not the old one's.
|
||||
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
|
||||
writeClientAssets(t, `{"name":"a","code":1}`)
|
||||
first := decodeClientVersion(t, getClientVersion(t))
|
||||
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
|
||||
apk := filepath.Join(dir, clientAPKFilename)
|
||||
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := decodeClientVersion(t, getClientVersion(t))
|
||||
want := sha256.Sum256([]byte("another, longer build"))
|
||||
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
|
||||
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
|
||||
t.Helper()
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp clientVersionResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// A release published before ordering keys were recorded has a name and
|
||||
|
||||
+12
-9
@@ -20,14 +20,13 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Action is the discriminator stored in audit_log.action. New values
|
||||
// added in U2/U3 + future tasks; declare them here so callers don't
|
||||
// stringly-type and so a future audit-search UI has a single source
|
||||
// of truth for the vocabulary.
|
||||
// Action is the discriminator stored in audit_log.action. Every value is
|
||||
// declared here, so callers never stringly-type an action and the vocabulary
|
||||
// has one source.
|
||||
type Action string
|
||||
|
||||
const (
|
||||
// U1
|
||||
// Accounts and invites.
|
||||
ActionRegister Action = "register"
|
||||
ActionPromoteAdmin Action = "promote_admin"
|
||||
ActionDemoteAdmin Action = "demote_admin"
|
||||
@@ -35,15 +34,13 @@ const (
|
||||
ActionInviteRedeem Action = "invite_redeem"
|
||||
ActionInviteRevoke Action = "invite_revoke"
|
||||
|
||||
// U2 (declared early; callers in U1 don't use them yet, but
|
||||
// having them here means U2's diff is purely additive on the
|
||||
// caller side, not also touching this file).
|
||||
// Admin user management.
|
||||
ActionCreateUserAdmin Action = "create_user_admin"
|
||||
ActionDeleteUser Action = "delete_user"
|
||||
ActionPasswordResetAdmin Action = "password_reset_admin"
|
||||
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
||||
|
||||
// U3
|
||||
// Credentials.
|
||||
ActionPasswordChangeSelf Action = "password_change_self"
|
||||
ActionTokenRegenerate Action = "token_regenerate"
|
||||
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
||||
@@ -65,6 +62,12 @@ const (
|
||||
ActionDuplicateDismiss Action = "duplicate_dismiss"
|
||||
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
|
||||
|
||||
// An admin removing a track: its file and row are deleted, with every
|
||||
// like, play and playlist entry that hung off the row. The metadata names
|
||||
// the track and its file, so the log can say what went after the row is
|
||||
// gone.
|
||||
ActionTrackRemove Action = "track_remove"
|
||||
|
||||
// Subsonic password: generated in Settings for t/s-only clients.
|
||||
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
||||
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
||||
|
||||
@@ -272,73 +272,41 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
||||
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
||||
// its freshly-built APK, then overwrite it with the previous release's. The
|
||||
// image would still build and the sidecar would still parse — it would just
|
||||
// quietly serve stale art to the channel whose whole job is being current.
|
||||
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
// A merge to main is enough to put a new app in front of stable phones: main
|
||||
// builds the signed APK from its own commit and bakes it into :latest, as dev
|
||||
// does for :dev. The regression to guard is main falling back to a tag-only
|
||||
// APK build, which leaves every merged app change waiting on a release while
|
||||
// each build stays green.
|
||||
func TestEveryBranchShipsTheAppBuiltFromItsOwnCommit(t *testing.T) {
|
||||
jobs := releaseJobs(t)
|
||||
|
||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||
if i < 0 {
|
||||
t.Fatal("no 'Bundle latest release APK' step")
|
||||
}
|
||||
step := yaml[i:]
|
||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||
step = step[:j]
|
||||
}
|
||||
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
|
||||
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
|
||||
// command substitution whose grep matches NOTHING fatal at the assignment —
|
||||
// pipefail reports the grep's non-zero status even though `head` succeeded —
|
||||
// so the step dies before reaching the `if` written to handle the empty case.
|
||||
//
|
||||
// This is not hypothetical. It took down the first `main` build after the
|
||||
// version rework: the newest release predated sidecar assets, its
|
||||
// `.apk.version` grep matched nothing, and the step aborted instead of
|
||||
// falling through to the name-only branch that exists precisely for it. The
|
||||
// same latent hazard sat on the other two assignments and had simply never
|
||||
// fired, because their greps always matched.
|
||||
//
|
||||
// Pins the property that makes every "degrades gracefully" branch in that
|
||||
// step reachable at all.
|
||||
func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
|
||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||
if i < 0 {
|
||||
t.Fatal("no 'Bundle latest release APK' step")
|
||||
}
|
||||
step := yaml[i:]
|
||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||
step = step[:j]
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(step, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
|
||||
continue
|
||||
build := strings.Join(strings.Fields(jobs["android-release"].If), " ")
|
||||
for _, ref := range []string{"refs/heads/main", "refs/heads/dev", "refs/tags/v"} {
|
||||
if !strings.Contains(build, ref) {
|
||||
t.Errorf("android-release does not build on %s, so that ref's image carries no app of its own\n if: %s", ref, build)
|
||||
}
|
||||
// Only assignments from a command substitution can abort the step.
|
||||
if !strings.Contains(trimmed, `="$(`) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, "|| true") {
|
||||
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
|
||||
"skipping the empty-case branch below it:\n %s", trimmed)
|
||||
}
|
||||
|
||||
image := strings.Join(strings.Fields(jobs["image-release"].If), " ")
|
||||
if !strings.Contains(image, "needs.android-release.result == 'success'") || strings.Contains(image, "'skipped'") {
|
||||
t.Errorf("image-release can publish without a freshly-built APK\n if: %s", image)
|
||||
}
|
||||
|
||||
yaml := releaseYAML(t)
|
||||
if strings.Contains(yaml, "releases/latest") {
|
||||
t.Error("release.yml fetches the latest release; an image bundling a release's APK ships an app older than its own commit")
|
||||
}
|
||||
|
||||
// Both bundling steps run on every ref. Sliced from image-release on,
|
||||
// because release-assets has a step of the same name.
|
||||
i := strings.Index(yaml, "\n image-release:")
|
||||
if i < 0 {
|
||||
t.Fatal("no image-release job")
|
||||
}
|
||||
for _, name := range []string{"Download signed APK artifact", "Stage bundled APK + version sidecar"} {
|
||||
step := stepBody(t, yaml[i:], name)
|
||||
if strings.Contains(step, "github.ref") {
|
||||
t.Errorf("%q is gated by ref, so some ref's image ships without its APK\n%s", name, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,7 +344,7 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
||||
t.Errorf("tag arm does not refresh :latest — the server would hand out a different APK build from the one attached to the Release\n%s", arm)
|
||||
}
|
||||
if strings.Contains(arm, "GITHUB_SHA") {
|
||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
||||
@@ -455,7 +423,7 @@ func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
|
||||
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
|
||||
}
|
||||
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
|
||||
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
|
||||
t.Error("the image job no longer derives its version from ci/version.sh — the image's version has drifted out of test coverage")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,10 +476,11 @@ func commitFile(t *testing.T, dir, path, epoch string) {
|
||||
}
|
||||
|
||||
// versionIn runs ci/version.sh inside dir, reading real git rather than the
|
||||
// pinned-clock override, so the PATHSPEC is what is under test.
|
||||
func versionIn(t *testing.T, dir string) (string, error) {
|
||||
// pinned-clock override, so the PATHSPEC is what is under test. flags go
|
||||
// before the ref, as in `--apk`.
|
||||
func versionIn(t *testing.T, dir string, flags ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
|
||||
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), append(flags, "HEAD")...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
@@ -558,11 +527,9 @@ func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// android/ is deliberately NOT excluded, and that is the subtle half of the
|
||||
// list. It ships in no server image — but it is the APK's entire source, and
|
||||
// ONE script derives the version for both artifacts. Excluding it would stop
|
||||
// an Android-only commit from moving the APK's own version, which is exactly
|
||||
// the silent downgrade the versioning rework exists to prevent.
|
||||
// android/ is deliberately NOT excluded from the image's name: the image
|
||||
// bundles the APK under client/, so an Android-only commit changes what the
|
||||
// image carries, and an image that changed must not keep its old name.
|
||||
func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", "1757443736")
|
||||
@@ -573,7 +540,71 @@ func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
|
||||
t.Errorf("an Android commit did not move the image's version; it carries a new APK under its old name\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The app offers an update whenever the server's APK has a different name
|
||||
// from its own, so the APK's name must move for Android commits and ONLY for
|
||||
// them. A name taken from the whole repo would offer every phone a rebuild of
|
||||
// the app it already has on each server-only deploy.
|
||||
func TestApkName_MovesOnlyForAndroidCommits(t *testing.T) {
|
||||
const (
|
||||
app = "1757443736" // 2025.09.09.1848
|
||||
server = "1789000920" // 2026.09.10.0042, later
|
||||
again = "1789600000" // 2026.09.16.2306, later still
|
||||
)
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "android/app/src/main/Thing.kt", app)
|
||||
commitFile(t, dir, "internal/server/thing.go", server)
|
||||
commitFile(t, dir, "web/src/routes/page.svelte", server)
|
||||
|
||||
out, err := versionIn(t, dir, "--apk")
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("a server-only commit moved the APK's name; every phone would be offered the app it already has\n%s", out)
|
||||
}
|
||||
// The image's name still follows the server commit, so the test above
|
||||
// is not passing because the flag is ignored and everything is stale.
|
||||
out, err = versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("without --apk the name should follow the server commit\n%s", out)
|
||||
}
|
||||
|
||||
// A test-only Android commit ships nothing either.
|
||||
commitFile(t, dir, "android/app/src/test/java/A.kt", again)
|
||||
out, err = versionIn(t, dir, "--apk")
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("an Android test-only commit moved the APK's name\n%s", out)
|
||||
}
|
||||
|
||||
// ...and a real Android commit must move it, or new app code would ship
|
||||
// under the name of the old and never be offered.
|
||||
commitFile(t, dir, "android/app/src/main/Thing.kt", again)
|
||||
out, err = versionIn(t, dir, "--apk")
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.16.2306") {
|
||||
t.Errorf("an Android commit did not move the APK's name; the new app would never be offered\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The APK lane must ask for the APK's name. Without the flag it would still
|
||||
// build, sign and publish, and quietly go back to offering an update on every
|
||||
// server deploy.
|
||||
func TestReleaseWorkflow_NamesTheApkFromAndroidAlone(t *testing.T) {
|
||||
step := stepBody(t, releaseYAML(t), "Compute release version")
|
||||
if !strings.Contains(step, "ci/version.sh --apk HEAD") {
|
||||
t.Errorf("the APK lane does not derive its version with --apk\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func seedPlayHistory(t *testing.T) playHistoryFixture {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
other, err := q.CreateUser(ctx, dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + "other", PasswordHash: "x", ApiTokenHash: "x",
|
||||
Username: dbtest.TestUserPrefix + "other", PasswordHash: "x", ApiTokenHash: "y",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create other user: %v", err)
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||
)
|
||||
@@ -57,11 +59,32 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Servic
|
||||
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
|
||||
// deleted — see library.DeleteTrackFile for why that order is the contract.
|
||||
//
|
||||
// adminID is accepted for the audit log but not yet written to it.
|
||||
// A removal is written to the audit log with adminID as the actor. The
|
||||
// track's names are read before the delete, since the row, and perhaps its
|
||||
// album and artist, are gone after it.
|
||||
func (s *Service) RemoveTrack(
|
||||
ctx context.Context,
|
||||
trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up
|
||||
ctx context.Context, trackID, adminID pgtype.UUID,
|
||||
) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err error) {
|
||||
q := dbq.New(s.pool)
|
||||
track, err := q.GetTrackByID(ctx, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, ErrNotFound
|
||||
}
|
||||
return nil, nil, fmt.Errorf("get track: %w", err)
|
||||
}
|
||||
meta := map[string]any{
|
||||
"track_id": syncpkg.FormatUUID(track.ID),
|
||||
"title": track.Title,
|
||||
"file_path": track.FilePath,
|
||||
}
|
||||
if artist, err := q.GetArtistByID(ctx, track.ArtistID); err == nil {
|
||||
meta["artist"] = artist.Name
|
||||
}
|
||||
if album, err := q.GetAlbumByID(ctx, track.AlbumID); err == nil {
|
||||
meta["album"] = album.Title
|
||||
}
|
||||
|
||||
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrTrackNotFound) {
|
||||
@@ -69,6 +92,9 @@ func (s *Service) RemoveTrack(
|
||||
}
|
||||
return nil, nil, fmt.Errorf("delete track: %w", err)
|
||||
}
|
||||
meta["album_deleted"] = deleted.AlbumID != nil
|
||||
meta["artist_deleted"] = deleted.ArtistID != nil
|
||||
audit.WriteOrLog(ctx, s.pool, s.logger, adminID, pgtype.UUID{}, audit.ActionTrackRemove, meta)
|
||||
return deleted.AlbumID, deleted.ArtistID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,47 @@ func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_WritesAuditRow(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM audit_log`); err != nil {
|
||||
t.Fatalf("reset audit_log: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "track.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, track := seedArtistAlbumTrack(t, pool, "Audited", "", path)
|
||||
|
||||
svc := NewService(pool, nil, "")
|
||||
if _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID); err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
|
||||
var actor pgtype.UUID
|
||||
var title, artist, album, filePath string
|
||||
var albumDeleted bool
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
SELECT actor_id, metadata->>'title', metadata->>'artist', metadata->>'album',
|
||||
metadata->>'file_path', (metadata->>'album_deleted')::boolean
|
||||
FROM audit_log WHERE action = 'track_remove'`).
|
||||
Scan(&actor, &title, &artist, &album, &filePath, &albumDeleted)
|
||||
if err != nil {
|
||||
t.Fatalf("read audit row: %v", err)
|
||||
}
|
||||
if actor != admin.ID {
|
||||
t.Errorf("actor = %v, want the admin %v", actor, admin.ID)
|
||||
}
|
||||
if title != "Audited Track" || artist != "Audited Artist" || album != "Audited Album" || filePath != path {
|
||||
t.Errorf("metadata = %q / %q / %q / %q", title, artist, album, filePath)
|
||||
}
|
||||
if !albumDeleted {
|
||||
t.Errorf("album_deleted = false, want true: the track was its album's only one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
admin := seedAdmin(t, pool, "alice")
|
||||
|
||||
Reference in New Issue
Block a user