summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt38
-rw-r--r--app/src/main/java/org/terst/listen/work/QueuedDownloads.kt36
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt24
-rw-r--r--app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt57
-rw-r--r--app/src/test/java/org/terst/listen/work/QueuedDownloadsTest.kt103
5 files changed, 250 insertions, 8 deletions
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index 2a2d74e..da3c8d6 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -15,6 +15,8 @@ import org.terst.listen.queue.DripReleaser
import org.terst.listen.queue.QueueOrdering
import org.terst.listen.queue.RetentionPolicy
import org.terst.listen.queue.RuleApplier
+import org.terst.listen.work.QueuedDownloads
+import org.terst.listen.work.Scheduling
import java.io.File
import java.util.concurrent.TimeUnit
@@ -88,7 +90,15 @@ class AppGraph(private val context: Context) {
RetentionPolicy(gracePeriodMs = TimeUnit.DAYS.toMillis(1))
}
- val queue: QueueRepository by lazy { QueueRepository(queueDao) }
+ private val queuedDownloads: QueuedDownloads by lazy { QueuedDownloads(episodes) }
+
+ val queue: QueueRepository by lazy {
+ QueueRepository(queueDao) { joined ->
+ // Anything queued is meant to be listened to, so it is fetched for offline
+ // straight away rather than at the next periodic sweep.
+ if (queuedDownloads.request(joined) > 0) Scheduling.downloadNow(context)
+ }
+ }
// Reuses the app's own OkHttpClient rather than Coil's default, so artwork fetches share
// connection pooling and timeouts with feed refreshes and downloads.
@@ -105,7 +115,17 @@ class AppGraph(private val context: Context) {
}
/** Applies QueueOrdering's decisions to storage, keeping positions contiguous. */
-class QueueRepository(private val dao: QueueDao) {
+class QueueRepository(
+ private val dao: QueueDao,
+ /**
+ * Told which episodes have just joined the queue, so they can be downloaded.
+ *
+ * Here rather than at each call site because the queue is the one choke point every route
+ * goes through -- a rule, a drip release, a tap on Play, the share sheet. Fired with newly
+ * present ids only, so reordering, removing and advancing say nothing.
+ */
+ private val onQueued: suspend (List<String>) -> Unit = {},
+) {
fun observe() = dao.observeQueue()
@@ -126,19 +146,27 @@ class QueueRepository(private val dao: QueueDao) {
* Separate from [move]: a drag gesture reports intermediate positions continuously, and
* only the final order should ever reach storage -- one write, not one per drag step.
*/
- suspend fun reorder(episodeIds: List<String>) = dao.replaceAll(episodeIds)
+ suspend fun reorder(episodeIds: List<String>) = write(episodeIds, ids())
/** Drops the finished episode and reports what plays next. */
suspend fun advance(): String? {
val current = ids()
val (next, remaining) = QueueOrdering.advance(current)
- dao.replaceAll(remaining)
+ write(remaining, before = current)
return next
}
private suspend fun ids() = dao.getEntries().map { it.episodeId }
private suspend fun rewrite(transform: (List<String>) -> List<String>) {
- dao.replaceAll(transform(ids()))
+ val before = ids()
+ write(transform(before), before)
+ }
+
+ /** Writes the order and reports whatever was not in the queue a moment ago. */
+ private suspend fun write(after: List<String>, before: List<String>) {
+ dao.replaceAll(after)
+ val joined = after - before.toSet()
+ if (joined.isNotEmpty()) onQueued(joined)
}
}
diff --git a/app/src/main/java/org/terst/listen/work/QueuedDownloads.kt b/app/src/main/java/org/terst/listen/work/QueuedDownloads.kt
new file mode 100644
index 0000000..f9642ff
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/work/QueuedDownloads.kt
@@ -0,0 +1,36 @@
+package org.terst.listen.work
+
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeDao
+
+/**
+ * Marks episodes that have just joined the queue as wanted, for [DownloadDrainer] to collect.
+ *
+ * Anything queued is something intended for listening, so it gets fetched for offline. The part
+ * worth testing is what to leave alone: a second download of bytes already on disk, or of one
+ * already in flight, costs real data for nothing.
+ */
+class QueuedDownloads(private val episodes: EpisodeDao) {
+
+ /** Returns how many episodes were newly marked as wanted. */
+ suspend fun request(episodeIds: List<String>): Int {
+ val wanted = episodeIds
+ .mapNotNull { episodes.getById(it) }
+ .filterNot { it.downloadState in SETTLED }
+
+ wanted.forEach { episodes.updateDownload(it.id, DownloadState.QUEUED, null, null) }
+ return wanted.size
+ }
+
+ private companion object {
+ /**
+ * States that need nothing doing. FAILED is deliberately absent: re-queueing something
+ * is a reasonable way to ask for a download that did not work to be tried again.
+ */
+ val SETTLED = setOf(
+ DownloadState.DOWNLOADED,
+ DownloadState.DOWNLOADING,
+ DownloadState.QUEUED,
+ )
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
index 3f829ab..db896a6 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -4,6 +4,8 @@ import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
@@ -70,15 +72,31 @@ object Scheduling {
private const val REFRESH_WORK = "listen-refresh"
private const val DOWNLOAD_WORK = "listen-download"
+ private const val DOWNLOAD_NOW_WORK = "listen-download-now"
private const val RETENTION_WORK = "listen-retention"
private const val REFRESH_HOURS = 6L
private const val DOWNLOAD_HOURS = 2L
private const val RETENTION_HOURS = 12L
/**
- * Both jobs are KEEP rather than UPDATE, so the schedule survives an app restart instead of
- * resetting its interval every launch -- which on a frequently-opened app means the periodic
- * work never actually fires.
+ * Fetches what is in the queue now, rather than at the next periodic sweep.
+ *
+ * No network constraint, unlike the periodic job: Peter asked for anything added to the
+ * queue to download immediately, mobile data included. REPLACE so a burst of queueing --
+ * a rule catching several episodes at once -- collapses into one run.
+ */
+ fun downloadNow(context: Context) {
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ DOWNLOAD_NOW_WORK,
+ ExistingWorkPolicy.REPLACE,
+ OneTimeWorkRequestBuilder<DownloadWorker>().build(),
+ )
+ }
+
+ /**
+ * The periodic jobs are KEEP rather than UPDATE, so the schedule survives an app restart
+ * instead of resetting its interval every launch -- which on a frequently-opened app means
+ * the periodic work never actually fires.
*/
fun schedule(context: Context) {
val manager = WorkManager.getInstance(context)
diff --git a/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt
index d880e57..11b080e 100644
--- a/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/QueueRepositoryTest.kt
@@ -153,4 +153,61 @@ class QueueRepositoryTest {
assertEquals(listOf("c", "a", "b"), ids())
assertContiguous()
}
+
+ // --- telling someone what just joined the queue -------------------------------------------
+
+ /**
+ * Anything entering the queue is something Peter intends to listen to, so it gets
+ * downloaded. The queue is the one choke point every route goes through, which is why the
+ * notification lives here rather than being repeated at each call site.
+ */
+ @Test
+ fun `appending reports the new episode`() = runTest {
+ val queued = mutableListOf<List<String>>()
+ val repo = QueueRepository(dao) { queued += it }
+
+ repo.append("a")
+
+ assertEquals(listOf(listOf("a")), queued)
+ }
+
+ @Test
+ fun `playing next and playing now both report`() = runTest {
+ val queued = mutableListOf<List<String>>()
+ val repo = QueueRepository(dao) { queued += it }
+
+ repo.playNext("a")
+ repo.playNow("b")
+
+ assertEquals(listOf(listOf("a"), listOf("b")), queued)
+ }
+
+ /** Re-adding something already queued moves it. Nothing joined, so nothing to fetch. */
+ @Test
+ fun `re-adding a queued episode reports nothing`() = runTest {
+ val queued = mutableListOf<List<String>>()
+ val repo = QueueRepository(dao) { queued += it }
+ repo.append("a")
+ queued.clear()
+
+ repo.append("a")
+
+ assertEquals(emptyList<List<String>>(), queued)
+ }
+
+ @Test
+ fun `removing, reordering and advancing report nothing`() = runTest {
+ val queued = mutableListOf<List<String>>()
+ val repo = QueueRepository(dao) { queued += it }
+ repo.append("a")
+ repo.append("b")
+ queued.clear()
+
+ repo.move(0, 1)
+ repo.reorder(listOf("b", "a"))
+ repo.remove("a")
+ repo.advance()
+
+ assertEquals(emptyList<List<String>>(), queued)
+ }
}
diff --git a/app/src/test/java/org/terst/listen/work/QueuedDownloadsTest.kt b/app/src/test/java/org/terst/listen/work/QueuedDownloadsTest.kt
new file mode 100644
index 0000000..244da4c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/work/QueuedDownloadsTest.kt
@@ -0,0 +1,103 @@
+package org.terst.listen.work
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+
+/**
+ * Marking newly queued episodes as wanted.
+ *
+ * Anything Peter puts in the queue is something he intends to listen to, so it gets fetched for
+ * offline. The interesting part is what *not* to re-request: a second download of a file
+ * already on disk costs real data for nothing.
+ */
+class QueuedDownloadsTest {
+
+ private lateinit var episodes: FakeEpisodeDao
+ private lateinit var requests: QueuedDownloads
+
+ @Before
+ fun setUp() {
+ episodes = FakeEpisodeDao()
+ requests = QueuedDownloads(episodes)
+ }
+
+ private fun episode(id: String, state: DownloadState, localPath: String? = null) {
+ episodes.rows += EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = id,
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ downloadState = state,
+ localPath = localPath,
+ )
+ }
+
+ private fun stateOf(id: String) = episodes.rows.first { it.id == id }.downloadState
+
+ @Test
+ fun `an episode with nothing downloaded is wanted`() = runTest {
+ episode("a", DownloadState.NONE)
+
+ assertEquals(1, requests.request(listOf("a")))
+ assertEquals(DownloadState.QUEUED, stateOf("a"))
+ }
+
+ /** Already on disk. Asking again would spend the data twice for the same bytes. */
+ @Test
+ fun `an episode already downloaded is left alone`() = runTest {
+ episode("a", DownloadState.DOWNLOADED, localPath = "/audio/a.mp3")
+
+ assertEquals(0, requests.request(listOf("a")))
+ assertEquals(DownloadState.DOWNLOADED, stateOf("a"))
+ }
+
+ /** Already in flight -- re-marking it QUEUED would let the drainer start a second copy. */
+ @Test
+ fun `an episode being downloaded is left alone`() = runTest {
+ episode("a", DownloadState.DOWNLOADING)
+
+ assertEquals(0, requests.request(listOf("a")))
+ assertEquals(DownloadState.DOWNLOADING, stateOf("a"))
+ }
+
+ /** Re-queueing something is a reasonable way to ask for a failed download again. */
+ @Test
+ fun `a failed episode is wanted again`() = runTest {
+ episode("a", DownloadState.FAILED)
+
+ assertEquals(1, requests.request(listOf("a")))
+ assertEquals(DownloadState.QUEUED, stateOf("a"))
+ }
+
+ @Test
+ fun `an already queued episode is not counted twice`() = runTest {
+ episode("a", DownloadState.QUEUED)
+
+ assertEquals(0, requests.request(listOf("a")))
+ }
+
+ @Test
+ fun `an episode that has gone is skipped rather than throwing`() = runTest {
+ assertEquals(0, requests.request(listOf("gone")))
+ }
+
+ @Test
+ fun `a batch marks each one that needs it`() = runTest {
+ episode("a", DownloadState.NONE)
+ episode("b", DownloadState.DOWNLOADED, localPath = "/audio/b.mp3")
+ episode("c", DownloadState.FAILED)
+
+ assertEquals(2, requests.request(listOf("a", "b", "c")))
+ assertEquals(DownloadState.QUEUED, stateOf("a"))
+ assertEquals(DownloadState.DOWNLOADED, stateOf("b"))
+ assertEquals(DownloadState.QUEUED, stateOf("c"))
+ }
+}