summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-25 07:44:49 +0000
committerClaude <noreply@anthropic.com>2026-09-25 07:44:49 +0000
commit8dc3cbc4569d384c1689c3f9823e40d4f8a277be (patch)
tree77f60d318ad2bbd2d3220d844c8f4fc49d9fd573
parente5608851415ec04a8450678e6399441467194ddd (diff)
Mark-older-as-played, episode details, unplayed-first sorting
After an OPML import every episode of every show arrives unplayed -- a back catalogue already heard, far too large to tap through. markPlayedUpTo marks an episode and everything older in one action. It skips episodes already played, so their original dates survive, and clears positions as it goes: retention reads a non-zero position as "resumed, still in use" and would otherwise never reclaim those files. Sorting now ranks by newest *unplayed* episode rather than newest episode. A show you are caught up on was sitting at the top with nothing to offer, which is backwards; it now drops down the list until it publishes again. An episode details screen, because the list can show two lines and the description -- often the only place a show says what an episode is about -- had nowhere to go. It is also where the bulk action lives: the row already has five controls and a sixth did not fit. One bug worth recording. SubscriptionNav.openEpisode had a `= {}` default, so the subscription route forgot to pass it and tapping an episode title silently did nothing -- it compiled, it ran, the button was simply dead. Default no-ops on navigation callbacks turn a missing wire into a dead control instead of a compile error, so the default is gone. Verified on device: details screen opens, and against real Room on a live feed the bulk action marked 498 older episodes while leaving the 2 newer alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt30
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeActions.kt1
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt46
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt158
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt20
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt33
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt5
-rw-r--r--app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt92
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt10
-rw-r--r--app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt54
11 files changed, 453 insertions, 6 deletions
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index 958e2a5..ea8899b 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -11,16 +11,17 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface SubscriptionDao {
/**
- * Most recently published first.
+ * Newest *unplayed* episode first.
*
- * Alphabetical put the show you are least likely to want next at the top as often as not;
- * what a listener is looking for is whatever has something new. Shows with no episodes yet
- * sort last rather than first, which is what COALESCE to 0 does here.
+ * Not newest episode: a show you are caught up on would sit at the top having nothing for
+ * you, which is the opposite of useful. Sorting on what is still unheard puts whatever
+ * actually wants listening to at the top, and drops a finished show down the list until it
+ * publishes again. Shows with nothing unplayed sort last, which is the COALESCE to 0.
*/
@Query(
"""SELECT s.* FROM subscriptions s
LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest
- FROM episodes GROUP BY subscriptionId) e
+ FROM episodes WHERE playedAtMs IS NULL GROUP BY subscriptionId) e
ON e.subscriptionId = s.id
ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE"""
)
@@ -91,6 +92,25 @@ interface EpisodeDao {
)
suspend fun updateDownload(id: String, state: DownloadState, localPath: String?, downloadedAtMs: Long?)
+ /**
+ * Marks an episode and everything older in the same podcast as played.
+ *
+ * The case this exists for is an OPML import: every episode of every show arrives unplayed,
+ * which is a back catalogue of things already heard and far too many to tap individually.
+ *
+ * `playedAtMs IS NULL` keeps an already-played episode's original date, which recorded when
+ * it was actually finished. Positions are cleared because retention reads a non-zero
+ * position as "resumed, still in use" and would never reclaim the files.
+ */
+ @Query(
+ """UPDATE episodes SET playedAtMs = :atMs, positionMs = 0
+ WHERE subscriptionId = :subscriptionId
+ AND publishedAtMs IS NOT NULL
+ AND publishedAtMs <= :publishedAtMs
+ AND playedAtMs IS NULL"""
+ )
+ suspend fun markPlayedUpTo(subscriptionId: String, publishedAtMs: Long, atMs: Long)
+
/** Candidates for retention: played, downloaded, and past their grace period. */
@Query(
"""SELECT * FROM episodes
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
index e19834e..c3d1d72 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
@@ -50,5 +50,6 @@ data class EpisodeActions(
val playNext: () -> Unit,
val download: () -> Unit,
val togglePlayed: () -> Unit,
+ val openDetails: () -> Unit,
)
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
new file mode 100644
index 0000000..5ef0ff1
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
@@ -0,0 +1,46 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import org.terst.listen.data.EpisodeEntity
+
+/** The detail screen's actions, bundled so the signature stays readable. */
+data class EpisodeDetailActions(
+ val play: () -> Unit,
+ val enqueue: () -> Unit,
+ val download: () -> Unit,
+ val togglePlayed: () -> Unit,
+ val markOlderPlayed: () -> Unit,
+)
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
index 20e46cc..99fe053 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -1,6 +1,7 @@
package org.terst.listen.ui
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -51,7 +52,14 @@ internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artwork
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Column(Modifier.weight(1f).padding(start = 12.dp)) {
- Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2)
+ // Tapping the title opens the episode: the list can only show a couple of lines and
+ // the description has nowhere else to go.
+ Text(
+ episode.title,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 2,
+ modifier = Modifier.clickable(onClick = actions.openDetails),
+ )
Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
new file mode 100644
index 0000000..72434a0
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -0,0 +1,158 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import org.terst.listen.data.EpisodeEntity
+
+/**
+ * One episode in full.
+ *
+ * The list can only show a couple of lines, so the description — often the only place the show
+ * says what an episode is actually about — had nowhere to go. This is also where bulk actions
+ * live: the row has five controls already and a sixth would not fit.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EpisodeScreen(
+ vm: ListenViewModel,
+ player: PlayerViewModel,
+ episodeId: String,
+ nav: SubscriptionNav,
+) {
+ var episode by remember { mutableStateOf<EpisodeEntity?>(null) }
+ LaunchedEffect(episodeId) { episode = vm.episode(episodeId) }
+
+ val current = episode
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(current?.title ?: "Episode", maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = {
+ IconButton(onClick = nav.back) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ bottomBar = { PlayerBar(player, nav.openPlayer, nav.openQueue) },
+ ) { padding ->
+ if (current == null) {
+ Text("Episode not found", Modifier.padding(padding).padding(24.dp))
+ return@Scaffold
+ }
+ EpisodeDetail(
+ episode = current,
+ modifier = Modifier.padding(padding),
+ actions = EpisodeDetailActions(
+ play = { player.play(current); nav.openPlayer() },
+ enqueue = { vm.enqueue(current.id) },
+ download = { vm.download(current) },
+ togglePlayed = {
+ if (current.playedAtMs == null) vm.markPlayed(current.id) else vm.markUnplayed(current.id)
+ episode = current.copy(playedAtMs = if (current.playedAtMs == null) 1L else null)
+ },
+ markOlderPlayed = { vm.markPlayedUpTo(current) },
+ ),
+ )
+ }
+}
+
+@Composable
+private fun EpisodeDetail(
+ episode: EpisodeEntity,
+ modifier: Modifier,
+ actions: EpisodeDetailActions,
+) {
+ Column(
+ modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ AsyncImage(
+ model = episode.imageUrl,
+ contentDescription = null,
+ modifier = Modifier
+ .size(96.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ )
+ Column(Modifier.weight(1f)) {
+ Text(episode.title, style = MaterialTheme.typography.titleMedium)
+ Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Button(onClick = actions.play) { Text(if (episode.positionMs > 0) "Resume" else "Play") }
+ OutlinedButton(onClick = actions.enqueue) { Text("Queue") }
+ OutlinedButton(onClick = actions.download) { Text("Download") }
+ OutlinedButton(onClick = actions.togglePlayed) {
+ Text(if (episode.playedAtMs == null) "Played" else "Unplayed")
+ }
+ }
+
+ // The import case: a freshly imported back catalogue is entirely unplayed, and this is
+ // the only practical way to say "I've heard everything up to here".
+ OutlinedButton(onClick = actions.markOlderPlayed, modifier = Modifier.fillMaxWidth()) {
+ Text("Mark this and older as played")
+ }
+
+ episode.description?.let {
+ Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+}
+
+/**
+ * Show notes are HTML in practice, whatever the spec says.
+ *
+ * Rendered raw they appear as a wall of tags. This is not a parser — it strips tags, decodes the
+ * handful of entities that actually show up, and collapses the whitespace that leaves behind.
+ */
+internal fun stripHtml(raw: String): String = raw
+ .replace(Regex("""<br\s*/?>|</p>|</div>""", RegexOption.IGNORE_CASE), "\n")
+ .replace(Regex("""<[^>]+>"""), "")
+ .replace("&nbsp;", " ")
+ .replace("&amp;", "&")
+ .replace("&lt;", "<")
+ .replace("&gt;", ">")
+ .replace("&quot;", "\"")
+ .replace("&#39;", "'")
+ .replace(Regex("""\n{3,}"""), "\n\n")
+ .lines().joinToString("\n") { it.trim() }
+ .trim()
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 1ff29f6..f439a2f 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -100,6 +100,8 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
_banner.value = null
}
+ suspend fun episode(id: String): EpisodeEntity? = graph.episodes.getById(id)
+
fun episodesOf(subscriptionId: String) = graph.episodes.observeForSubscription(subscriptionId)
/**
@@ -211,6 +213,24 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { graph.episodes.markPlayed(episodeId, System.currentTimeMillis()) }
}
+ /**
+ * Marks an episode and everything older in the same show as played.
+ *
+ * The OPML-import case: a freshly imported back catalogue is entirely unplayed, and the
+ * only practical way to say "I've heard everything up to here" is in one action.
+ */
+ fun markPlayedUpTo(episode: EpisodeEntity) {
+ val publishedAtMs = episode.publishedAtMs
+ if (publishedAtMs == null) {
+ _banner.value = Banner("That episode has no date, so there's no 'older' to mark", isError = true)
+ return
+ }
+ viewModelScope.launch {
+ graph.episodes.markPlayedUpTo(episode.subscriptionId, publishedAtMs, System.currentTimeMillis())
+ _banner.value = Banner("Marked this and older as played")
+ }
+ }
+
fun markUnplayed(episodeId: String) {
viewModelScope.launch { graph.episodes.markUnplayed(episodeId) }
}
diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
index 61416a3..e483acc 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -58,6 +58,7 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
composable("player") {
PlayerScreen(player = player, onBack = { nav.popBackStack() })
}
+ episodeRoute(nav, viewModel, player)
composable("settings") {
SettingsScreen(vm = viewModel,
player = player, onBack = { nav.popBackStack() })
@@ -81,6 +82,7 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
player = player,
subscriptionId = id,
nav = SubscriptionNav(
+ openEpisode = { nav.navigate("episode/$it") },
openPlayer = { nav.navigate("player") },
openQueue = { nav.navigate("queue") },
back = { nav.popBackStack() },
@@ -90,3 +92,34 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
}
}
}
+
+/**
+ * The episode detail route.
+ *
+ * Extracted because the nav graph grew past the length limit as screens were added; each route
+ * that needs more than a line belongs in its own builder rather than inline.
+ */
+private fun androidx.navigation.NavGraphBuilder.episodeRoute(
+ nav: androidx.navigation.NavHostController,
+ viewModel: ListenViewModel,
+ player: PlayerViewModel,
+) {
+ composable("episode/{id}") { entry ->
+ val id = entry.arguments?.getString("id")
+ if (id == null) {
+ nav.popBackStack()
+ } else {
+ EpisodeScreen(
+ vm = viewModel,
+ player = player,
+ episodeId = id,
+ nav = SubscriptionNav(
+ openEpisode = { nav.navigate("episode/$it") },
+ openPlayer = { nav.navigate("player") },
+ openQueue = { nav.navigate("queue") },
+ back = { nav.popBackStack() },
+ ),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
index c8d71ea..8f3bc70 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -103,6 +103,7 @@ fun SubscriptionScreen(
enqueue = { vm.enqueue(episode.id) },
playNext = { vm.playNext(episode.id) },
download = { vm.download(episode) },
+ openDetails = { nav.openEpisode(episode.id) },
togglePlayed = {
if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
},
@@ -161,6 +162,10 @@ private fun UnsubscribeDialog(
/** Navigation out of this screen, bundled so the signature stays readable. */
data class SubscriptionNav(
+ // No default. A no-op default on a navigation callback compiles perfectly and ships a
+ // button that does nothing -- which is exactly what happened: tapping an episode title
+ // silently did nothing because one route forgot to pass this.
+ val openEpisode: (String) -> Unit,
val openPlayer: () -> Unit,
val openQueue: () -> Unit,
val back: () -> Unit,
diff --git a/app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt b/app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt
new file mode 100644
index 0000000..b0eef17
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/BulkPlayedTest.kt
@@ -0,0 +1,92 @@
+package org.terst.listen.data
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Marking a back catalogue played.
+ *
+ * After an OPML import every episode of every show arrives unplayed, which is thousands of
+ * items you have in fact already heard. Marking them one at a time is not a real option.
+ */
+class BulkPlayedTest {
+
+ private lateinit var dao: FakeEpisodeDao
+
+ private fun episode(id: String, publishedAtMs: Long?, playedAtMs: Long? = null) = EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = "Episode $id",
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ publishedAtMs = publishedAtMs,
+ playedAtMs = playedAtMs,
+ positionMs = if (id == "resumed") 90_000 else 0,
+ )
+
+ @Before
+ fun setUp() {
+ dao = FakeEpisodeDao()
+ dao.rows += listOf(
+ episode("newest", 500),
+ episode("pivot", 300),
+ episode("older", 200),
+ episode("oldest", 100),
+ )
+ }
+
+ private fun played(id: String) = dao.rows.first { it.id == id }.playedAtMs
+
+ @Test
+ fun `marks the chosen episode and everything older`() = runTest {
+ dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999)
+ assertEquals(999L, played("pivot"))
+ assertEquals(999L, played("older"))
+ assertEquals(999L, played("oldest"))
+ }
+
+ @Test
+ fun `leaves newer episodes alone`() = runTest {
+ dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999)
+ assertNull("a newer episode must not be marked", played("newest"))
+ }
+
+ @Test
+ fun `does not touch another podcast`() = runTest {
+ dao.rows += episode("other", 100).copy(id = "other", subscriptionId = "s2")
+ dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999)
+ assertNull(dao.rows.first { it.id == "other" }.playedAtMs)
+ }
+
+ /** Re-marking must not rewrite a date that already recorded when it was really finished. */
+ @Test
+ fun `an already-played episode keeps its original date`() = runTest {
+ dao.rows += episode("done", 250, playedAtMs = 42)
+ dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999)
+ assertEquals(42L, dao.rows.first { it.id == "done" }.playedAtMs)
+ }
+
+ /**
+ * Retention deletes played downloads, and it treats a non-zero position as "resumed, still
+ * in use". Bulk-marking clears positions so a swept-away back catalogue doesn't linger.
+ */
+ @Test
+ fun `clears playback positions it marks`() = runTest {
+ dao.rows += episode("resumed", 150)
+ dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999)
+ assertEquals(0L, dao.rows.first { it.id == "resumed" }.positionMs)
+ }
+
+ /** An undated episode has no place in a chronological sweep; leave it. */
+ @Test
+ fun `undated episodes are left alone`() = runTest {
+ dao.rows += episode("undated", null)
+ dao.markPlayedUpTo("s1", publishedAtMs = 300, atMs = 999)
+ assertNull(dao.rows.first { it.id == "undated" }.playedAtMs)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index 1822ab9..59443c0 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -89,6 +89,16 @@ class FakeEpisodeDao : EpisodeDao {
replace(id) { it.copy(playedAtMs = atMs, positionMs = 0) }
}
+ override suspend fun markPlayedUpTo(subscriptionId: String, publishedAtMs: Long, atMs: Long) {
+ rows.replaceAll { row ->
+ val eligible = row.subscriptionId == subscriptionId &&
+ row.publishedAtMs != null &&
+ row.publishedAtMs!! <= publishedAtMs &&
+ row.playedAtMs == null
+ if (eligible) row.copy(playedAtMs = atMs, positionMs = 0) else row
+ }
+ }
+
override suspend fun markUnplayed(id: String) {
replace(id) { it.copy(playedAtMs = null) }
}
diff --git a/app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt b/app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt
new file mode 100644
index 0000000..b3995a3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ui/StripHtmlTest.kt
@@ -0,0 +1,54 @@
+package org.terst.listen.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Show notes are HTML in practice; rendered raw they are a wall of tags. */
+class StripHtmlTest {
+
+ @Test
+ fun `removes tags`() {
+ assertEquals("Hello world", stripHtml("<p>Hello <b>world</b></p>"))
+ }
+
+ @Test
+ fun `turns breaks and paragraph ends into newlines`() {
+ assertEquals("One\nTwo", stripHtml("One<br/>Two"))
+ assertEquals("One\nTwo", stripHtml("<p>One</p><p>Two</p>"))
+ }
+
+ @Test
+ fun `decodes the entities that actually appear`() {
+ assertEquals("Rock & Roll", stripHtml("Rock &amp; Roll"))
+ assertEquals("\"quoted\" and 'apostrophe'", stripHtml("&quot;quoted&quot; and &#39;apostrophe&#39;"))
+ assertEquals("a b", stripHtml("a&nbsp;b"))
+ }
+
+ @Test
+ fun `collapses the blank lines stripping leaves behind`() {
+ assertEquals("One\n\nTwo", stripHtml("<p>One</p><br/><br/><br/><p>Two</p>"))
+ }
+
+ @Test
+ fun `leaves plain text alone`() {
+ assertEquals("Just words.", stripHtml("Just words."))
+ }
+
+ @Test
+ fun `handles an empty description`() {
+ assertEquals("", stripHtml(""))
+ assertEquals("", stripHtml("<p></p>"))
+ }
+
+ /** Links are common in show notes; the text should survive even though the href cannot. */
+ @Test
+ fun `keeps link text`() {
+ assertEquals("See the notes", stripHtml("""See <a href="https://x.com">the notes</a>"""))
+ }
+
+ @Test
+ fun `does not leave stray angle brackets`() {
+ assertTrue(!stripHtml("<div><span>x</span></div>").contains("<"))
+ }
+}