summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--app/src/main/java/org/terst/listen/ListenApp.kt10
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt6
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt128
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeActions.kt54
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt17
-rw-r--r--app/src/main/java/org/terst/listen/ui/LibraryScreen.kt16
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt58
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt17
-rw-r--r--app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt101
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt123
-rw-r--r--app/src/main/java/org/terst/listen/ui/QueueScreen.kt3
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt133
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt22
-rw-r--r--app/src/main/java/org/terst/listen/work/RetentionSweeper.kt45
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt84
-rw-r--r--app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt144
-rw-r--r--config/detekt/detekt.yml4
18 files changed, 942 insertions, 25 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 8af6b78..65bf252 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-79.62
+70.75
diff --git a/app/src/main/java/org/terst/listen/ListenApp.kt b/app/src/main/java/org/terst/listen/ListenApp.kt
index 063e824..23032fd 100644
--- a/app/src/main/java/org/terst/listen/ListenApp.kt
+++ b/app/src/main/java/org/terst/listen/ListenApp.kt
@@ -1,5 +1,13 @@
package org.terst.listen
import android.app.Application
+import org.terst.listen.work.Scheduling
-class ListenApp : Application()
+class ListenApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ // Scheduled on every launch but enqueued with KEEP, so an existing schedule is left
+ // alone rather than having its interval reset each time the app opens.
+ Scheduling.schedule(this)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
index fbdcba2..26db0a3 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -111,6 +111,12 @@ fun EpisodeEntity.toMediaItem(): MediaItem = MediaItem.Builder()
.setMediaId(id)
.setUri(localPath ?: mediaUrl)
.setMimeType(mediaType)
+ .setMediaMetadata(
+ androidx.media3.common.MediaMetadata.Builder()
+ .setTitle(title)
+ .setIsPlayable(true)
+ .build()
+ )
.build()
/** Applies a show's speed, falling back to the global default. */
diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
new file mode 100644
index 0000000..af9cc14
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -0,0 +1,128 @@
+package org.terst.listen.playback
+
+import android.content.ComponentName
+import android.content.Context
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import com.google.common.util.concurrent.MoreExecutors
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** What the UI needs to know about playback. */
+data class NowPlaying(
+ val episodeId: String?,
+ val title: String?,
+ val isPlaying: Boolean,
+ val positionMs: Long,
+ val durationMs: Long?,
+ val speed: Float,
+)
+
+/**
+ * Binds the UI to the playback service.
+ *
+ * Media3's MediaController is the supported way for an app process to drive a
+ * MediaSessionService, rather than reaching for the ExoPlayer instance directly -- the service
+ * may be restarted independently of the activity, and a direct reference would go stale without
+ * any obvious symptom.
+ */
+class PlayerConnection(private val context: Context) {
+
+ private var controller: MediaController? = null
+
+ private val _nowPlaying = MutableStateFlow(
+ NowPlaying(null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f)
+ )
+ val nowPlaying: StateFlow<NowPlaying> = _nowPlaying.asStateFlow()
+
+ fun connect() {
+ if (controller != null) return
+ val token = SessionToken(context, ComponentName(context, PlaybackService::class.java))
+ val future = MediaController.Builder(context, token).buildAsync()
+ future.addListener(
+ {
+ controller = runCatching { future.get() }.getOrNull()
+ controller?.addListener(listener)
+ publish()
+ },
+ MoreExecutors.directExecutor(),
+ )
+ }
+
+ fun release() {
+ controller?.removeListener(listener)
+ controller?.release()
+ controller = null
+ }
+
+ private val listener = object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) = publish()
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = publish()
+ override fun onPlaybackStateChanged(playbackState: Int) = publish()
+ }
+
+ /**
+ * Starts an episode, seeking to where the listener got to.
+ *
+ * The resume decision belongs to PlaybackProgress, not here: a stored position past the end
+ * has to restart rather than seek past the end and stall.
+ */
+ fun play(
+ item: MediaItem,
+ storedPositionMs: Long,
+ durationMs: Long?,
+ speed: Float,
+ progress: PlaybackProgress = PlaybackProgress(),
+ ) {
+ val player = controller ?: return
+ player.setMediaItem(item)
+ player.prepare()
+ player.seekTo(progress.resumePosition(storedPositionMs, durationMs))
+ player.applySpeed(speed, speed)
+ player.play()
+ publish()
+ }
+
+ fun togglePlayPause() {
+ val player = controller ?: return
+ if (player.isPlaying) player.pause() else player.play()
+ publish()
+ }
+
+ fun seekBy(deltaMs: Long) {
+ val player = controller ?: return
+ player.seekTo((player.currentPosition + deltaMs).coerceAtLeast(0))
+ publish()
+ }
+
+ fun pause() {
+ controller?.pause()
+ publish()
+ }
+
+ /** Used by the sleep timer's fade. */
+ fun setVolume(volume: Float) {
+ controller?.volume = volume.coerceIn(0f, 1f)
+ }
+
+ fun setSpeed(speed: Float) {
+ controller?.applySpeed(speed, speed)
+ publish()
+ }
+
+ /** Called on a UI tick, since position changes without any callback firing. */
+ fun publish() {
+ val player = controller
+ _nowPlaying.value = NowPlaying(
+ episodeId = player?.currentMediaItem?.mediaId,
+ title = player?.currentMediaItem?.mediaMetadata?.title?.toString(),
+ isPlaying = player?.isPlaying == true,
+ positionMs = player?.currentPosition ?: 0,
+ durationMs = player?.duration?.takeIf { it > 0 },
+ speed = player?.playbackParameters?.speed ?: 1.0f,
+ )
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
new file mode 100644
index 0000000..e19834e
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeActions.kt
@@ -0,0 +1,54 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Snackbar
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionEntity
+import java.util.concurrent.TimeUnit
+
+/** The row's five actions, bundled so the signature stays readable. */
+data class EpisodeActions(
+ val play: () -> Unit,
+ val enqueue: () -> Unit,
+ val playNext: () -> Unit,
+ val download: () -> Unit,
+ val togglePlayed: () -> 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 4a5fec2..439f4cb 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -44,25 +44,20 @@ import org.terst.listen.data.SubscriptionEntity
import java.util.concurrent.TimeUnit
@Composable
-internal fun EpisodeRow(
- episode: EpisodeEntity,
- onEnqueue: () -> Unit,
- onPlayNext: () -> Unit,
- onDownload: () -> Unit,
- onTogglePlayed: () -> Unit,
-) {
+internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2)
Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(onClick = onPlayNext) { Text("Next") }
- Button(onClick = onEnqueue) { Text("Queue") }
+ Button(onClick = actions.play) { Text(if (episode.positionMs > 0) "Resume" else "Play") }
+ Button(onClick = actions.playNext) { Text("Next") }
+ Button(onClick = actions.enqueue) { Text("Queue") }
if (episode.downloadState != DownloadState.DOWNLOADED) {
- Button(onClick = onDownload, enabled = episode.downloadState != DownloadState.DOWNLOADING) {
+ Button(onClick = actions.download, enabled = episode.downloadState != DownloadState.DOWNLOADING) {
Text(if (episode.downloadState == DownloadState.DOWNLOADING) "…" else "Get")
}
}
- Button(onClick = onTogglePlayed) {
+ Button(onClick = actions.togglePlayed) {
Text(if (episode.playedAtMs == null) "Played" else "Unplayed")
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
index ef1cca8..80631c9 100644
--- a/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/LibraryScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -47,13 +48,16 @@ import java.util.concurrent.TimeUnit
@Composable
fun LibraryScreen(
vm: ListenViewModel,
+ player: PlayerViewModel,
onOpenSubscription: (String) -> Unit,
onOpenQueue: () -> Unit,
+ onOpenSettings: () -> Unit,
) {
val subscriptions by vm.subscriptions.collectAsState()
val queue by vm.queue.collectAsState()
val refreshing by vm.refreshing.collectAsState()
val banner by vm.banner.collectAsState()
+ val nowPlaying by player.nowPlaying.collectAsState()
val snackbar = remember { SnackbarHostState() }
var adding by remember { mutableStateOf(false) }
@@ -75,10 +79,22 @@ fun LibraryScreen(
IconButton(onClick = vm::refreshAll, enabled = !refreshing) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh all feeds")
}
+ IconButton(onClick = onOpenSettings) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
},
)
},
snackbarHost = { SnackbarHost(snackbar) { Snackbar(it) } },
+ bottomBar = {
+ NowPlayingBar(
+ state = nowPlaying,
+ onTogglePlayPause = player::togglePlayPause,
+ onSkipBack = player::skipBack,
+ onSkipForward = player::skipForward,
+ onOpenQueue = onOpenQueue,
+ )
+ },
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
if (refreshing) {
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 fa8a89d..c7a27e5 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -15,6 +15,9 @@ import org.terst.listen.data.RefreshOutcome
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionKind
import org.terst.listen.feed.SubscriptionResolver
+import android.net.Uri
+import org.terst.listen.opml.Opml
+import org.terst.listen.opml.OpmlFeed
/** Transient message for the UI. Errors are surfaced, never only logged. */
data class Banner(val text: String, val isError: Boolean = false)
@@ -37,6 +40,61 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
private val _refreshing = MutableStateFlow(false)
val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
+ // --- OPML ----------------------------------------------------------------------------
+
+ /**
+ * Imports subscriptions from an OPML file.
+ *
+ * Reports how many were added and how many failed. Silently importing 40 of 60 feeds is the
+ * kind of partial success that only surfaces weeks later when a show never appears.
+ */
+ fun importOpml(uri: Uri) {
+ viewModelScope.launch {
+ val feeds = runCatching {
+ getApplication<Application>().contentResolver.openInputStream(uri)
+ ?.use { Opml.parse(it) }
+ .orEmpty()
+ }.getOrElse {
+ _banner.value = Banner("Could not read that file: ${it.message}", isError = true)
+ return@launch
+ }
+
+ if (feeds.isEmpty()) {
+ _banner.value = Banner("No subscriptions found in that file", isError = true)
+ return@launch
+ }
+
+ var added = 0
+ var failed = 0
+ for (feed in feeds) {
+ graph.repository.subscribe(feed.xmlUrl).fold(
+ onSuccess = { added++ },
+ onFailure = { failed++ },
+ )
+ }
+ _banner.value = if (failed > 0) {
+ Banner("Imported $added, $failed failed", isError = true)
+ } else {
+ Banner("Imported $added subscription(s)")
+ }
+ }
+ }
+
+ fun exportOpml(uri: Uri) {
+ viewModelScope.launch {
+ val feeds = graph.subscriptions.getAll().map {
+ OpmlFeed(title = it.title, xmlUrl = it.feedUrl, htmlUrl = it.link)
+ }
+ runCatching {
+ getApplication<Application>().contentResolver.openOutputStream(uri)
+ ?.use { Opml.write(feeds, it) }
+ }.fold(
+ onSuccess = { _banner.value = Banner("Exported ${feeds.size} subscription(s)") },
+ onFailure = { _banner.value = Banner("Export failed: ${it.message}", isError = true) },
+ )
+ }
+ }
+
fun dismissBanner() {
_banner.value = null
}
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 6fd76e5..a395b0f 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -16,10 +16,11 @@ import androidx.navigation.compose.rememberNavController
class MainActivity : ComponentActivity() {
private val viewModel: ListenViewModel by viewModels()
+ private val playerViewModel: PlayerViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContent { ListenTheme { ListenApp(viewModel) } }
+ setContent { ListenTheme { ListenApp(viewModel, playerViewModel) } }
}
}
@@ -29,26 +30,34 @@ fun ListenTheme(content: @Composable () -> Unit) {
}
@Composable
-private fun ListenApp(viewModel: ListenViewModel) {
+private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "library") {
composable("library") {
LibraryScreen(
vm = viewModel,
+ player = player,
onOpenSubscription = { nav.navigate("subscription/$it") },
onOpenQueue = { nav.navigate("queue") },
+ onOpenSettings = { nav.navigate("settings") },
)
}
+ composable("settings") {
+ SettingsScreen(vm = viewModel,
+ player = player, onBack = { nav.popBackStack() })
+ }
composable("queue") {
- QueueScreen(vm = viewModel, onBack = { nav.popBackStack() })
+ QueueScreen(vm = viewModel,
+ player = player, onBack = { nav.popBackStack() })
}
composable("subscription/{id}") { entry ->
val id = entry.arguments?.getString("id")
if (id == null) {
nav.popBackStack()
} else {
- SubscriptionScreen(vm = viewModel, subscriptionId = id, onBack = { nav.popBackStack() })
+ SubscriptionScreen(vm = viewModel,
+ player = player, subscriptionId = id, onBack = { nav.popBackStack() })
}
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
new file mode 100644
index 0000000..54718a4
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/NowPlayingBar.kt
@@ -0,0 +1,101 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.clickable
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.FastForward
+import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.terst.listen.playback.NowPlaying
+
+/**
+ * The persistent transport bar.
+ *
+ * Hidden entirely when nothing is loaded, rather than shown in a disabled state -- a dead control
+ * strip at the bottom of every screen is worse than no strip.
+ */
+@Composable
+fun NowPlayingBar(
+ state: NowPlaying,
+ onTogglePlayPause: () -> Unit,
+ onSkipBack: () -> Unit,
+ onSkipForward: () -> Unit,
+ onOpenQueue: () -> Unit,
+) {
+ if (state.episodeId == null) return
+
+ Surface(tonalElevation = 3.dp, modifier = Modifier.fillMaxWidth()) {
+ Column {
+ val fraction = state.durationMs
+ ?.takeIf { it > 0 }
+ ?.let { (state.positionMs.toFloat() / it).coerceIn(0f, 1f) }
+ if (fraction != null) {
+ LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth())
+ }
+
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Column(Modifier.weight(1f).clickable(onClick = onOpenQueue).padding(8.dp)) {
+ Text(
+ state.title ?: "Playing",
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ "${formatClock(state.positionMs)} · ${state.speed}×",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ IconButton(onClick = onSkipBack) {
+ Icon(Icons.Default.FastRewind, contentDescription = "Back 15 seconds")
+ }
+ IconButton(onClick = onTogglePlayPause) {
+ Icon(
+ if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (state.isPlaying) "Pause" else "Play",
+ )
+ }
+ IconButton(onClick = onSkipForward) {
+ Icon(Icons.Default.FastForward, contentDescription = "Forward 30 seconds")
+ }
+ }
+ }
+ }
+}
+
+private const val SECONDS_PER_MINUTE = 60
+private const val MINUTES_PER_HOUR = 60
+private const val MS_PER_SECOND = 1000
+
+internal fun formatClock(ms: Long): String {
+ val totalSeconds = ms / MS_PER_SECOND
+ val seconds = totalSeconds % SECONDS_PER_MINUTE
+ val minutes = (totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR
+ val hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)
+ return if (hours > 0) {
+ "%d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%d:%02d".format(minutes, seconds)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
new file mode 100644
index 0000000..350d3a0
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -0,0 +1,123 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.Graph
+import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlayerConnection
+import org.terst.listen.playback.SleepTimer
+import org.terst.listen.playback.toMediaItem
+
+/**
+ * Playback, speed and the sleep timer.
+ *
+ * Separate from ListenViewModel because these are the controls that operate on whatever is
+ * currently playing, regardless of which screen is open -- and because one view model covering
+ * both grew past the point where its function list said anything useful.
+ */
+class PlayerViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val graph = Graph.get(app)
+ private val _banner = MutableStateFlow<Banner?>(null)
+ val banner: StateFlow<Banner?> = _banner.asStateFlow()
+
+ fun dismissBanner() {
+ _banner.value = null
+ }
+
+ private val connection = PlayerConnection(app).also { it.connect() }
+ val nowPlaying: StateFlow<NowPlaying> = connection.nowPlaying
+
+ init {
+ // Position advances with no callback of its own, so the bar is driven by a tick.
+ viewModelScope.launch {
+ while (true) {
+ delay(PROGRESS_TICK_MS)
+ connection.publish()
+ }
+ }
+ }
+
+ override fun onCleared() {
+ connection.release()
+ super.onCleared()
+ }
+
+ /** Starts an episode, resuming where the listener left off and at the show's speed. */
+ fun play(episode: EpisodeEntity) {
+ viewModelScope.launch {
+ val speed = graph.subscriptions.getById(episode.subscriptionId)?.playbackSpeed ?: DEFAULT_SPEED
+ connection.play(
+ item = episode.toMediaItem(),
+ storedPositionMs = episode.positionMs,
+ durationMs = episode.durationMs,
+ speed = speed,
+ )
+ }
+ }
+
+ fun togglePlayPause() = connection.togglePlayPause()
+
+ fun skipBack() = connection.seekBy(-SKIP_BACK_MS)
+
+ fun skipForward() = connection.seekBy(SKIP_FORWARD_MS)
+
+ fun setSpeed(speed: Float) = connection.setSpeed(speed)
+
+ // --- sleep timer ---------------------------------------------------------------------
+
+ private val sleepTimer = SleepTimer()
+ private val _sleepRemainingMs = MutableStateFlow<Long?>(null)
+ val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow()
+
+ fun armSleepTimer(minutes: Long) {
+ sleepTimer.armFor(minutes * MS_PER_MINUTE, System.currentTimeMillis())
+ watchSleepTimer()
+ }
+
+ fun cancelSleepTimer() {
+ sleepTimer.cancel()
+ _sleepRemainingMs.value = null
+ }
+
+ /**
+ * Polls the timer rather than scheduling a single alarm: the fade needs a volume update
+ * every tick anyway, and a deadline read from the clock survives the process being paused.
+ */
+ private fun watchSleepTimer() {
+ viewModelScope.launch {
+ while (sleepTimer.isArmed) {
+ val now = System.currentTimeMillis()
+ _sleepRemainingMs.value = sleepTimer.remainingMs(now)
+ if (sleepTimer.hasExpired(now)) {
+ connection.setVolume(0f)
+ connection.pause()
+ sleepTimer.cancel()
+ _sleepRemainingMs.value = null
+ _banner.value = Banner("Sleep timer finished")
+ break
+ }
+ connection.setVolume(sleepTimer.volumeAt(now))
+ delay(PROGRESS_TICK_MS)
+ }
+ }
+ }
+
+
+ private companion object {
+ const val PROGRESS_TICK_MS = 1_000L
+ const val DEFAULT_SPEED = 1.0f
+ // Asymmetric on purpose: back is for catching a missed sentence, forward for skipping
+ // an ad break.
+ const val SKIP_BACK_MS = 15_000L
+ const val SKIP_FORWARD_MS = 30_000L
+ const val MS_PER_MINUTE = 60_000L
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
index e297489..fbeb7d8 100644
--- a/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/QueueScreen.kt
@@ -45,7 +45,7 @@ import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun QueueScreen(vm: ListenViewModel, onBack: () -> Unit) {
+fun QueueScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) {
val queue by vm.queue.collectAsState()
Scaffold(
topBar = {
@@ -65,6 +65,7 @@ fun QueueScreen(vm: ListenViewModel, onBack: () -> Unit) {
Text(episode.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall)
}
+ Button(onClick = { player.play(episode) }) { Text("Play") }
Button(onClick = { vm.removeFromQueue(episode.id) }) { Text("Remove") }
}
HorizontalDivider()
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
new file mode 100644
index 0000000..bdb1cef
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -0,0 +1,133 @@
+package org.terst.listen.ui
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+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.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/** Offered speeds. Beyond 2x speech becomes hard to follow, so the list stops there. */
+private val SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
+
+/** Sleep durations in minutes. */
+private val SLEEP_MINUTES = listOf(15L, 30L, 45L, 60L)
+
+private val OPML_MIME_TYPES = arrayOf("text/x-opml", "text/xml", "application/xml", "*/*")
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) {
+ val nowPlaying by player.nowPlaying.collectAsState()
+ val sleepRemaining by player.sleepRemainingMs.collectAsState()
+
+ // The system file picker rather than a permission-hungry file browser: it grants access to
+ // exactly the one file the user chose and nothing else.
+ val importLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocument()
+ ) { uri -> uri?.let(vm::importOpml) }
+
+ val exportLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.CreateDocument("text/x-opml")
+ ) { uri -> uri?.let(vm::exportOpml) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Settings") },
+ navigationIcon = { Button(onClick = onBack) { Text("Back") } },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp),
+ ) {
+ SpeedSection(current = nowPlaying.speed, onSelect = player::setSpeed)
+ HorizontalDivider()
+ SleepSection(remainingMs = sleepRemaining, onArm = player::armSleepTimer, onCancel = player::cancelSleepTimer)
+ HorizontalDivider()
+ SubscriptionsSection(
+ onImport = { importLauncher.launch(OPML_MIME_TYPES) },
+ onExport = { exportLauncher.launch("listen-subscriptions.opml") },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SpeedSection(current: Float, onSelect: (Float) -> Unit) {
+ Section("Playback speed") {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ SPEEDS.forEach { speed ->
+ if (current == speed) {
+ Button(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") }
+ } else {
+ OutlinedButton(onClick = { onSelect(speed) }) { Text("${speed}\u00d7") }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SleepSection(remainingMs: Long?, onArm: (Long) -> Unit, onCancel: () -> Unit) {
+ Section("Sleep timer") {
+ if (remainingMs != null) {
+ Text("Stopping in ${formatClock(remainingMs)}")
+ Button(onClick = onCancel) { Text("Cancel") }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ SLEEP_MINUTES.forEach { minutes ->
+ OutlinedButton(onClick = { onArm(minutes) }) { Text("${minutes}m") }
+ }
+ }
+ }
+ Text(
+ "Volume fades over the last 20 seconds rather than cutting.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
+private fun SubscriptionsSection(onImport: () -> Unit, onExport: () -> Unit) {
+ Section("Subscriptions") {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Some exporters use text/xml and some a bare octet-stream, so the filter is broad;
+ // narrowing it hides the file the user is trying to pick.
+ OutlinedButton(onClick = onImport) { Text("Import OPML") }
+ OutlinedButton(onClick = onExport) { Text("Export OPML") }
+ }
+ Text(
+ "OPML carries subscriptions only. Playback positions, history and the queue do not " +
+ "transfer from another app.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
+private fun Section(title: String, content: @Composable () -> Unit) {
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ content()
+ }
+}
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 92568de..483333d 100644
--- a/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SubscriptionScreen.kt
@@ -45,7 +45,12 @@ import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SubscriptionScreen(vm: ListenViewModel, subscriptionId: String, onBack: () -> Unit) {
+fun SubscriptionScreen(
+ vm: ListenViewModel,
+ player: PlayerViewModel,
+ subscriptionId: String,
+ onBack: () -> Unit,
+) {
val episodes by vm.episodesOf(subscriptionId).collectAsState(initial = emptyList())
val subscription = vm.subscriptions.collectAsState().value.firstOrNull { it.id == subscriptionId }
@@ -61,12 +66,15 @@ fun SubscriptionScreen(vm: ListenViewModel, subscriptionId: String, onBack: () -
items(episodes, key = { it.id }) { episode ->
EpisodeRow(
episode = episode,
- onEnqueue = { vm.enqueue(episode.id) },
- onPlayNext = { vm.playNext(episode.id) },
- onDownload = { vm.download(episode) },
- onTogglePlayed = {
- if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
- },
+ actions = EpisodeActions(
+ play = { player.play(episode) },
+ enqueue = { vm.enqueue(episode.id) },
+ playNext = { vm.playNext(episode.id) },
+ download = { vm.download(episode) },
+ togglePlayed = {
+ if (episode.playedAtMs == null) vm.markPlayed(episode.id) else vm.markUnplayed(episode.id)
+ },
+ ),
)
HorizontalDivider()
}
diff --git a/app/src/main/java/org/terst/listen/work/RetentionSweeper.kt b/app/src/main/java/org/terst/listen/work/RetentionSweeper.kt
new file mode 100644
index 0000000..cd329b3
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/work/RetentionSweeper.kt
@@ -0,0 +1,45 @@
+package org.terst.listen.work
+
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.queue.RetentionPolicy
+import java.io.File
+
+/**
+ * Reclaims played downloads.
+ *
+ * Extracted from the Worker so it is testable: this deletes a listener's audio, and the failure
+ * mode is silent and permanent.
+ */
+class RetentionSweeper(
+ private val episodes: EpisodeDao,
+ private val policy: RetentionPolicy,
+ /** Injected so the undeletable-file path can be exercised without a read-only filesystem. */
+ private val deleteFile: (File) -> Boolean = { it.delete() },
+) {
+
+ /** Returns how many episodes were reclaimed. */
+ suspend fun sweep(nowMs: Long): Int {
+ // The query is a coarse filter; the policy makes the decision, including cases SQL
+ // cannot express such as an episode resumed after being marked played.
+ val candidates = episodes.playedDownloadsBefore(nowMs)
+ var reclaimed = 0
+
+ for (episode in policy.selectDeletable(candidates, nowMs)) {
+ val path = episode.localPath ?: continue
+ val file = File(path)
+
+ // A row pointing at a file that is already gone still needs clearing, or it stays a
+ // permanently unplayable DOWNLOADED entry.
+ val gone = !file.exists() || deleteFile(file)
+
+ // Only clear the row once the file is actually gone. The other order orphans the
+ // file: nothing else knows about it, so the disk is never reclaimed.
+ if (gone) {
+ episodes.updateDownload(episode.id, DownloadState.NONE, null, null)
+ reclaimed++
+ }
+ }
+ return reclaimed
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
new file mode 100644
index 0000000..75c6625
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -0,0 +1,84 @@
+package org.terst.listen.work
+
+import android.content.Context
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import org.terst.listen.data.Graph
+import org.terst.listen.data.RefreshOutcome
+import java.util.concurrent.TimeUnit
+
+/**
+ * Refreshes every subscription.
+ *
+ * Never returns failure. A retry would re-fetch every feed because one publisher's server was
+ * down, and the reason is already recorded against the subscription and shown in the library --
+ * which is the point of storing it rather than logging it.
+ */
+class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val outcomes = Graph.get(applicationContext).repository.refreshAll()
+ val failed = outcomes.values.count { it is RefreshOutcome.Failed }
+ return Result.success(
+ androidx.work.Data.Builder()
+ .putInt("refreshed", outcomes.size - failed)
+ .putInt("failed", failed)
+ .build()
+ )
+ }
+}
+
+/** Reclaims played downloads; the decisions live in RetentionSweeper, which is tested. */
+class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val graph = Graph.get(applicationContext)
+ val reclaimed = RetentionSweeper(graph.episodes, graph.retention)
+ .sweep(System.currentTimeMillis())
+ return Result.success(androidx.work.Data.Builder().putInt("reclaimed", reclaimed).build())
+ }
+}
+
+object Scheduling {
+
+ private const val REFRESH_WORK = "listen-refresh"
+ private const val RETENTION_WORK = "listen-retention"
+ private const val REFRESH_HOURS = 6L
+ 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.
+ */
+ fun schedule(context: Context) {
+ val manager = WorkManager.getInstance(context)
+
+ manager.enqueueUniquePeriodicWork(
+ REFRESH_WORK,
+ ExistingPeriodicWorkPolicy.KEEP,
+ PeriodicWorkRequestBuilder<RefreshWorker>(REFRESH_HOURS, TimeUnit.HOURS)
+ .setConstraints(
+ Constraints.Builder()
+ // Unmetered: refreshing every feed is cheap, but downloads that follow
+ // are not, and a phone on mobile data should not discover that by itself.
+ .setRequiredNetworkType(NetworkType.UNMETERED)
+ .build()
+ )
+ .build(),
+ )
+
+ manager.enqueueUniquePeriodicWork(
+ RETENTION_WORK,
+ ExistingPeriodicWorkPolicy.KEEP,
+ PeriodicWorkRequestBuilder<RetentionWorker>(RETENTION_HOURS, TimeUnit.HOURS)
+ .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
+ .build(),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt b/app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt
new file mode 100644
index 0000000..65b739f
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/work/RetentionSweeperTest.kt
@@ -0,0 +1,144 @@
+package org.terst.listen.work
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.queue.RetentionPolicy
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+/**
+ * This code deletes a listener's audio, so it is worth testing directly rather than leaving
+ * inside a Worker where only an emulator could reach it.
+ */
+class RetentionSweeperTest {
+
+ @get:Rule val temp = TemporaryFolder()
+
+ private val day = TimeUnit.DAYS.toMillis(1)
+ private lateinit var dao: FakeEpisodeDao
+ private lateinit var sweeper: RetentionSweeper
+
+ @Before
+ fun setUp() {
+ dao = FakeEpisodeDao()
+ sweeper = RetentionSweeper(dao, RetentionPolicy(gracePeriodMs = day))
+ }
+
+ private fun downloaded(
+ id: String,
+ playedAtMs: Long?,
+ positionMs: Long = 0,
+ file: File? = temp.newFile("$id.mp3").apply { writeText("audio") },
+ ): EpisodeEntity {
+ val episode = EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = "Episode $id",
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ playedAtMs = playedAtMs,
+ positionMs = positionMs,
+ downloadState = DownloadState.DOWNLOADED,
+ localPath = file?.absolutePath,
+ )
+ dao.rows += episode
+ return episode
+ }
+
+ @Test
+ fun `reclaims a played download past its grace period`() = runTest {
+ val episode = downloaded("a", playedAtMs = 0)
+ val reclaimed = sweeper.sweep(nowMs = 2 * day)
+
+ assertEquals(1, reclaimed)
+ assertTrue("the file should be gone", !File(episode.localPath!!).exists())
+ val row = dao.rows.single()
+ assertEquals(DownloadState.NONE, row.downloadState)
+ assertNull(row.localPath)
+ }
+
+ @Test
+ fun `leaves an unplayed download alone`() = runTest {
+ val episode = downloaded("a", playedAtMs = null)
+ assertEquals(0, sweeper.sweep(nowMs = 100 * day))
+ assertTrue(File(episode.localPath!!).exists())
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState)
+ }
+
+ @Test
+ fun `leaves a download inside its grace period alone`() = runTest {
+ val episode = downloaded("a", playedAtMs = 10 * day)
+ assertEquals(0, sweeper.sweep(nowMs = 10 * day + 1000))
+ assertTrue(File(episode.localPath!!).exists())
+ }
+
+ /** Marked played but resumed since: the audio is in use. */
+ @Test
+ fun `leaves a resumed episode alone`() = runTest {
+ val episode = downloaded("a", playedAtMs = 0, positionMs = 500_000)
+ assertEquals(0, sweeper.sweep(nowMs = 100 * day))
+ assertTrue(File(episode.localPath!!).exists())
+ }
+
+ /**
+ * A row pointing at a file already gone should still be cleared, or it stays a
+ * permanently-unplayable DOWNLOADED entry.
+ */
+ @Test
+ fun `clears the row when the file has already been removed`() = runTest {
+ val episode = downloaded("a", playedAtMs = 0)
+ File(episode.localPath!!).delete()
+
+ assertEquals(1, sweeper.sweep(nowMs = 2 * day))
+ assertEquals(DownloadState.NONE, dao.rows.single().downloadState)
+ assertNull(dao.rows.single().localPath)
+ }
+
+ /**
+ * If the file cannot be deleted the row must stay as it is. Clearing it would orphan the
+ * file: nothing else knows about it, so the disk would never be reclaimed.
+ */
+ @Test
+ fun `an undeletable file leaves the row untouched`() = runTest {
+ downloaded("a", playedAtMs = 0)
+ val stubborn = RetentionSweeper(dao, RetentionPolicy(gracePeriodMs = day), deleteFile = { false })
+
+ assertEquals(0, stubborn.sweep(nowMs = 2 * day))
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState)
+ assertTrue(dao.rows.single().localPath != null)
+ }
+
+ @Test
+ fun `sweeps several episodes and reports the count`() = runTest {
+ downloaded("a", playedAtMs = 0)
+ downloaded("b", playedAtMs = 0)
+ downloaded("c", playedAtMs = null)
+
+ assertEquals(2, sweeper.sweep(nowMs = 2 * day))
+ assertEquals(1, dao.rows.count { it.downloadState == DownloadState.DOWNLOADED })
+ }
+
+ @Test
+ fun `an empty library is harmless`() = runTest {
+ assertEquals(0, sweeper.sweep(nowMs = 100 * day))
+ }
+
+ @Test
+ fun `disabled retention reclaims nothing`() = runTest {
+ val episode = downloaded("a", playedAtMs = 0)
+ val off = RetentionSweeper(dao, RetentionPolicy.Disabled)
+ assertEquals(0, off.sweep(nowMs = 100 * day))
+ assertTrue(File(episode.localPath!!).exists())
+ }
+}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index 3d282f5..7d8a663 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -3,6 +3,10 @@
# upgrading detekt pulls in sane new defaults automatically instead of us silently diverging.
style:
+ MagicNumber:
+ ignorePropertyDeclaration: true # assigning a literal to a named property IS the fix this
+ # rule asks for -- `private val SPEEDS = listOf(1.0f, 1.25f, ...)` is named data, and
+ # exploding each element into its own constant would make it less readable, not more.
ReturnCount:
max: 5 # the feed parser is guard-clause heavy on purpose -- an enclosure with no URL, no
# type, or a non-playable type each bail out early. Forcing those into nested conditionals