feat: per-user volume leveling preference, synced across devices (M464 #4998)
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / web (push) Failing after 1m5s
release / android (push) Failing after 1m51s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m12s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s

Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost
(within headroom, or fully with a limiter), stored per user on the server
so the web player, the Android app and casts apply the same one.

- Server: user_normalization_prefs (migration 0067), GET/PUT
  /api/me/normalization; a whole-body PUT, validated, last write wins.
- Web: Settings > Playback > Volume leveling. Saves at once, restores the
  old choice if the save fails, and caches the value for the player.
- Android: Settings card. The device keeps a copy for offline playback
  (Room v9 with an explicit migration, so the upgrade wipes nothing).
  Writes are offline-first: shown at once, PUT best effort, queued on
  failure (NORMALIZATION_SET, collapsed to the newest). A refresh never
  overwrites a change still queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:29:40 -04:00
co-authored by Claude Opus 5.5
parent e3aa8629d3
commit af36b2f24a
34 changed files with 1225 additions and 5 deletions
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
@@ -59,6 +60,14 @@ interface MeApi {
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
/** The caller's loudness-normalization preference, or the defaults if never set. */
@GET("api/me/normalization")
suspend fun getNormalization(): NormalizationPrefs
/** Replaces the whole preference; returns what the server stored. */
@PUT("api/me/normalization")
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
}
/**
@@ -4,6 +4,7 @@ import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
}
}
}
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT)
}
private fun decodeNormalization(raw: String?): NormalizationPrefs {
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
return runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), raw)
}.getOrDefault(NormalizationPrefs.DEFAULT)
}
/**
* Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) }
}
fun setNormalization(value: NormalizationPrefs) {
normalizationState.value = value
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
scope.launch { persistNormalization(encoded) }
}
private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -243,6 +261,14 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistNormalization(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(normalizationJson = json))
} else {
dao.setNormalizationJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
)
companion object {
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
@@ -65,13 +67,18 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class,
DiagnosticEventEntity::class,
],
// v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
// row — the server address and theme — and the queued offline writes,
// which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
version = 8,
version = 9,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -92,3 +99,10 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
}
/** v8 → v9: add the nullable normalization preference column (#4998). */
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
}
}
@@ -37,6 +37,7 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -46,4 +46,8 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean)
/** Partial update: change only the serialized normalization preference. */
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
suspend fun setNormalizationJson(json: String?)
}
@@ -31,4 +31,8 @@ interface CachedMutationDao {
@Query("DELETE FROM cached_mutations")
suspend fun clear()
/** Whether a write of [kind] is still waiting to be replayed. */
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
suspend fun hasPending(kind: String): Boolean
}
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
* choice lives here. Default false = honor the account flag.
*/
val diagnosticsOptOut: Boolean = false,
/**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here (M464 #4998). Null = never fetched; the
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
)
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -41,6 +42,11 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// M464 #4998 loudness-normalization preference. The payload is the whole
// preference, a target state like the toggles above, so queued changes
// collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set"
}
/**
@@ -177,6 +183,12 @@ class MutationQueue @Inject constructor(
),
)
/** Queues the user's whole normalization preference for replay. */
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
MutationKind.NORMALIZATION_SET,
json.encodeToString(NormalizationPrefs.serializer(), prefs),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val mutex = Mutex()
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
/**
* Sends the queued normalization preference. The device already shows
* it, so the server's echo is not written back: a change made since the
* row was queued would be a newer row, and the collapse keeps only that.
*/
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
return Outcome.SENT
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
/**
* Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles and suggestion snoozes (#2374) — because
* than an action — like-toggles, suggestion snoozes (#2374) and the
* normalization preference (#4998) — because
* replaying a stale one last would invert the final state.
*
* Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
// One preference per user, so every normalization row shares one key.
MutationKind.NORMALIZATION_SET -> runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
}.getOrNull()?.let { row.kind }
else -> null
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.settings.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The user's loudness-normalization preference (M464 #4998). The server
* holds it, so this device, the web player and casts all level the same
* way; a copy is kept on the device so offline playback still applies it.
* Field names and values match `GET/PUT /api/me/normalization`.
*
* No constructor defaults, on purpose: the app's Json leaves out any field
* equal to its default (encodeDefaults is off), and the server refuses a PUT
* missing a field. Without defaults every field is always sent.
*/
@Serializable
data class NormalizationPrefs(
val mode: NormalizationMode,
@SerialName("target_lufs") val targetLufs: Int,
val boost: NormalizationBoost,
) {
companion object {
/** The targets the server accepts, quietest first. */
val TARGETS: List<Int> = listOf(-18, -16, -14)
/** Must match library.DefaultNormalizationPrefs on the server. */
val DEFAULT: NormalizationPrefs = NormalizationPrefs(
mode = NormalizationMode.AUTO,
targetLufs = -18,
boost = NormalizationBoost.HEADROOM,
)
}
}
@Serializable
enum class NormalizationMode {
/** Tracks play at their mastered volume. */
@SerialName("off") OFF,
/** Album gain while an album plays in order, track gain otherwise. */
@SerialName("auto") AUTO,
@SerialName("track") TRACK,
@SerialName("album") ALBUM,
}
@Serializable
enum class NormalizationBoost {
/** Quiet tracks are raised only as far as their true peak allows. */
@SerialName("headroom") HEADROOM,
/** Quiet tracks are raised all the way and a limiter holds the peaks. */
@SerialName("limiter") LIMITER,
}
@@ -0,0 +1,67 @@
package com.fabledsword.minstrel.settings.data
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.mutations.MutationKind
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.StateFlow
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* The user's loudness-normalization preference (M464 #4998). The device's
* copy lives in [AuthStore] so playback reads it offline; the server's copy
* is what other devices and casts use.
*
* Writes follow rule 100: the device shows the change at once, the PUT is
* best effort, and a failed PUT is queued for the MutationReplayer.
*/
@Singleton
class NormalizationRepository @Inject constructor(
retrofit: Retrofit,
private val authStore: AuthStore,
private val mutationQueue: MutationQueue,
private val mutationDao: CachedMutationDao,
) {
private val api: MeApi = retrofit.create()
val prefs: StateFlow<NormalizationPrefs> = authStore.normalization
/**
* Takes the server's value, unless a change made here is still queued:
* the server has not seen it yet, and taking its older value would undo
* the change on screen until the replay landed. Throws on a network
* failure; the device's copy then stands.
*/
suspend fun refresh() {
val server = api.getNormalization()
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) return
authStore.setNormalization(server)
}
suspend fun set(next: NormalizationPrefs) {
authStore.setNormalization(next)
// An earlier change still queued would be replayed after this PUT
// and undo it. Queue behind it instead: the replayer sends only the
// newest queued preference.
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) {
mutationQueue.enqueueNormalizationSet(next)
return
}
try {
api.putNormalization(next)
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "normalization: PUT failed; queued for replay")
mutationQueue.enqueueNormalizationSet(next)
}
}
}
@@ -0,0 +1,143 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
/**
* Volume leveling (M464 #4998). The choice is stored on the server, so the
* web player and casts follow it too.
*/
@Composable
fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
val prefs by viewModel.prefs.collectAsStateWithLifecycle()
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NormalizationCardContent(
prefs: NormalizationPrefs,
onChange: (NormalizationPrefs) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Volume leveling",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Hint(modeHint(prefs.mode))
val modes = NormalizationMode.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
modes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == prefs.mode,
onClick = { onChange(prefs.copy(mode = mode)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = modes.size,
),
) { Text(modeLabel(mode)) }
}
}
if (prefs.mode != NormalizationMode.OFF) {
TargetRow(prefs = prefs, onChange = onChange)
BoostRow(prefs = prefs, onChange = onChange)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
Text(
text = "Target loudness",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val targets = NormalizationPrefs.TARGETS
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { index, lufs ->
SegmentedButton(
selected = lufs == prefs.targetLufs,
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = targets.size,
),
) { Text("$lufs LUFS") }
}
}
}
@Composable
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Boost quiet tracks fully",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Hint("A limiter catches the peaks.")
}
Spacer(Modifier.size(12.dp))
Switch(
checked = prefs.boost == NormalizationBoost.LIMITER,
onCheckedChange = { on ->
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
onChange(prefs.copy(boost = boost))
},
)
}
}
@Composable
private fun Hint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
NormalizationMode.OFF -> "Off"
NormalizationMode.AUTO -> "Auto"
NormalizationMode.TRACK -> "Track"
NormalizationMode.ALBUM -> "Album"
}
private fun modeHint(mode: NormalizationMode): String = when (mode) {
NormalizationMode.OFF -> "Tracks play at their mastered volume."
NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
NormalizationMode.TRACK -> "Every track at the same loudness."
NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
}
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.settings.data.NormalizationRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* Backs the Volume leveling card. Shows the device's copy straight away and
* refreshes it from the server on open, so a change made on the web shows
* here too.
*/
@HiltViewModel
class NormalizationViewModel @Inject constructor(
private val repository: NormalizationRepository,
) : ViewModel() {
val prefs: StateFlow<NormalizationPrefs> = repository.prefs
init {
viewModelScope.launch {
try {
repository.refresh()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
// Offline: the device's copy stands.
Timber.i(e, "normalization: refresh failed")
}
}
}
fun update(next: NormalizationPrefs) {
viewModelScope.launch { repository.set(next) }
}
}
@@ -161,6 +161,7 @@ private fun SettingsList(
onToggle = onToggleDiagnostics,
)
}
NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard()
AboutCard()
@@ -1,6 +1,8 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
fun `an empty queue collapses nothing`() {
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
}
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
id = id,
kind = MutationKind.NORMALIZATION_SET,
payload = json.encodeToString(
NormalizationPrefs.serializer(),
NormalizationPrefs.DEFAULT.copy(mode = mode),
),
)
// There is one preference per user, so any two queued changes to it
// collapse, and only the newest is sent (#4998).
@Test
fun `only the newest queued normalization change survives`() {
val rows = listOf(
normalizationRow(1, NormalizationMode.TRACK),
snoozeRow(2, "mb-a", desiredSnoozed = true),
normalizationRow(3, NormalizationMode.OFF),
normalizationRow(4, NormalizationMode.ALBUM),
)
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
}
}
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.settings.data
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.mutations.MutationKind
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import java.io.IOException
/**
* The normalization preference is written offline-first (rule 100): the
* device shows the change at once, and a change the server has not seen
* yet is never overwritten or reordered.
*/
class NormalizationRepositoryTest {
private val api: MeApi = mockk()
private val authStore: AuthStore = mockk(relaxed = true) {
every { normalization } returns MutableStateFlow(NormalizationPrefs.DEFAULT)
}
private val queue: MutationQueue = mockk(relaxed = true)
private val dao: CachedMutationDao = mockk()
private val retrofit: Retrofit = mockk {
every { create(MeApi::class.java) } returns api
}
private val repo = NormalizationRepository(retrofit, authStore, queue, dao)
private val album = NormalizationPrefs.DEFAULT.copy(mode = NormalizationMode.ALBUM)
@Test
fun `a change is shown at once and sent`() = runTest {
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
coEvery { api.putNormalization(album) } returns album
repo.set(album)
verify { authStore.setNormalization(album) }
coVerify { api.putNormalization(album) }
coVerify(exactly = 0) { queue.enqueueNormalizationSet(any()) }
}
@Test
fun `a change that cannot reach the server is queued`() = runTest {
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
coEvery { api.putNormalization(album) } throws IOException("offline")
repo.set(album)
verify { authStore.setNormalization(album) }
coVerify { queue.enqueueNormalizationSet(album) }
}
@Test
fun `a change behind a queued one queues too, so the older one cannot land last`() = runTest {
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
repo.set(album)
coVerify(exactly = 0) { api.putNormalization(any()) }
coVerify { queue.enqueueNormalizationSet(album) }
}
@Test
fun `refresh takes the server value`() = runTest {
coEvery { api.getNormalization() } returns album
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
repo.refresh()
verify { authStore.setNormalization(album) }
}
@Test
fun `refresh leaves a queued change on screen`() = runTest {
coEvery { api.getNormalization() } returns NormalizationPrefs.DEFAULT
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
repo.refresh()
verify(exactly = 0) { authStore.setNormalization(any()) }
}
}