From d5016183adf01fb470a50dcfb8951df75b0b06b2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 21 Oct 2026 15:31:38 +0200 Subject: [PATCH] fix: fall back at once when a music app refuses the alarm The start waited for PLAYING and nothing else, so an app that answered the play request with an error (YouTube without Premium) left the alarm vibrating in silence for the full 15 s START_TIMEOUT before the default sound took over. An error stamped at or after the request, or a lost connection, now ends the wait straight away. An error left over from the user's own listening still doesn't count. --- .../clockula/domain/ring/MediaRingPolicy.kt | 11 +++++++++++ .../jeanlucmakiola/clockula/ring/MediaAppRing.kt | 16 +++++++++++++++- .../clockula/domain/ring/MediaRingPolicyTest.kt | 10 ++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicy.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicy.kt index 15955c6..6872131 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicy.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicy.kt @@ -60,6 +60,17 @@ object MediaRingPolicy { fun shouldFallBack(stall: Stall): Boolean = stall.stopped >= STALL_GRACE || stall.notPlaying >= BUFFER_GRACE + /** + * Whether the app turned down the ring's play request, so the start can + * fall back now instead of waiting out [START_TIMEOUT]. YouTube without + * Premium does this at once. Only an error reported at or after + * [requestedAtMillis] counts: one left over from the user's own listening + * says nothing about this request. Both times are `elapsedRealtime`, which + * is what `PlaybackState` stamps its updates with. + */ + fun refusedStart(isError: Boolean, updatedAtMillis: Long, requestedAtMillis: Long): Boolean = + isError && updatedAtMillis >= requestedAtMillis + /** * The media-stream index a fully ramped ring plays at. * diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ring/MediaAppRing.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/MediaAppRing.kt index 8dd18c9..6dd0afe 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ring/MediaAppRing.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/MediaAppRing.kt @@ -6,6 +6,7 @@ import android.media.AudioManager import android.media.browse.MediaBrowser import android.media.session.MediaController import android.media.session.PlaybackState +import android.os.SystemClock import de.jeanlucmakiola.clockula.data.ringtones.connectMediaBrowser import de.jeanlucmakiola.clockula.domain.MediaSourceRef import de.jeanlucmakiola.clockula.domain.ring.MediaPlayback @@ -86,6 +87,7 @@ internal class MediaAppRing(private val context: Context, private val ref: Media } } if (stopped) return false + val requestedAt = SystemClock.elapsedRealtime() runCatching { if (ref.mediaId != null) controls.playFromMediaId(ref.mediaId, null) else controls.play() }.onFailure { return false } @@ -93,11 +95,23 @@ internal class MediaAppRing(private val context: Context, private val ref: Media return withTimeoutOrNull(MediaRingPolicy.START_TIMEOUT) { // Strictly PLAYING here: an app stuck buffering a stream it cannot // reach must not count as a ring that started. - while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) delay(POLL_MILLIS) + while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) { + if (connectionLost || refused(requestedAt)) return@withTimeoutOrNull false + delay(POLL_MILLIS) + } !stopped } ?: false } + private fun refused(requestedAt: Long): Boolean { + val state = runCatching { controller?.playbackState }.getOrNull() ?: return false + return MediaRingPolicy.refusedStart( + isError = state.state == PlaybackState.STATE_ERROR, + updatedAtMillis = state.lastPositionUpdateTime, + requestedAtMillis = requestedAt, + ) + } + fun playback(): MediaPlayback { if (connectionLost) return MediaPlayback.STOPPED return when (playbackState()) { diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicyTest.kt index 379f4a9..39fce88 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicyTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/ring/MediaRingPolicyTest.kt @@ -29,6 +29,16 @@ class MediaRingPolicyTest { assertThat(MediaRingPolicy.shouldFallBack(long)).isTrue() } + @Test + fun `an error after the play request ends the start, an older one does not`() { + val requestedAt = 10_000L + + assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = 10_050L, requestedAtMillis = requestedAt)).isTrue() + assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = requestedAt, requestedAtMillis = requestedAt)).isTrue() + assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = 9_000L, requestedAtMillis = requestedAt)).isFalse() + assertThat(MediaRingPolicy.refusedStart(isError = false, updatedAtMillis = 10_050L, requestedAtMillis = requestedAt)).isFalse() + } + @Test fun `buffering forever is a failure too`() { val brief = run(*repeat(MediaPlayback.TRANSITIONING, MediaRingPolicy.BUFFER_GRACE - tick))