kotlinkotlin-coroutinescoroutinescope

Restartable count down in Kotlin


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)
            }
        }
    }
}

Solution

  • 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)
                }
            }
        }
    }