ui: roll back rejected multi-step queue edits

This commit is contained in:
austinried
2026-11-14 12:05:29 +00:00
parent 4d5328d8a0
commit 2766affa65
4 changed files with 256 additions and 54 deletions
@@ -120,7 +120,7 @@ class QueueViewModel(
private set private set
private var reorderPending = false private var reorderPending = false
private var lastReorder: ReorderRollback? = null private var dragRollback: ReorderRollback? = null
private var nextId = 0L private var nextId = 0L
private val mutex = Mutex() private val mutex = Mutex()
@@ -251,14 +251,13 @@ class QueueViewModel(
fromIndex: Int, fromIndex: Int,
toIndex: Int, toIndex: Int,
) { ) {
lastReorder = null
if (fromIndex == toIndex) return if (fromIndex == toIndex) return
if (fromIndex !in rows.indices || toIndex !in rows.indices) return if (fromIndex !in rows.indices || toIndex !in rows.indices) return
if (rows[fromIndex].upNext != rows[toIndex].upNext) return if (rows[fromIndex].upNext != rows[toIndex].upNext) return
if (rows[fromIndex].resetVersion != rows[toIndex].resetVersion) return if (rows[fromIndex].resetVersion != rows[toIndex].resetVersion) return
val before = rows.map { it.id } val before = dragRollback?.before ?: rows.map { it.id }
rows.add(toIndex, rows.removeAt(fromIndex)) rows.add(toIndex, rows.removeAt(fromIndex))
lastReorder = ReorderRollback(before, rows.map { it.id }) dragRollback = ReorderRollback(before, rows.map { it.id })
} }
fun play(position: Long) { fun play(position: Long) {
@@ -275,7 +274,7 @@ class QueueViewModel(
private fun rejectMove( private fun rejectMove(
edit: ReorderRollback?, edit: ReorderRollback?,
onComplete: (Boolean) -> Unit, onComplete: (Boolean) -> Unit = {},
): Boolean { ): Boolean {
viewModelScope.launch { viewModelScope.launch {
mutex.withLock { mutex.withLock {
@@ -287,27 +286,28 @@ class QueueViewModel(
return false return false
} }
fun cancelMove() {
val edit = dragRollback ?: return
dragRollback = null
rejectMove(edit)
}
fun move( fun move(
from: Long, from: Long,
to: Long, to: Long,
targetResetVersion: Long? = null, targetResetVersion: Long? = null,
onComplete: (Boolean) -> Unit = {}, onComplete: (Boolean) -> Unit = {},
): Boolean { ): Boolean {
val edit = lastReorder val edit = dragRollback
lastReorder = null dragRollback = null
if (from == to || reorderPending) { if (from == to || reorderPending) return rejectMove(edit, onComplete)
onComplete(false)
return false
}
val resetVersion = rows.firstOrNull { it.position == from }?.resetVersion ?: return rejectMove(edit, onComplete) val resetVersion = rows.firstOrNull { it.position == from }?.resetVersion ?: return rejectMove(edit, onComplete)
val targetResetVersion = targetResetVersion ?: rows.firstOrNull { it.position == to }?.resetVersion ?: resetVersion val targetResetVersion = targetResetVersion ?: rows.firstOrNull { it.position == to }?.resetVersion ?: resetVersion
if (resetVersion != targetResetVersion) return rejectMove(edit, onComplete) if (resetVersion != targetResetVersion) return rejectMove(edit, onComplete)
reorderPending = true reorderPending = true
viewModelScope.launch { viewModelScope.launch {
try { try {
val contentVersion = queueRepository.contentVersion() val applied = playbackController.move(from, to, resetVersion)
playbackController.move(from, to, resetVersion)
val applied = queueRepository.contentVersion() != contentVersion
mutex.withLock { mutex.withLock {
if (!applied) rollbackReorder(edit) if (!applied) rollbackReorder(edit)
reconcileLocked() reconcileLocked()
@@ -340,10 +340,14 @@ class QueueViewModel(
reconcileLocked() reconcileLocked()
return@withLock false return@withLock false
} }
val contentVersion = queueRepository.contentVersion() val applied = playbackController.removeAt(expected.position, expected.resetVersion)
playbackController.removeAt(expected.position, expected.resetVersion) if (applied) {
val applied = queueRepository.contentVersion() != contentVersion val currentIndex =
if (applied) rows.removeAt(index) rows.indexOfFirst {
it.id == expected.id && it.position == expected.position && it.resetVersion == expected.resetVersion
}
if (currentIndex >= 0) rows.removeAt(currentIndex)
}
reconcileLocked() reconcileLocked()
applied applied
} }
@@ -395,6 +399,7 @@ fun QueueRoute(
onRemove = { position, onComplete -> viewModel.remove(position, onComplete) }, onRemove = { position, onComplete -> viewModel.remove(position, onComplete) },
onReorder = viewModel::reorder, onReorder = viewModel::reorder,
onMove = { move, onComplete -> viewModel.move(move.from, move.to, move.resetVersion, onComplete) }, onMove = { move, onComplete -> viewModel.move(move.from, move.to, move.resetVersion, onComplete) },
onCancelMove = viewModel::cancelMove,
onLoadOlder = viewModel::loadOlder, onLoadOlder = viewModel::loadOlder,
onLoadNewer = viewModel::loadNewer, onLoadNewer = viewModel::loadNewer,
onUndo = viewModel::undo, onUndo = viewModel::undo,
@@ -414,7 +419,8 @@ fun QueueScreen(
onPlay: (Long) -> Unit, onPlay: (Long) -> Unit,
onRemove: (Long, (Boolean) -> Unit) -> Unit, onRemove: (Long, (Boolean) -> Unit) -> Unit,
onReorder: (Int, Int) -> Unit, onReorder: (Int, Int) -> Unit,
onMove: (QueueMove, (Boolean) -> Unit) -> Boolean, onMove: (QueueMove, (Boolean) -> Unit) -> Unit,
onCancelMove: () -> Unit,
onLoadOlder: () -> Unit, onLoadOlder: () -> Unit,
onLoadNewer: () -> Unit, onLoadNewer: () -> Unit,
onUndo: () -> Unit, onUndo: () -> Unit,
@@ -554,11 +560,12 @@ fun QueueScreen(
Modifier.draggableHandle( Modifier.draggableHandle(
onDragStarted = { dragId = row.id }, onDragStarted = { dragId = row.id },
onDragStopped = { onDragStopped = {
dragId?.let { id -> val move = dragId?.let { id -> rows.dropMoveFor(id) }
rows.dropMoveFor(id)?.let { move -> if (move == null) {
onCancelMove()
} else {
onMove(move) { applied -> if (applied) showUndo(queueReordered) } onMove(move) { applied -> if (applied) showUndo(queueReordered) }
} }
}
dragId = null dragId = null
}, },
) )
@@ -573,7 +573,8 @@ class AppScreenshotTest {
onPlay = {}, onPlay = {},
onRemove = { _, _ -> }, onRemove = { _, _ -> },
onReorder = { _, _ -> }, onReorder = { _, _ -> },
onMove = { _, _ -> true }, onMove = { _, _ -> },
onCancelMove = {},
onLoadOlder = {}, onLoadOlder = {},
onLoadNewer = {}, onLoadNewer = {},
onUndo = {}, onUndo = {},
@@ -78,10 +78,7 @@ class QueueScreenTest {
visible: List<QueueRow> = rows, visible: List<QueueRow> = rows,
shuffle: Boolean = false, shuffle: Boolean = false,
onRemove: (Long, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) }, onRemove: (Long, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
onMove: (QueueMove, (Boolean) -> Unit) -> Boolean = { _, onComplete -> onMove: (QueueMove, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
onComplete(false)
true
},
onUndo: () -> Unit = {}, onUndo: () -> Unit = {},
) { ) {
val visibleRows = mutableStateListOf<QueueRow>().also { it.addAll(visible) } val visibleRows = mutableStateListOf<QueueRow>().also { it.addAll(visible) }
@@ -99,6 +96,7 @@ class QueueScreenTest {
onRemove = onRemove, onRemove = onRemove,
onReorder = { from, to -> visibleRows.add(to, visibleRows.removeAt(from)) }, onReorder = { from, to -> visibleRows.add(to, visibleRows.removeAt(from)) },
onMove = onMove, onMove = onMove,
onCancelMove = {},
onLoadOlder = {}, onLoadOlder = {},
onLoadNewer = {}, onLoadNewer = {},
onUndo = onUndo, onUndo = onUndo,
@@ -29,6 +29,7 @@ import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -36,11 +37,13 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -50,7 +53,9 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@@ -536,6 +541,164 @@ class QueueViewModelTest {
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds()) assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
} }
@Test
fun moveWaitingForQueuedResetDoesNotReportTheResetAsApplied() {
runBlocking { seedSongs(3) }
controller.playAlbum(1, "al1", 0)
await { controller.state.value.position == 0L }
viewModel.open()
await { viewModel.ready }
val point = prepareAlbumReset("s0", 0)
val paused = pauseQueueMutation()
val reset =
repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
}
val completion = CompletableDeferred<Boolean>()
try {
runOnMainSuspending {
viewModel.reorder(1, 2)
assertTrue(viewModel.move(1, 2, onComplete = completion::complete))
yield()
}
} finally {
resumeQueueMutation(paused)
}
assertFalse(awaitCompletion(completion))
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
}
@Test
fun removalWaitingForQueuedResetDoesNotReportTheResetAsApplied() {
runBlocking { seedSongs(3) }
controller.playAlbum(1, "al1", 0)
await { controller.state.value.position == 0L }
viewModel.open()
await { viewModel.ready }
val point = prepareAlbumReset("s0", 0)
val paused = pauseQueueMutation()
val reset =
repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
}
val completion = CompletableDeferred<Boolean>()
try {
runOnMainSuspending {
viewModel.remove(2, completion::complete)
yield()
}
} finally {
resumeQueueMutation(paused)
}
assertFalse(awaitCompletion(completion))
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
}
@Test
fun rejectedTwoCallbackSparseTailDragRestoresTheDragBaseline() {
openReconciledSparseOfflineQueue(includeFarTail = true)
val canonical = resolvedQueueIds()
val before = rowsSnapshot()
val dragged = before[1]
val completion = CompletableDeferred<Boolean>()
runOnMain {
viewModel.reorder(1, 2)
viewModel.reorder(2, 3)
val move = viewModel.rows.dropMoveFor(dragged.id)!!
assertTrue(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
}
assertFalse(awaitCompletion(completion))
assertEquals(canonical, resolvedQueueIds())
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
}
@Test
fun rejectedMixedGenerationCrossingRestoresTheWholeDrag() {
runOnMain {
viewModel.rows += row(0)
viewModel.rows += row(1)
viewModel.rows += row(2)
}
val before = rowsSnapshot()
val dragged = before[0]
val completion = CompletableDeferred<Boolean>()
runOnMain {
viewModel.reorder(0, 1)
viewModel.rows[2] = viewModel.rows[2].copy(resetVersion = 1)
viewModel.reorder(1, 2)
val move = viewModel.rows.dropMoveFor(dragged.id)!!
assertFalse(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
}
assertFalse(awaitCompletion(completion))
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
}
@Test
fun secondOptimisticDragRejectedWhileMovePendingRollsBack() {
openSparseOfflineQueue()
val before = rowsSnapshot()
val visibleIds = before.map { it.song.song.id }.toSet()
val expectedFirst = before.toMutableList().also { it.add(1, it.removeAt(0)) }.map { it.song.song.id }
val expectedSecond =
before
.toMutableList()
.also { it.add(1, it.removeAt(0)) }
.also { it.add(1, it.removeAt(2)) }
.map { it.song.song.id }
val paused = pauseQueueMutation()
val firstCompletion = CompletableDeferred<Boolean>()
val secondCompletion = CompletableDeferred<Boolean>()
try {
runOnMainSuspending {
viewModel.reorder(0, 1)
val firstMove = viewModel.rows.dropMoveFor(before[0].id)!!
assertTrue(viewModel.move(firstMove.from, firstMove.to, firstMove.resetVersion, firstCompletion::complete))
yield()
viewModel.reorder(2, 1)
assertEquals(expectedSecond, viewModel.rows.map { it.song.song.id })
val secondMove = viewModel.rows.dropMoveFor(before[2].id)!!
assertFalse(viewModel.move(secondMove.from, secondMove.to, secondMove.resetVersion, secondCompletion::complete))
}
await { rowsSnapshot().map { it.song.song.id } == expectedFirst }
} finally {
resumeQueueMutation(paused)
}
assertTrue(awaitCompletion(firstCompletion))
assertFalse(awaitCompletion(secondCompletion))
assertEquals(expectedFirst, resolvedQueueIds().filter { it in visibleIds })
}
@Test
fun removalAfterInterleavedReorderRemovesTheCapturedRow() {
openSparseOfflineQueue()
val before = rowsSnapshot()
val paused = pauseQueueMutation()
val completion = CompletableDeferred<Boolean>()
try {
runOnMainSuspending {
viewModel.remove(before[2].position, completion::complete)
yield()
viewModel.reorder(0, 2)
}
assertEquals(listOf("s801", "s901", "s1"), rowsSnapshot().map { it.song.song.id })
} finally {
resumeQueueMutation(paused)
}
assertTrue(awaitCompletion(completion))
assertEquals(listOf("s1", "s801"), rowsSnapshot().map { it.song.song.id })
assertEquals(listOf("s1", "s801"), resolvedQueueIds().filter { it in setOf("s1", "s801", "s901") })
}
@Test @Test
fun moveRejectsRowsFromDifferentResetGenerations() { fun moveRejectsRowsFromDifferentResetGenerations() {
runOnMain { runOnMain {
@@ -594,11 +757,12 @@ class QueueViewModelTest {
assertEquals(3L, controller.state.value.position) assertEquals(3L, controller.state.value.position)
} }
private fun openReconciledSparseOfflineQueue() { private fun openSparseOfflineQueue(includeFarTail: Boolean = false) {
runBlocking { seedSongs(901) } runBlocking { seedSongs(if (includeFarTail) 1001 else 901) }
markDownloaded(1, "s1") markDownloaded(1, "s1")
markDownloaded(1, "s801") markDownloaded(1, "s801")
markDownloaded(1, "s901") markDownloaded(1, "s901")
if (includeFarTail) markDownloaded(1, "s1001")
setOffline() setOffline()
controller.playAlbum(1, "al1", 0) controller.playAlbum(1, "al1", 0)
await { await {
@@ -608,12 +772,17 @@ class QueueViewModelTest {
viewModel.open() viewModel.open()
await { viewModel.ready } await { viewModel.ready }
viewModel.loadNewer() viewModel.loadNewer()
await { rowsSnapshot().map { it.position } == listOf(0L, 800L, 900L) } val expected = if (includeFarTail) listOf(0L, 800L, 900L, 1000L) else listOf(0L, 800L, 900L)
await { rowsSnapshot().map { it.position } == expected }
}
private fun openReconciledSparseOfflineQueue(includeFarTail: Boolean = false) {
openSparseOfflineQueue(includeFarTail)
resetAlbumWith("s0", 0) resetAlbumWith("s0", 0)
viewModel.reconcile() viewModel.reconcile()
await { rowsSnapshot().firstOrNull()?.resetVersion == 1L } await { rowsSnapshot().firstOrNull()?.resetVersion == 1L }
assertEquals(listOf(1L, 800L, 900L), rowsSnapshot().map { it.position }) val expected = if (includeFarTail) listOf(1L, 800L, 900L, 1000L) else listOf(1L, 800L, 900L)
assertEquals(expected, rowsSnapshot().map { it.position })
} }
private fun resetAlbumWith( private fun resetAlbumWith(
@@ -621,7 +790,15 @@ class QueueViewModelTest {
track: Long, track: Long,
) { ) {
val layout = controller.state.value.layout val layout = controller.state.value.layout
runBlocking { val point = prepareAlbumReset(songId, track)
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point) }
await { controller.state.value.layout > layout }
}
private fun prepareAlbumReset(
songId: String,
track: Long,
) = runBlocking {
val point = queues.captureResetPoint() val point = queues.captureResetPoint()
db.libraryDao().upsertSongs( db.libraryDao().upsertSongs(
listOf( listOf(
@@ -641,9 +818,7 @@ class QueueViewModelTest {
), ),
), ),
) )
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point) point
}
await { controller.state.value.layout > layout }
} }
private fun resolvedQueueIds(): List<String> = private fun resolvedQueueIds(): List<String> =
@@ -770,12 +945,33 @@ class QueueViewModelTest {
) )
} }
private fun pauseQueueMutation(): Pair<CountDownLatch, Job> {
val locked = CountDownLatch(1)
val release = CountDownLatch(1)
val locker =
repoScope.launch {
queues.withMutationLock {
locked.countDown()
release.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
}
}
assertTrue(locked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
return release to locker
}
private fun resumeQueueMutation(paused: Pair<CountDownLatch, Job>) {
paused.first.countDown()
runBlocking { withTimeout(TEST_TIMEOUT_MS) { paused.second.join() } }
}
// `rows` is a snapshot list mutated on the main dispatcher, so read it there rather than from // `rows` is a snapshot list mutated on the main dispatcher, so read it there rather than from
// the test thread (otherwise iterating it races the view model and throws or reads torn state). // the test thread (otherwise iterating it races the view model and throws or reads torn state).
private fun rowsSnapshot(): List<QueueRow> = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } } private fun rowsSnapshot(): List<QueueRow> = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } }
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } } private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
private fun runOnMainSuspending(block: suspend () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
private fun awaitCompletion(completion: CompletableDeferred<Boolean>): Boolean = private fun awaitCompletion(completion: CompletableDeferred<Boolean>): Boolean =
runBlocking { withTimeout(TEST_TIMEOUT_MS) { completion.await() } } runBlocking { withTimeout(TEST_TIMEOUT_MS) { completion.await() } }