Skip to content

Commit ce95297

Browse files
Fix the sleep timer (#3112)
Fixes #3110
1 parent caa82f1 commit ce95297

File tree

4 files changed

+98
-44
lines changed

4 files changed

+98
-44
lines changed
Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,9 @@
11
package voice.core.sleeptimer
22

3-
import android.content.Context
4-
import android.hardware.SensorManager
5-
import dev.zacsweers.metro.Inject
6-
import kotlinx.coroutines.suspendCancellableCoroutine
7-
import kotlin.coroutines.resume
8-
import com.squareup.seismic.ShakeDetector as SeismicShakeDetector
9-
10-
@Inject
11-
class ShakeDetector(private val context: Context) {
3+
interface ShakeDetector {
124

135
/**
146
* This function returns once a shake was detected
157
*/
16-
suspend fun detect() {
17-
suspendCancellableCoroutine { cont ->
18-
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager?
19-
?: return@suspendCancellableCoroutine
20-
val listener = SeismicShakeDetector.Listener {
21-
if (!cont.isCompleted) {
22-
cont.resume(Unit)
23-
}
24-
}
25-
val shakeDetector = SeismicShakeDetector(listener)
26-
shakeDetector.start(sensorManager, SensorManager.SENSOR_DELAY_GAME)
27-
cont.invokeOnCancellation {
28-
shakeDetector.stop()
29-
}
30-
}
31-
}
8+
suspend fun detect()
329
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package voice.core.sleeptimer
2+
3+
import android.content.Context
4+
import android.hardware.SensorManager
5+
import dev.zacsweers.metro.AppScope
6+
import dev.zacsweers.metro.ContributesBinding
7+
import dev.zacsweers.metro.Inject
8+
import kotlinx.coroutines.suspendCancellableCoroutine
9+
import kotlin.coroutines.resume
10+
import com.squareup.seismic.ShakeDetector as SeismicShakeDetector
11+
12+
@ContributesBinding(AppScope::class)
13+
@Inject
14+
class ShakeDetectorImpl(private val context: Context) : ShakeDetector {
15+
16+
override suspend fun detect() {
17+
suspendCancellableCoroutine { cont ->
18+
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager?
19+
?: return@suspendCancellableCoroutine
20+
val listener = SeismicShakeDetector.Listener {
21+
if (!cont.isCompleted) {
22+
cont.resume(Unit)
23+
}
24+
}
25+
val shakeDetector = SeismicShakeDetector(listener)
26+
shakeDetector.start(sensorManager, SensorManager.SENSOR_DELAY_GAME)
27+
cont.invokeOnCancellation {
28+
shakeDetector.stop()
29+
}
30+
}
31+
}
32+
}

core/sleeptimer/impl/src/main/kotlin/voice/core/sleeptimer/SleepTimerImpl.kt

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ class SleepTimerImpl(
7171
playerController.setVolume(1F)
7272
}
7373

74-
private suspend fun startCountdown(duration: Duration) {
74+
private tailrec suspend fun startCountdown(duration: Duration) {
7575
Logger.d("startCountdown(duration=$duration)")
7676
var left = duration
7777
_state.value = SleepTimerState.Enabled.WithDuration(left)
@@ -95,15 +95,21 @@ class SleepTimerImpl(
9595

9696
playerController.pauseWithRewind(fadeOutDuration)
9797

98-
val shakeToResetTime = 30.seconds
99-
Logger.d("Waiting $shakeToResetTime for shake...")
100-
withTimeoutOrNull(shakeToResetTime) {
101-
shakeDetector.detect()
98+
val shakeDetected = detectShakeWithTimeout()
99+
playerController.setVolume(1F)
100+
if (shakeDetected) {
102101
Logger.i("Shake detected, resetting timer")
103102
playerController.play()
104103
startCountdown(duration)
105104
}
106-
playerController.setVolume(1F)
105+
}
106+
107+
private suspend fun detectShakeWithTimeout(): Boolean {
108+
Logger.d("Waiting $SHAKE_TO_RESET_TIME for shake...")
109+
return withTimeoutOrNull(SHAKE_TO_RESET_TIME) {
110+
shakeDetector.detect()
111+
true
112+
} ?: false
107113
}
108114

109115
private fun updateVolume(
@@ -122,4 +128,8 @@ class SleepTimerImpl(
122128
Logger.i("Playback resumed.")
123129
}
124130
}
131+
132+
internal companion object {
133+
val SHAKE_TO_RESET_TIME = 30.seconds
134+
}
125135
}

core/sleeptimer/impl/src/test/kotlin/voice/core/sleeptimer/impl/SleepTimerImplTest.kt

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ import io.kotest.matchers.collections.shouldEndWith
66
import io.kotest.matchers.collections.shouldNotBeEmpty
77
import io.kotest.matchers.shouldBe
88
import io.mockk.Runs
9-
import io.mockk.coEvery
109
import io.mockk.coVerify
1110
import io.mockk.every
1211
import io.mockk.just
1312
import io.mockk.mockk
14-
import kotlinx.coroutines.delay
15-
import kotlinx.coroutines.suspendCancellableCoroutine
13+
import io.mockk.verify
14+
import kotlinx.coroutines.channels.Channel
1615
import kotlinx.coroutines.test.StandardTestDispatcher
1716
import kotlinx.coroutines.test.TestScope
1817
import kotlinx.coroutines.test.advanceTimeBy
18+
import kotlinx.coroutines.test.runCurrent
1919
import kotlinx.coroutines.test.runTest
2020
import kotlinx.coroutines.yield
2121
import org.junit.BeforeClass
@@ -33,24 +33,35 @@ import voice.core.sleeptimer.SleepTimerMode
3333
import voice.core.sleeptimer.SleepTimerState
3434
import kotlin.time.Duration.Companion.seconds
3535

36+
private class TestShakeDetector : ShakeDetector {
37+
private val shakes = Channel<Unit>(capacity = Channel.UNLIMITED)
38+
override suspend fun detect() {
39+
shakes.receive()
40+
}
41+
42+
fun shake() {
43+
shakes.trySend(Unit)
44+
}
45+
}
46+
3647
class SleepTimerImplTest {
3748

3849
private val playStateManager = PlayStateManager().apply {
3950
playState = PlayStateManager.PlayState.Playing
4051
}
41-
private val shakeDetector = mockk<ShakeDetector> {
42-
coEvery { detect() } coAnswers {
43-
delay(30.seconds)
44-
}
45-
}
46-
52+
private val shakeDetector = TestShakeDetector()
4753
private val sleepTimerPreferenceStore = MemoryDataStore(SleepTimerPreference.Default)
4854
private val setVolumeSlots = mutableListOf<Float>()
4955
private val playerController = mockk<PlayerController> {
5056
every { setVolume(capture(setVolumeSlots)) } just Runs
5157
every { pauseWithRewind(any()) } answers {
5258
playStateManager.playState = PlayStateManager.PlayState.Paused
5359
}
60+
every {
61+
play()
62+
} answers {
63+
playStateManager.playState = PlayStateManager.PlayState.Playing
64+
}
5465
}
5566

5667
private val fadeOutStore = MemoryDataStore(2.seconds)
@@ -78,10 +89,6 @@ class SleepTimerImplTest {
7889

7990
@Test
8091
fun `enable with fixed duration eventually disables and pauses playback`() = testScope.runTest {
81-
coEvery { shakeDetector.detect() } coAnswers {
82-
suspendCancellableCoroutine { } // never completes
83-
}
84-
8592
sleepTimer.enable(SleepTimerMode.TimedWithDuration(1.seconds))
8693

8794
advanceTimeBy(2.seconds)
@@ -130,6 +137,34 @@ class SleepTimerImplTest {
130137
setVolumeSlots.shouldEndWith(1f)
131138
}
132139

140+
@Test
141+
fun shake_does_not_cancel_second_countdown_after_window() = testScope.runTest {
142+
// Use a LONG duration so we can observe behavior across the 30s window
143+
val longDuration = SleepTimerImpl.SHAKE_TO_RESET_TIME * 2
144+
145+
sleepTimer.enable(SleepTimerMode.TimedWithDuration(longDuration))
146+
147+
// 1) Let the first countdown finish and enter the shake window
148+
advanceTimeBy(longDuration + 1.seconds)
149+
runCurrent()
150+
coVerify(exactly = 1) { playerController.pauseWithRewind(any()) }
151+
sleepTimer.state.value shouldBe SleepTimerState.Disabled
152+
153+
// 2) Trigger the shake → a new countdown should start independently of the old timeout
154+
shakeDetector.shake()
155+
runCurrent()
156+
verify(exactly = 1) { playerController.play() }
157+
sleepTimer.state.value shouldBe SleepTimerState.Enabled.WithDuration(longDuration)
158+
159+
// 3) Advance past the original 30s shake window and allow the second countdown to finish
160+
advanceTimeBy(SleepTimerImpl.SHAKE_TO_RESET_TIME + longDuration + 2.seconds)
161+
runCurrent()
162+
163+
// The second countdown should complete normally
164+
coVerify(exactly = 2) { playerController.pauseWithRewind(any()) }
165+
sleepTimer.state.value shouldBe SleepTimerState.Disabled
166+
}
167+
133168
companion object {
134169

135170
@BeforeClass

0 commit comments

Comments
 (0)