0

I try to implement a restartable count down in pure Kotlin (without CountDownTimer from Android SDK)

I got inspired from How to create a simple countdown timer in Kotlin?

I adapted it because I want coroutine scope managed by caller.

I add a filter in initTimer flow to stop and restart countdown when coroutine is cancelled but it doesn't work, count down continue and not restarted, when I call toggleTime before last is not finished.

class CountDownTimerUseCase {

    private val _countDown = MutableStateFlow(0)
    val countDown: StateFlow<Int> = _countDown

    private var countDownTimerJob: Job? = null

    suspend fun toggleTime(totalSeconds: Int) {
        coroutineScope {
            countDownTimerJob?.cancel()

            countDownTimerJob = launch {
                initTimer(this, totalSeconds)
                    .cancellable()
                    .onCompletion { _countDown.emit(0) }
                    .collect { _countDown.emit(it) }
            }
        }
    }

    /**
     * The timer emits the total seconds immediately.
     * Each second after that, it will emit the next value.
     */
    private suspend fun initTimer(coroutineScope: CoroutineScope, totalSeconds: Int): Flow<Int> =
        (totalSeconds - 1 downTo 0)
            .filter {
                //coroutineContext[Job]?.isActive == true
                coroutineScope.isActive
            }
            .asFlow() // Emit total - 1 because the first was emitted onStart
            .cancellable()
            .onEach { delay(1000) } // Each second later emit a number
            .onStart { emit(totalSeconds) } // Emit total seconds immediately
            .conflate() // In case the operation onTick takes some time, conflate keeps the time ticking separately
            .transform { remainingSeconds: Int ->
                emit(remainingSeconds)
            }
}

Here the junit test :

class CountDownTimerUseCaseTest {

    private val countDownTimerUseCase = CountDownTimerUseCase()

    @Test
    fun `WHEN count down timer re-start THEN get re-initialized tick`() = runTest{
        countDownTimerUseCase.countDown.test {

            //init value
            var tick = awaitItem()
            assertEquals(0, tick)

            //start count down
            countDownTimerUseCase.toggleTime(30)

            // not loop until 0 to be sure cancel is done before the end
            for (i in 30 downTo  1) {
                tick = awaitItem()
                println(tick)
                if(tick==0) {
                    //re-start has be done
                    break
                }
                assertEquals(i, tick)
                if(i==30) {
                    println("relaunch")
                    countDownTimerUseCase.toggleTime(30)
                }
            }

            // check tick after restart
            for (i in 30 downTo  0) {
                tick = awaitItem()
                println(tick)
                assertEquals(i, tick)
            }
        }
    }
}
LaurentY
  • 7,495
  • 3
  • 37
  • 55
  • 1
    I think the problem is your use of `coroutineScope` in your `toggleTime()` function. `coroutineScope` doesn't return until any child coroutines are finished, so your `toggleTime()` in practical use would only return after the whole countdown is finished. Combine that with the way `runTest` skips delays, and you get some weird behavior. Just a guess. I think your code can be simplified quite a bit: https://pl.kotl.in/HLN5N-C4j I don't know how to write a proper test for this though. – Tenfour04 Feb 15 '23 at 17:09
  • 1
    Your filter is called before you convert to a Flow. It will run on every item in the Iterable range immediately before the resulting List is converted to a Flow. Your flow is already cancellable anyway since you use `cancellable()`. – Tenfour04 Feb 15 '23 at 17:47

1 Answers1

0

Solution in comment from @Tenfour04 works, thanks

class CountDownTimerUseCase {

    private val _countDown = MutableStateFlow(0)
    val countDown: StateFlow<Int> = _countDown

    private var countDownTimerJob: Job? = null

    fun toggleTime(scope: CoroutineScope, totalSeconds: Int) {
        countDownTimerJob?.cancel()
        countDownTimerJob = initTimer(totalSeconds)
            .onEach { _countDown.emit(it) }
            .onCompletion { _countDown.emit(0) }
            .launchIn(scope)
    }

    /**
     * The timer emits the total seconds immediately.
     * Each second after that, it will emit the next value.
     */
    private fun initTimer(totalSeconds: Int): Flow<Int> =
        flow {
            for (i in totalSeconds downTo 1) {
                emit(i)
                delay(1000)
            }
            emit(0)
        }.conflate()

}

And unit-tests:

class CountDownTimerUseCaseTest {

    private val countDownTimerUseCase = CountDownTimerUseCase()

    @Test
    fun `WHEN count down timer start THEN get tick`() = runTest {
        countDownTimerUseCase.countDown.test {
            //init value
            var tick = awaitItem()
            assertEquals(0, tick)

            countDownTimerUseCase.toggleTime(this@runTest, 30)

            for (i in 30 downTo 0) {
                tick = awaitItem()
                assertEquals(i, tick)
            }
        }
    }

    @Test
    fun `WHEN count down timer re-start THEN get re-initialized tick`() = runTest{
        countDownTimerUseCase.countDown.test {

            //init value
            var tick = awaitItem()
            assertEquals(0, tick)

            //start count down
            countDownTimerUseCase.toggleTime(this@runTest, 30)

            // not loop until 0 to be sure cancel is done before the end
            for (i in 30 downTo  1) {
                tick = awaitItem()
                if(tick==0) {
                    //re-start has be done
                    break
                }
                assertEquals(i, tick)
                if(i==30) {
                    countDownTimerUseCase.toggleTime(this@runTest, 30)
                }
            }

            // check tick after restart
            for (i in 30 downTo  0) {
                tick = awaitItem()
                assertEquals(i, tick)
            }
        }
    }
}
LaurentY
  • 7,495
  • 3
  • 37
  • 55