diff options
Diffstat (limited to 'app/src')
13 files changed, 459 insertions, 64 deletions
diff --git a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt index 04ca9a3..9aca940 100644 --- a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt +++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt @@ -46,6 +46,16 @@ interface ServerAudio { /** Records the server's offsets, once its audio is actually the file on disk. */ suspend fun record(episode: EpisodeEntity, info: ServerEpisode) + + /** + * Called when the server could not supply this episode yet and it has been left waiting. + * + * Here rather than as another downloader parameter because it is the same subject as the + * rest of this interface: a wait exists only because a server is configured, and what to do + * about one -- ask again shortly rather than at the next feed refresh -- is the server's + * side of the download, not the HTTP's. + */ + suspend fun waiting(episode: EpisodeEntity) {} } class EpisodeDownloader( @@ -110,6 +120,9 @@ class EpisodeDownloader( DownloadState.FAILED } episodes.updateDownload(episode.id, state, null, null) + // Only a wait. A download that failed outright has nothing to come back for -- + // see DownloadDrainer on why a 404 is not retried for ever. + if (state == DownloadState.WAITING_FOR_SERVER) serverAudio?.waiting(episode) }, ) return result.map { it.file } diff --git a/app/src/main/java/org/terst/listen/playback/SleepTimer.kt b/app/src/main/java/org/terst/listen/playback/SleepTimer.kt index d94b371..9cab6cf 100644 --- a/app/src/main/java/org/terst/listen/playback/SleepTimer.kt +++ b/app/src/main/java/org/terst/listen/playback/SleepTimer.kt @@ -65,9 +65,20 @@ class SleepTimer(private val fadeMs: Long = 20_000) { /** Ramps linearly to silence over the final stretch, so the audio recedes rather than stops. */ fun volumeAt(nowMs: Long): Float { val remaining = remainingMs(nowMs) ?: return 1.0f - if (remaining >= fadeMs) return 1.0f + return volumeFor(remaining) + } + + /** + * The same ramp over a stretch somebody else is counting. + * + * End-of-episode mode has no deadline of its own -- what is left comes from the episode, and + * a seek moves it -- so the caller supplies the remaining time and the fade is shared rather + * than written twice. + */ + fun volumeFor(remainingMs: Long): Float { + if (remainingMs >= fadeMs) return 1.0f if (fadeMs <= 0) return 0.0f - return (remaining.toFloat() / fadeMs).coerceIn(0.0f, 1.0f) + return (remainingMs.toFloat() / fadeMs).coerceIn(0.0f, 1.0f) } fun state(nowMs: Long): State? = when { diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt index 48ab23f..3404589 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt @@ -29,6 +29,7 @@ data class EpisodeDetailActions( val reportAd: () -> Unit = {}, /** Stops playback after this many minutes, fading over the last twenty seconds. */ val armSleepTimer: (minutes: Long) -> Unit = {}, + val armSleepTimerAtEndOfEpisode: () -> Unit = {}, val cancelSleepTimer: () -> Unit = {}, /** Opens the episode's words, or the reason there are none. */ val openTranscript: () -> Unit = {}, diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt index 93294ba..4ce2f3b 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeMenu.kt @@ -4,6 +4,7 @@ import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.EpisodeTranscriptEntity import org.terst.listen.download.WaitingForServerText import org.terst.listen.feed.Chapter +import org.terst.listen.playback.SleepTimer /** What the overflow menu needs to know about the open episode. */ data class OverflowContent( @@ -12,8 +13,8 @@ data class OverflowContent( val loadChapters: suspend () -> List<Chapter>, val watchLink: String?, val positionMs: Long, - /** Milliseconds left on the sleep timer, or null when none is running. */ - val sleepRemainingMs: Long? = null, + /** What the sleep timer is waiting for, or null when none is running. */ + val sleep: SleepTimer.State? = null, /** What is known about this episode's words, or null when nobody has looked. */ val transcript: EpisodeTranscriptEntity? = null, ) @@ -38,14 +39,14 @@ data class EpisodeMenuItem(val action: EpisodeMenuAction, val label: String) object EpisodeMenu { /** - * [sleepRemainingMs] is null when no sleep timer is running. It is shown on the entry so the - * remaining time can be read without opening anything -- the one question anyone has about a - * timer they set in the dark. + * [sleep] is null when no sleep timer is running. What it is waiting for is shown on the + * entry so it can be read without opening anything -- the one question anyone has about a + * timer they set in the dark. End-of-episode mode has no countdown, so it names itself. */ fun items( episode: EpisodeEntity, watchLink: String?, - sleepRemainingMs: Long? = null, + sleep: SleepTimer.State? = null, transcript: EpisodeTranscriptEntity? = null, ): List<EpisodeMenuItem> = buildList { add(EpisodeMenuItem(EpisodeMenuAction.QUEUE, "Queue")) @@ -72,7 +73,11 @@ object EpisodeMenu { add( EpisodeMenuItem( EpisodeMenuAction.SLEEP, - if (sleepRemainingMs == null) "Sleep timer" else "Sleep timer \u00b7 ${formatClock(sleepRemainingMs)}", + when (sleep) { + null -> "Sleep timer" + is SleepTimer.State.Countdown -> "Sleep timer \u00b7 ${formatClock(sleep.remainingMs)}" + SleepTimer.State.EndOfEpisode -> "Sleep timer \u00b7 end of episode" + }, ), ) } diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt index 0622bb2..b2f8ecb 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeOverflow.kt @@ -35,6 +35,7 @@ import androidx.core.net.toUri import org.terst.listen.data.EpisodeEntity import org.terst.listen.download.WaitingForServerText import org.terst.listen.feed.Chapter +import org.terst.listen.playback.SleepTimer /** * The top bar's `⋮`: every secondary action, one tap away. What it lists is [EpisodeMenu]'s call. @@ -55,7 +56,7 @@ fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) { EpisodeMenu.items( content.episode, content.watchLink, - content.sleepRemainingMs, + content.sleep, content.transcript, ).forEach { item -> DropdownMenuItem( @@ -78,7 +79,7 @@ fun EpisodeOverflow(content: OverflowContent, actions: EpisodeDetailActions) { EpisodeMenuAction.CHAPTERS -> ChaptersDialog(content, actions.seekTo) { dialog = null } EpisodeMenuAction.PUBLISHER_COPY -> PublisherCopyDialog(content.episode, actions.downloadFromPublisher) { dialog = null } - EpisodeMenuAction.SLEEP -> SleepTimerDialog(content.sleepRemainingMs, actions) { dialog = null } + EpisodeMenuAction.SLEEP -> SleepTimerDialog(content.sleep, actions) { dialog = null } else -> Unit } } @@ -102,35 +103,46 @@ private fun perform(action: EpisodeMenuAction, actions: EpisodeDetailActions, wa /** * Set a sleep timer, or cancel the one running. * - * Durations rather than a picker: nobody sleeps in 37 minutes. The remaining time is shown + * Durations rather than a picker: nobody sleeps in 37 minutes. What it is waiting for is shown * because the whole point of opening this a second time is to find out how long is left. + * + * A duration **adds** to a timer already running, and the buttons say so -- "+15m" rather than + * "15m". Resetting silently took the remaining time away, on a control reached for half-asleep. + * "End of episode" is the timer's other mode, which the app supported internally and offered + * nowhere until 2026-11-03. */ @Composable -private fun SleepTimerDialog(remainingMs: Long?, actions: EpisodeDetailActions, dismiss: () -> Unit) { +private fun SleepTimerDialog(sleep: SleepTimer.State?, actions: EpisodeDetailActions, dismiss: () -> Unit) { + val running = sleep != null AlertDialog( onDismissRequest = dismiss, title = { Text("Sleep timer") }, text = { Column { Text( - if (remainingMs == null) { - "Volume fades over the last 20 seconds rather than cutting." - } else { - "Stopping in ${formatClock(remainingMs)}. Volume fades over the last 20 seconds." + when (sleep) { + null -> "Volume fades over the last 20 seconds rather than cutting." + is SleepTimer.State.Countdown -> + "Stopping in ${formatClock(sleep.remainingMs)}. Volume fades over the last 20 seconds." + SleepTimer.State.EndOfEpisode -> + "Stopping when this episode ends. Volume fades over the last 20 seconds." }, style = MaterialTheme.typography.bodyMedium, ) Row(Modifier.padding(top = 12.dp)) { SLEEP_MINUTES.forEach { minutes -> TextButton(onClick = { dismiss(); actions.armSleepTimer(minutes) }) { - Text("${minutes}m") + Text(if (running) "+${minutes}m" else "${minutes}m") } } } + TextButton(onClick = { dismiss(); actions.armSleepTimerAtEndOfEpisode() }) { + Text("End of episode") + } } }, confirmButton = { - if (remainingMs != null) { + if (running) { TextButton(onClick = { dismiss(); actions.cancelSleepTimer() }) { Text("Cancel timer") } } }, diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt index fec8d4e..1e8864f 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -43,6 +43,7 @@ import org.terst.listen.feed.WatchLink import org.terst.listen.fingerprint.EpisodeSkips import org.terst.listen.fingerprint.SkipSummary import org.terst.listen.playback.NowPlaying +import org.terst.listen.playback.SleepTimer import org.terst.listen.playback.PlaybackSpeed /** @@ -66,7 +67,7 @@ fun EpisodeScreen( var ranges by remember { mutableStateOf<List<EpisodeMidrollEntity>>(emptyList()) } var speed by remember { mutableFloatStateOf(PlaybackSpeed.DEFAULT) } val nowPlaying by player.nowPlaying.collectAsState() - val sleepRemaining by player.sleepRemainingMs.collectAsState() + val sleep by player.sleepState.collectAsState() // What the screen shows, which is the route's episode until the player moves off it. The // route argument cannot be the answer on its own: the queue advances without any navigation, @@ -102,7 +103,7 @@ fun EpisodeScreen( topBar = { EpisodeTopBar(EpisodeTitles.topBar(showTitle), nav.back) { current?.let { shown -> - Overflow(ScreenSource(vm, player), shown, nowPlaying, sleepRemaining, hooks) + Overflow(ScreenSource(vm, player), shown, nowPlaying, sleep, hooks) } } }, @@ -194,6 +195,7 @@ private fun episodeActions( reportAd = player::reportAd, openTranscript = hooks.openTranscript, armSleepTimer = player::armSleepTimer, + armSleepTimerAtEndOfEpisode = player::armSleepTimerAtEndOfEpisode, cancelSleepTimer = player::cancelSleepTimer, setSpeed = { chosen -> hooks.onSpeed(chosen) @@ -355,7 +357,7 @@ private fun Overflow( source: ScreenSource, shown: EpisodeEntity, nowPlaying: NowPlaying, - sleepRemainingMs: Long?, + sleep: SleepTimer.State?, hooks: EpisodeHooks, ) { val positionMs = if (nowPlaying.episodeId == shown.id) nowPlaying.positionMs else shown.positionMs @@ -367,7 +369,7 @@ private fun Overflow( EpisodeOverflow( OverflowContent( shown, chapters, WatchLink.at(shown.mediaUrl, positionMs), positionMs, - sleepRemainingMs, transcript, + sleep, transcript, ), episodeActions(source.vm, source.player, shown, nowPlaying, hooks), ) diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt index 4552211..7ff96b4 100644 --- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt @@ -13,13 +13,13 @@ import org.terst.listen.data.Graph import org.terst.listen.feed.Chapter import org.terst.listen.playback.NowPlaying import org.terst.listen.playback.PlaybackSpeed +import org.terst.listen.playback.SleepTimer import org.terst.listen.data.UiGraph import org.terst.listen.playback.PlaybackControls import org.terst.listen.playback.PlayerConnection import org.terst.listen.playback.StartOutcome import org.terst.listen.playback.BedtimeAction import org.terst.listen.playback.BedtimePolicy -import org.terst.listen.playback.SleepTimer import org.terst.listen.playback.toMediaItem /** @@ -187,18 +187,25 @@ class PlayerViewModel( // --- sleep timer --------------------------------------------------------------------- - private val sleepTimer = SleepTimer() - private val _sleepRemainingMs = MutableStateFlow<Long?>(null) - val sleepRemainingMs: StateFlow<Long?> = _sleepRemainingMs.asStateFlow() + /** The timer and the two ways of watching for the end; see [SleepController]. */ + private val sleep = SleepController( + scope = viewModelScope, + connection = connection, + onFinished = { + bedtimeArmedTimer = false + _banner.value = Banner("Sleep timer finished") + }, + ) - fun armSleepTimer(minutes: Long) { - sleepTimer.armFor(minutes * MS_PER_MINUTE, System.currentTimeMillis()) - watchSleepTimer() - } + val sleepRemainingMs: StateFlow<Long?> = sleep.remainingMs + val sleepState: StateFlow<SleepTimer.State?> = sleep.state + + fun armSleepTimer(minutes: Long) = sleep.arm(minutes * MS_PER_MINUTE) + + fun armSleepTimerAtEndOfEpisode() = sleep.armForEndOfEpisode() fun cancelSleepTimer() { - sleepTimer.cancel() - _sleepRemainingMs.value = null + sleep.cancel() bedtimeArmedTimer = false } @@ -212,7 +219,7 @@ class PlayerViewModel( /** Bedtime mode's effect on playback: see [BedtimePolicy] for why each case is what it is. */ fun onBedtimeChanged(entering: Boolean) { val action = if (entering) { - BedtimePolicy.entering(timerAlreadyArmed = sleepTimer.isArmed) + BedtimePolicy.entering(timerAlreadyArmed = sleep.isArmed) } else { BedtimePolicy.leaving(bedtimeArmedIt = bedtimeArmedTimer) } @@ -226,31 +233,6 @@ class PlayerViewModel( } } - /** - * 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 - bedtimeArmedTimer = false - _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 MS_PER_MINUTE = 60_000L diff --git a/app/src/main/java/org/terst/listen/ui/SleepController.kt b/app/src/main/java/org/terst/listen/ui/SleepController.kt new file mode 100644 index 0000000..7565f30 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/SleepController.kt @@ -0,0 +1,142 @@ +package org.terst.listen.ui + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +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.playback.PlaybackControls +import org.terst.listen.playback.SleepTimer + +/** + * Running the sleep timer against what is playing. + * + * Split out of [PlayerViewModel] when adding the second mode took that class past its function + * limit -- the same answer `ListenViewModel` got when it outgrew one, and for the same reason: + * the cluster that moved is a whole concern, not an arbitrary few methods. [SleepTimer] owns the + * arithmetic and is pure; this owns the two ways of watching for the end, which need a scope and + * the player. + * + * The modes watch differently on purpose. A countdown needs a clock, so it ticks. Stopping at the + * end of an episode needs to know the moment the episode is done, and a one-second poll means a + * second of the next episode playing to somebody who asked to be stopped -- so it follows + * `nowPlaying`, which already republishes every second for the progress bar. + */ +class SleepController( + private val scope: CoroutineScope, + private val connection: PlaybackControls, + private val onFinished: () -> Unit, + private val timer: SleepTimer = SleepTimer(), +) { + + private val _remainingMs = MutableStateFlow<Long?>(null) + val remainingMs: StateFlow<Long?> = _remainingMs.asStateFlow() + + private val _state = MutableStateFlow<SleepTimer.State?>(null) + val state: StateFlow<SleepTimer.State?> = _state.asStateFlow() + + val isArmed: Boolean get() = timer.isArmed + + /** The episode end-of-episode mode is waiting for, so the queue moving on counts as the end. */ + private var stopAfterEpisodeId: String? = null + private var endOfEpisodeWatch: Job? = null + + /** One loop however many times the timer is armed; extending makes re-arming routine. */ + private var ticking = false + + /** + * Adds to a running timer rather than replacing it. + * + * Every duration in the dialog used to reset: 15m on a timer with thirty seconds left gave + * 15:00 and quietly took those thirty seconds away. [SleepTimer.extendBy] was written for + * exactly this, with the reason in its own comment, and had no caller. + */ + fun arm(durationMs: Long) { + val now = System.currentTimeMillis() + if (timer.isArmed) timer.extendBy(durationMs, now) else timer.armFor(durationMs, now) + stopAfterEpisodeId = null + endOfEpisodeWatch?.cancel() + // At once rather than on the next tick: the dialog reads this the moment it reopens, and + // extending showed the figure from before the extension for up to a second. + publish(now) + tick() + } + + /** Stop when the current episode finishes. The timer's second mode, offered nowhere until now. */ + fun armForEndOfEpisode() { + timer.armForEndOfEpisode() + stopAfterEpisodeId = connection.nowPlaying.value.episodeId + _remainingMs.value = null + _state.value = SleepTimer.State.EndOfEpisode + watchEndOfEpisode() + } + + fun cancel() { + timer.cancel() + clear() + } + + private fun publish(nowMs: Long) { + _state.value = timer.state(nowMs) + _remainingMs.value = timer.remainingMs(nowMs) + } + + private fun tick() { + if (ticking) return + ticking = true + scope.launch { + try { + while (timer.isArmed && !timer.shouldStopAtEpisodeEnd) { + val now = System.currentTimeMillis() + publish(now) + if (timer.hasExpired(now)) { + finish() + break + } + connection.setVolume(timer.volumeAt(now)) + delay(TICK_MS) + } + } finally { + ticking = false + } + } + } + + private fun watchEndOfEpisode() { + endOfEpisodeWatch?.cancel() + endOfEpisodeWatch = scope.launch { + connection.nowPlaying.collect { playing -> + if (!timer.shouldStopAtEpisodeEnd) return@collect + // The queue moving on is the end of it, whatever the last position said. + if (playing.episodeId != stopAfterEpisodeId) { + finish() + return@collect + } + val duration = playing.durationMs ?: return@collect + val left = (duration - playing.positionMs).coerceAtLeast(0) + if (left <= 0) finish() else connection.setVolume(timer.volumeFor(left)) + } + } + } + + private fun finish() { + connection.setVolume(0f) + connection.pause() + timer.cancel() + clear() + onFinished() + } + + private fun clear() { + _remainingMs.value = null + _state.value = null + stopAfterEpisodeId = null + endOfEpisodeWatch?.cancel() + } + + private companion object { + const val TICK_MS = 1_000L + } +} diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt index e1e93b3..10d0409 100644 --- a/app/src/test/java/org/terst/listen/ReachabilityTest.kt +++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt @@ -12,8 +12,9 @@ import java.io.File * exception. Tests passing said nothing, because a tested function with no call site is still * dead code. * - * Scoped to `object` declarations on purpose: that is where pure helpers live here, and it is - * the shape where unreachable code hides without a compiler warning. + * Scoped to `object` declarations: a call on one names the object, so a caller can be matched + * exactly. Classes are checked separately and more loosely -- see the class test below for why + * they cannot use this one. */ class ReachabilityTest { @@ -160,6 +161,96 @@ class ReachabilityTest { private val allowedViewModelFunctions = emptySet<String>() + /** + * Methods on classes, which the object check structurally cannot see. + * + * The blind spot cost something real. SleepTimer is a class, and `armForEndOfEpisode`, + * `shouldStopAtEpisodeEnd` and `extendBy` sat written, documented and unit-tested with no + * caller anywhere -- so "stop at the end of this episode" did not exist in the app, and every + * duration in the sleep dialog reset the timer rather than extending it, which is the one + * thing `extendBy`'s own comment says not to do. Tests passing said nothing. + * + * Matched on `.name(` across all of main with declarations stripped, the same way the DAO + * check works and for the same reason: a call on a class names the *instance*, not the class, + * so `player.armSleepTimer(` cannot be found by looking for `PlayerViewModel.armSleepTimer(`. + * That makes this looser than the object check -- two classes with a same-named method alias + * each other -- and still worth having, because the names that hide are the distinctive ones. + * + * `override` members are excluded by [memberFun]: Kotlin requires the keyword, so anything + * reached through an interface is not reported here. + */ + @Test + fun `every class method has a caller`() { + val sources = mainDir.walkTopDown().filter { it.extension == "kt" }.toList() + assertTrue("could not find sources under ${mainDir.absolutePath}", sources.isNotEmpty()) + + val corpus = sources.joinToString("\n") { anyDeclaration.replace(it.readText(), "DECL(") } + + val classDecl = Regex( + """^(?:internal |abstract |sealed |open )*class (\w+)""", + RegexOption.MULTILINE, + ) + + val ownBodies = mutableMapOf<String, String>() + val declared = sources.flatMap { file -> + val body = file.readText() + classDecl.findAll(body).flatMap { declaration -> + val name = declaration.groupValues[1] + val slice = body.substring(declaration.range.first).let { rest -> + val next = nextTopLevel.find(rest, startIndex = 1)?.range?.first ?: rest.length + rest.substring(0, next) + } + ownBodies[name] = anyDeclaration.replace(slice, "DECL(") + memberFun.findAll(slice).map { "$name.${it.groupValues[1]}" } + } + } + + val dead = declared + .distinct() + .filterNot { it in allowedClassMethods } + .filterNot { qualified -> + val bare = qualified.substringAfter(".") + // Three shapes, all of them real in this codebase and all found the hard way. + // `.name(` is the ordinary call on an instance. `::name` is a method reference, + // which has no parens. `name {` is a trailing-lambda call with no parens either, + // which is how OpmlViewModel reaches importFrom and exportTo. + corpus.contains(".$bare(") || corpus.contains("::$bare") || corpus.contains(".$bare {") || + // A method called by its own class's entry point, with no receiver at all -- + // RetentionPolicy.shouldDelete, reached as `filter { shouldDelete(it, now) }`. + ownBodies[qualified.substringBefore(".")] + ?.let { it.contains("$bare(") || it.contains("$bare {") } == true + } + + assertTrue( + "No production caller for: ${dead.sorted()}. Either wire it up, delete it, or add " + + "it to allowedClassMethods with a reason.", + dead.isEmpty(), + ) + } + + /** + * Class methods deliberately not called from production. Every entry needs a reason. + * + * Both entries are the same fact: **on-device detection is dead code.** It moved to the + * server (see CLAUDE.md -- "FingerprintWorker and MidrollWorker are gone from the app"), and + * the workers went while the classes they drove stayed. Nothing constructs either updater; + * every reference to them from outside their own files is a KDoc cross-reference. + * + * That reaches further than these two methods. IntroOutroDetector, AudioCorrelation, + * MidrollScanner, MidrollDetector, MidrollClipStore, MediaCodecClipSource, Decimate, Fft, + * MonoResampler, PcmCodec, StoredClip and BreakTrack are all only reachable through them -- + * roughly two thousand lines of app code and fourteen test files, shipped in every APK. + * SkipSummary and BreakPopup are the only things in `fingerprint/` still live. + * + * Allowlisted rather than deleted because deleting a subsystem is the owner's call, and the + * audio-fingerprint work cost weeks and may yet be wanted back. Recorded here so it cannot + * be lost again: this is the entry to delete when that decision is made. + */ + private val allowedClassMethods = setOf( + "FingerprintUpdater.process", + "MidrollUpdater.processShow", + ) + @Test fun `every helper object function has a caller`() { val sources = mainDir.walkTopDown().filter { it.extension == "kt" }.toList() diff --git a/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt index bbb29af..f615eec 100644 --- a/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt +++ b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt @@ -189,6 +189,9 @@ class EpisodeDownloaderTest { override suspend fun record(episode: EpisodeEntity, info: ServerEpisode) { recorded = episode.id to info } + + var onWaiting: () -> Unit = {} + override suspend fun waiting(episode: EpisodeEntity) = onWaiting() } private fun withServer(audio: ServerAudio?) = @@ -283,6 +286,48 @@ class EpisodeDownloaderTest { assertEquals("nothing should be left in the library", 0, library.listFiles()!!.size) } + /* + A wait asks to be looked at again, rather than waiting for the next refresh. + + The server starts fetching the moment it is asked and its on-demand mirrors complete in + seconds -- 183 of them over the last thirty days. The phone was retrying only on a feed + refresh, which is the 5am alarm, a launch or the button, so an episode the server would have + within the minute read "waiting for server" for hours. That gap, not anything on the server, + is what makes "not checked yet" a state a listener sees. + + Only on a wait. A download that succeeded or failed outright has nothing to come back for. + */ + @Test + fun `an episode left waiting asks for a retry`() = runTest { + var retries = 0 + val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it } + + val audio = FakeServerAudio(pending()).also { it.onWaiting = { retries++ } } + + val result = withServer(audio).download(ep, SubscriptionKind.PODCAST) + + assertWaiting(result) + assertEquals("a wait must schedule a look back", 1, retries) + } + + @Test + fun `a download that worked asks for nothing`() = runTest { + server.enqueue(MockResponse().setBody("server-bytes")) + var retries = 0 + val ready = ServerEpisode( + ready = true, audioUrl = server.url("/audio/abc?token=t").toString(), + durationMs = 1, introMs = null, outroMs = null, detected = true, + ) + val ep = episode(url = "https://publisher.example/original.mp3").also { dao.rows += it } + + val audio = FakeServerAudio(ready).also { it.onWaiting = { retries++ } } + + withServer(audio).download(ep, SubscriptionKind.PODCAST) + + assertEquals("nothing to come back for", 0, retries) + } + + @Test fun `a pending server copy waits and never touches the publisher`() = runTest { val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it } diff --git a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt index f81d6d0..4a54a4e 100644 --- a/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt +++ b/app/src/test/java/org/terst/listen/ui/EpisodeMenuTest.kt @@ -7,6 +7,7 @@ import org.junit.Test import org.terst.listen.data.DownloadState import org.terst.listen.data.EpisodeEntity import org.terst.listen.data.EpisodeTranscriptEntity +import org.terst.listen.playback.SleepTimer import org.terst.listen.ui.EpisodeMenuAction.CHAPTERS import org.terst.listen.ui.EpisodeMenuAction.DOWNLOAD import org.terst.listen.ui.EpisodeMenuAction.MARK_OLDER_PLAYED @@ -103,21 +104,30 @@ class EpisodeMenuTest { */ @Test fun `the sleep timer is offered while something is playing`() { - assertTrue(EpisodeMenu.items(episode(), null, sleepRemainingMs = null).map { it.action } + assertTrue(EpisodeMenu.items(episode(), null, sleep = null).map { it.action } .contains(EpisodeMenuAction.SLEEP)) } @Test fun `an unarmed timer offers to start one`() { - val item = EpisodeMenu.items(episode(), null, sleepRemainingMs = null) + val item = EpisodeMenu.items(episode(), null, sleep = null) .single { it.action == EpisodeMenuAction.SLEEP } assertEquals("Sleep timer", item.label) } + /* The mode has no countdown, so the entry says which mode rather than a time. */ + @Test + fun `a timer set for the end of the episode says so`() { + val item = EpisodeMenu.items(episode(), null, sleep = SleepTimer.State.EndOfEpisode) + .single { it.action == EpisodeMenuAction.SLEEP } + + assertEquals("Sleep timer \u00b7 end of episode", item.label) + } + @Test fun `an armed timer says how long is left, so it can be checked without opening anything`() { - val item = EpisodeMenu.items(episode(), null, sleepRemainingMs = 12 * 60_000 + 34_000) + val item = EpisodeMenu.items(episode(), null, sleep = SleepTimer.State.Countdown(12 * 60_000 + 34_000)) .single { it.action == EpisodeMenuAction.SLEEP } assertEquals("Sleep timer \u00b7 12:34", item.label) diff --git a/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt b/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt index 1a29593..b61deb8 100644 --- a/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt +++ b/app/src/test/java/org/terst/listen/ui/FakePlaybackControls.kt @@ -46,6 +46,16 @@ class FakePlaybackControls : PlaybackControls { _nowPlaying.value = _nowPlaying.value.copy(episodeId = episodeId, isPlaying = episodeId != null) } + /** As [playing], with the position and length the end-of-episode sleep mode reads. */ + fun playingAt(episodeId: String?, positionMs: Long, durationMs: Long?) { + _nowPlaying.value = _nowPlaying.value.copy( + episodeId = episodeId, + isPlaying = episodeId != null, + positionMs = positionMs, + durationMs = durationMs, + ) + } + override fun connect() { connects++ } diff --git a/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt index 844b90f..3195f46 100644 --- a/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt +++ b/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt @@ -15,6 +15,7 @@ import org.terst.listen.data.SubscriptionEntity import org.terst.listen.data.SubscriptionKind import org.terst.listen.playback.BedtimePolicy import org.terst.listen.playback.PlaybackSpeed +import org.terst.listen.playback.SleepTimer /** * The controls that act on whatever is playing. @@ -310,6 +311,76 @@ class PlayerViewModelTest { // --- bedtime mode ------------------------------------------------------------------- + /* + Tapping a duration on a running timer adds to it. + + SleepTimer.extendBy was written for this, documented with the reason -- "+5 minutes on a timer + with 30 seconds to run means five and a half minutes ... a control people reach for half-asleep + without watching the result" -- and then never called. Every button in the dialog went to + armFor, which resets, so 15m on a timer with 30 seconds left gave 15:00 and silently took those + 30 seconds away. + */ + @Test + fun `tapping a duration while a timer runs adds to what is left`() { + vm.armSleepTimer(5) + + vm.armSleepTimer(15) + + val remaining = vm.sleepRemainingMs.value!! + assertTrue( + "15m on top of 5m should be about 20m, got ${remaining / MINUTE}m", + remaining in 19 * MINUTE..20 * MINUTE, + ) + } + + /* Nothing running: the first tap sets the timer rather than adding to zero. */ + @Test + fun `tapping a duration with no timer running sets it`() { + vm.armSleepTimer(15) + + val remaining = vm.sleepRemainingMs.value!! + assertTrue(remaining in 14 * MINUTE..15 * MINUTE) + } + + /* + The second mode the app never offered. + + SleepTimer has supported stopping at the end of the episode since it was written, and nothing + called armForEndOfEpisode or read shouldStopAtEpisodeEnd. ReachabilityTest did not catch it + because it only scans object declarations. + */ + @Test + fun `stopping at the end of the episode is a mode the timer reports`() { + controls.playing("e1") + + vm.armSleepTimerAtEndOfEpisode() + + assertEquals(SleepTimer.State.EndOfEpisode, vm.sleepState.value) + assertNull("there is no countdown to show in this mode", vm.sleepRemainingMs.value) + } + + @Test + fun `the end of the episode stops playback`() { + controls.playingAt("e1", positionMs = 3_600_000, durationMs = 3_600_000) + + vm.armSleepTimerAtEndOfEpisode() + + assertTrue("reaching the end must pause", controls.pauses >= 1) + assertNull(vm.sleepState.value) + } + + /* The queue moving on is the end of the episode, whatever the position said. */ + @Test + fun `the episode being replaced stops playback`() { + controls.playingAt("e1", positionMs = 10_000, durationMs = 3_600_000) + vm.armSleepTimerAtEndOfEpisode() + assertEquals(SleepTimer.State.EndOfEpisode, vm.sleepState.value) + + controls.playingAt("e2", positionMs = 0, durationMs = 3_600_000) + + assertTrue("advancing to another episode must pause", controls.pauses >= 1) + } + @Test fun `entering bedtime mode arms the timer`() { vm.onBedtimeChanged(entering = true) |
