Skip to content

Commit

Permalink
Merge pull request #815 from splendo/feature/safe-timer-stop
Browse files Browse the repository at this point in the history
Add support for safely stopping a timer
  • Loading branch information
thoutbeckers authored Oct 2, 2024
2 parents 73fd40c + 780a0dd commit f8ac7c3
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration
Expand All @@ -55,29 +61,57 @@ class RecurringTimer(
delayFunction: DelayFunction = { delayDuration -> delay(delayDuration) },
coroutineScope: CoroutineScope = MainScope(),
) : ControllableTimer {
private val stateRepo = TimerStateRepo(duration, interval, timeSource, delayFunction, coroutineScope)
override val state: Flow<Timer.State> = stateRepo.stateFlow.map { it.timerState }
override val currentState: Timer.State get() = stateRepo.stateFlow.value.timerState

private sealed class State {
data class Active(val repo: TimerStateRepo) : State()
data class Finished(val state: Timer.State.NotRunning.Finished) : State()
}

private val stateRepo: StateFlow<State>

init {
val repo = TimerStateRepo(duration, interval, timeSource, delayFunction, coroutineScope)
val stateRepo = MutableStateFlow<State>(State.Active(repo))
this.stateRepo = stateRepo.asStateFlow()
coroutineScope.launch {
awaitFinish()
stateRepo.cancel()
val finishedState = repo.stateFlow.filterIsInstance<Timer.State.NotRunning.Finished>().first()
stateRepo.compareAndSet(State.Active(repo), State.Finished(finishedState))
repo.cancel()
}
}

override suspend fun start() = runIfNotFinished { start() }

override suspend fun pause() = runIfNotFinished { pause() }
override val state: Flow<Timer.State> = stateRepo.flatMapLatest { state ->
when (state) {
is State.Active -> state.repo.stateFlow.map { it.timerState }
is State.Finished -> flowOf(state.state)
}
}
override val currentState: Timer.State get() = when (val state = stateRepo.value) {
is State.Active -> state.repo.stateFlow.value.timerState
is State.Finished -> state.state
}

override suspend fun stop() = runIfNotFinished { stop() }
override suspend fun start(): Boolean = stateRepo.transformLatest { state ->
when (state) {
is State.Active -> emit(state.repo.start())
is State.Finished -> emit(false)
}
}.first()

private suspend fun runIfNotFinished(block: suspend TimerStateRepo.() -> Unit) {
if (stateRepo.peekState() is Timer.State.NotRunning.Finished) {
throw IllegalStateException("Timer has already finished")
} else {
stateRepo.block()
override suspend fun pause() = stateRepo.transformLatest { state ->
when (state) {
is State.Active -> emit(state.repo.pause())
is State.Finished -> emit(false)
}
}.first()

override suspend fun stop() {
stateRepo.transformLatest { state ->
when (state) {
is State.Active -> state.repo.stop()
is State.Finished -> emit(Unit)
}
}.first()
}
}

Expand All @@ -94,29 +128,25 @@ private class TimerStateRepo(
State.NotRunning.Paused(elapsedSoFar = Duration.ZERO, totalDuration = totalDuration)
},
) {
suspend fun start() {
withContext(coroutineScope.coroutineContext) {
takeAndChangeState { state ->
when (state) {
is State.NotRunning.Paused -> suspend {
state.start(interval, timeSource, delayFunction, coroutineScope, ::finish)
}
is State.NotRunning.Finished, is State.Running -> state.remain()
suspend fun start(): Boolean = withContext(coroutineScope.coroutineContext) {
takeAndChangeState { state ->
when (state) {
is State.NotRunning.Paused -> suspend {
state.start(interval, timeSource, delayFunction, coroutineScope, ::finish)
}
is State.NotRunning.Finished, is State.Running -> state.remain()
}
}
}
} is State.Running

suspend fun pause() {
withContext(coroutineScope.coroutineContext) {
takeAndChangeState { state ->
when (state) {
is State.Running -> state::pause
is State.NotRunning -> state.remain()
}
suspend fun pause() = withContext(coroutineScope.coroutineContext) {
takeAndChangeState { state ->
when (state) {
is State.Running -> state::pause
is State.NotRunning -> state.remain()
}
}
}
} is State.NotRunning.Paused

suspend fun stop() {
withContext(coroutineScope.coroutineContext) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,18 @@ interface Timer {
interface ControllableTimer : Timer {
/**
* Starts the timer.
* @throws [IllegalStateException] if the timer has finished.
* @return `true` if the timer as started successfully, `false` otherwise.
* */
suspend fun start()
suspend fun start(): Boolean

/**
* Pauses the timer. Calling [start] again will make it resume.
* @throws [IllegalStateException] if the timer has finished.
* @return `true` if the timer as paused successfully, `false` otherwise.
* */
suspend fun pause()
suspend fun pause(): Boolean

/**
* Stops the timer causing it to finish. Calling [start] again will throw [IllegalStateException]
* @throws [IllegalStateException] if the timer has finished.
* Stops the timer causing it to finish. Calling [start] again will return `false`.
* */
suspend fun stop()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource

Expand All @@ -42,24 +42,24 @@ class RecurringTimerTest {
fun stateTransitions(): Unit = runBlocking {
val timerScope = CoroutineScope(Dispatchers.Default)
val timer = RecurringTimer(
duration = 100.milliseconds,
duration = 1.seconds,
interval = 10.milliseconds,
coroutineScope = timerScope,
)
timer.state.assertEmits("timer was not paused after creation") { it is Timer.State.NotRunning.Paused }
timer.start()
assertTrue(timer.start())
timer.state.assertEmits("timer is not running after start") { it is Timer.State.Running }
timer.pause()
assertTrue(timer.pause())
timer.state.assertEmits("timer was not paused after pause") { it is Timer.State.NotRunning.Paused }
delay(500)
timer.state.assertEmits("timer pause is not working") { it is Timer.State.NotRunning.Paused }
timer.start()
assertTrue(timer.start())
timer.state.assertEmits("timer is not running after start") { it is Timer.State.Running }
delay(500)
delay(1.5.seconds)
timer.state.assertEmits("timer was not finished after time elapsed") { it is Timer.State.NotRunning.Finished }
assertFailsWith<IllegalStateException> { timer.start() }
assertFalse(timer.start())
timer.state.assertEmits("was able to start timer after finish") { it is Timer.State.NotRunning.Finished }
assertFailsWith<IllegalStateException> { timer.pause() }
assertFalse(timer.pause())
timer.state.assertEmits("was able to pause timer after finish") { it is Timer.State.NotRunning.Finished }
timer.awaitFinish()
}
Expand Down Expand Up @@ -91,13 +91,12 @@ class RecurringTimerTest {
val initial = timer.elapsed().captureFor(100.milliseconds)
assertEquals(listOf(Duration.ZERO), initial, "timer was not started in paused state")

timer.start()
assertTrue(timer.start())
assertTrue(timerScope.isActive)
timer.state.assertEmits("timer is not running after start") { it is Timer.State.Running }
timer.stop()
timer.state.assertEmits("timer is not running after start") { it is Timer.State.NotRunning.Finished }
assertFailsWith<IllegalStateException> { timer.start() }
assertFailsWith<IllegalStateException> { timer.pause() }
assertFalse(timer.start())
assertFalse(timerScope.isActive)
}

Expand All @@ -119,15 +118,15 @@ class RecurringTimerTest {
assertEquals(listOf(Duration.ZERO), initial, "timer was not started in paused state")

// capture and validate a first chunk of data
timer.start()
assertTrue(timer.start())
val result0 = timer.elapsed().captureFor(200.milliseconds)
timer.pause()
assertTrue(timer.pause())
assertTrue(result0.isNotEmpty(), "values not emitted")
assertTrue(initial.last() <= result0.first(), "values are not in ascending order")
assertTrue(result0.isAscending(), "values are not in ascending order")

// capture and validate the rest of the data
timer.start()
assertTrue(timer.start())
val result1 = timer.elapsed().captureFor(1000.milliseconds)
assertTrue(result1.isNotEmpty(), "values not emitted")
assertTrue(result0.last() <= result1.first(), "values are not in ascending order")
Expand Down

0 comments on commit f8ac7c3

Please sign in to comment.