TestMonotonicFrameClock.jvm.kt

/*
 * Copyright 2020 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.ui.test

import androidx.compose.runtime.MonotonicFrameClock
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.test.DelayController
import kotlin.coroutines.ContinuationInterceptor

private const val DefaultFrameDelay = 16_000_000L

/**
 * Construct a [TestMonotonicFrameClock] for [coroutineScope], obtaining the [DelayController]
 * from the scope's [context][CoroutineScope.coroutineContext]. This frame clock may be used to
 * consistently drive time under controlled tests.
 *
 * Calls to [TestMonotonicFrameClock.withFrameNanos] will schedule an upcoming frame
 * [frameDelayNanos] nanoseconds in the future by launching into [coroutineScope] if such a frame
 * has not yet been scheduled.
 */
@Suppress("MethodNameUnits") // Nanos for high-precision animation clocks
@ExperimentalCoroutinesApi
fun TestMonotonicFrameClock(
    coroutineScope: CoroutineScope,
    frameDelayNanos: Long = DefaultFrameDelay
): TestMonotonicFrameClock = TestMonotonicFrameClock(
    coroutineScope = coroutineScope,
    delayController = coroutineScope.coroutineContext[ContinuationInterceptor].let { interceptor ->
        requireNotNull(interceptor as? DelayController) {
            "ContinuationInterceptor $interceptor of supplied scope must implement DelayController"
        }
    },
    frameDelayNanos = frameDelayNanos
)

/**
 * A [MonotonicFrameClock] with a time source controlled by a `kotlinx-coroutines-test`
 * [DelayController]. This frame clock may be used to consistently drive time under controlled
 * tests.
 *
 * Calls to [withFrameNanos] will schedule an upcoming frame [frameDelayNanos] nanoseconds in the
 * future by launching into [coroutineScope] if such a frame has not yet been scheduled. The
 * current frame time for [withFrameNanos] is provided by [delayController]. It is strongly
 * suggested that [coroutineScope] contain the test dispatcher controlled by [delayController].
 */
@ExperimentalCoroutinesApi
class TestMonotonicFrameClock(
    private val coroutineScope: CoroutineScope,
    private val delayController: DelayController,
    @get:Suppress("MethodNameUnits") // Nanos for high-precision animation clocks
    val frameDelayNanos: Long = DefaultFrameDelay
) : MonotonicFrameClock {
    private val lock = Any()
    private val awaiters = mutableListOf<Awaiter<*>>()
    private var posted = false

    /**
     * Returns whether there are any awaiters on this clock.
     */
    val hasAwaiters: Boolean get() = synchronized(lock) { awaiters.isNotEmpty() }

    private class Awaiter<R>(
        private val onFrame: (Long) -> R,
        private val continuation: CancellableContinuation<R>
    ) {
        fun runFrame(frameTimeNanos: Long): () -> Unit {
            val result = runCatching { onFrame(frameTimeNanos) }
            return { continuation.resumeWith(result) }
        }
    }

    override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R =
        suspendCancellableCoroutine { co ->
            synchronized(lock) {
                awaiters.add(Awaiter(onFrame, co))
                maybeLaunchTickRunner()
            }
        }

    private fun maybeLaunchTickRunner() {
        if (!posted) {
            posted = true
            coroutineScope.launch {
                delay(frameDelayMillis)
                synchronized(lock) {
                    posted = false
                    val toRun = awaiters.toList()
                    awaiters.clear()
                    val frameTime = delayController.currentTime * 1_000_000
                    // In case of awaiters on an immediate dispatcher, run all frame callbacks
                    // before resuming any associated continuations with the results.
                    toRun.map { it.runFrame(frameTime) }.forEach { it() }
                }
            }
        }
    }
}

/**
 * The frame delay time for the [TestMonotonicFrameClock] in milliseconds.
 */
@get:ExperimentalCoroutinesApi // Required to annotate Java-facing APIs
@ExperimentalCoroutinesApi // Required by kotlinc to use frameDelayNanos
val TestMonotonicFrameClock.frameDelayMillis: Long
    get() = frameDelayNanos / 1_000_000