Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9e0673f9 authored by Steve Elliott's avatar Steve Elliott
Browse files

Add suspending repeatWhenAttached methods

Flag: NONE new unused code
Bug: 354269846
Test: atest RepeatWhenAttachedTest
Change-Id: Ife20e8ed59de3bfc7a224a5164deea24eda1c873
parent b1b7d01b
Loading
Loading
Loading
Loading
+107 −0
Original line number Diff line number Diff line
@@ -30,10 +30,21 @@ import com.android.app.tracing.coroutines.launch
import com.android.systemui.Flags.coroutineTracing
import com.android.systemui.util.Assert
import com.android.systemui.util.Compile
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import com.android.systemui.utils.coroutines.flow.flatMapLatestConflated
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

/**
 * Runs the given [block] every time the [View] becomes attached (or immediately after calling this
@@ -215,6 +226,102 @@ private fun inferTraceSectionName(): String {
    }
}

/**
 * Runs the given [block] every time the [View] becomes attached (or immediately after calling this
 * function, if the view was already attached), automatically canceling the work when the view
 * becomes detached.
 *
 * Only use from the main thread.
 *
 * The [block] may be run multiple times, running once per every time the view is attached.
 */
@MainThread
suspend fun View.repeatWhenAttachedToWindow(block: suspend CoroutineScope.() -> Unit): Nothing {
    Assert.isMainThread()
    isAttached.collectLatest { if (it) coroutineScope { block() } }
    awaitCancellation() // satisfies return type of Nothing
}

/**
 * Runs the given [block] every time the [Window] this [View] is attached to becomes visible (or
 * immediately after calling this function, if the window is already visible), automatically
 * canceling the work when the window becomes invisible.
 *
 * Only use from the main thread.
 *
 * The [block] may be run multiple times, running once per every time the window becomes visible.
 */
@MainThread
suspend fun View.repeatWhenWindowIsVisible(block: suspend CoroutineScope.() -> Unit): Nothing {
    Assert.isMainThread()
    isWindowVisible.collectLatest { if (it) coroutineScope { block() } }
    awaitCancellation() // satisfies return type of Nothing
}

/**
 * Runs the given [block] every time the [Window] this [View] is attached to has focus (or
 * immediately after calling this function, if the window is already focused), automatically
 * canceling the work when the window loses focus.
 *
 * Only use from the main thread.
 *
 * The [block] may be run multiple times, running once per every time the window is focused.
 */
@MainThread
suspend fun View.repeatWhenWindowHasFocus(block: suspend CoroutineScope.() -> Unit): Nothing {
    Assert.isMainThread()
    isWindowFocused.collectLatest { if (it) coroutineScope { block() } }
    awaitCancellation() // satisfies return type of Nothing
}

private val View.isAttached
    get() = conflatedCallbackFlow {
        val onAttachListener =
            object : View.OnAttachStateChangeListener {
                override fun onViewAttachedToWindow(v: View) {
                    Assert.isMainThread()
                    trySend(true)
                }

                override fun onViewDetachedFromWindow(v: View) {
                    trySend(false)
                }
            }
        addOnAttachStateChangeListener(onAttachListener)
        trySend(isAttachedToWindow)
        awaitClose { removeOnAttachStateChangeListener(onAttachListener) }
    }

private val View.currentViewTreeObserver: Flow<ViewTreeObserver?>
    get() = isAttached.map { if (it) viewTreeObserver else null }

private val View.isWindowVisible
    get() =
        currentViewTreeObserver.flatMapLatestConflated { vto ->
            vto?.isWindowVisible?.onStart { emit(windowVisibility == View.VISIBLE) } ?: emptyFlow()
        }

private val View.isWindowFocused
    get() =
        currentViewTreeObserver.flatMapLatestConflated { vto ->
            vto?.isWindowFocused?.onStart { emit(hasWindowFocus()) } ?: emptyFlow()
        }

private val ViewTreeObserver.isWindowFocused
    get() = conflatedCallbackFlow {
        val listener = ViewTreeObserver.OnWindowFocusChangeListener { trySend(it) }
        addOnWindowFocusChangeListener(listener)
        awaitClose { removeOnWindowFocusChangeListener(listener) }
    }

private val ViewTreeObserver.isWindowVisible
    get() = conflatedCallbackFlow {
        val listener =
            ViewTreeObserver.OnWindowVisibilityChangeListener { v -> trySend(v == View.VISIBLE) }
        addOnWindowVisibilityChangeListener(listener)
        awaitClose { removeOnWindowVisibilityChangeListener(listener) }
    }

/**
 * Even though there is only has one usage of `Dispatchers.Main` in this file, we cache it in a
 * top-level property so that we do not unnecessarily create new `CoroutineContext` objects for
+198 −5
Original line number Diff line number Diff line
@@ -24,12 +24,14 @@ import androidx.lifecycle.LifecycleOwner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.Assert
import com.android.systemui.util.mockito.argumentCaptor
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
@@ -47,6 +49,8 @@ import org.mockito.Mockito.any
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` as whenever
import org.mockito.junit.MockitoJUnit
import org.mockito.kotlin.KArgumentCaptor
import org.mockito.kotlin.argumentCaptor

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@@ -95,6 +99,14 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            repeatWhenAttached()
        }

    @Test(expected = IllegalStateException::class)
    fun repeatWhenAttachedToWindow_enforcesMainThread() =
        testScope.runTest {
            Assert.setTestThread(null)

            view.repeatWhenAttachedToWindow {}
        }

    @Test(expected = IllegalStateException::class)
    fun repeatWhenAttached_disposeEnforcesMainThread() =
        testScope.runTest {
@@ -119,6 +131,58 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            assertThat(block.latestLifecycleState).isEqualTo(Lifecycle.State.CREATED)
        }

    @Test
    fun repeatWhenAttachedToWindow_viewAlreadyAttached_immediatelyRunsBlock() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenAttachedToWindow { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)
        }

    @Test
    fun repeatWhenAttachedToWindow_viewStartsDetached_runsBlockWhenAttached() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(false)
            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenAttachedToWindow { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isNotEqualTo(true)

            whenever(view.isAttachedToWindow).thenReturn(true)
            attachListeners.last().onViewAttachedToWindow(view)
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)
        }

    @Test
    fun repeatWhenAttachedToWindow_viewGetsDetached_cancelsBlock() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenAttachedToWindow { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)

            whenever(view.isAttachedToWindow).thenReturn(false)
            attachListeners.last().onViewDetachedFromWindow(view)
            runCurrent()

            assertThat(innerJob?.isActive).isNotEqualTo(true)
        }

    @Test
    fun repeatWhenAttached_viewAlreadyAttached_immediatelyRunsBlock() =
        testScope.runTest {
@@ -144,6 +208,65 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            assertThat(block.latestLifecycleState).isEqualTo(Lifecycle.State.STARTED)
        }

    @Test
    fun repeatWhenWindowIsVisible_startsAlreadyVisible_immediatelyRunsBlock() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            whenever(view.windowVisibility).thenReturn(View.VISIBLE)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenWindowIsVisible { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)
        }

    @Test
    fun repeatWhenWindowIsVisible_startsInvisible_runsBlockWhenVisible() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            whenever(view.windowVisibility).thenReturn(View.INVISIBLE)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenWindowIsVisible { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isNotEqualTo(true)

            whenever(view.windowVisibility).thenReturn(View.VISIBLE)
            argCaptor { verify(viewTreeObserver).addOnWindowVisibilityChangeListener(capture()) }
                .forEach { it.onWindowVisibilityChanged(View.VISIBLE) }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)
        }

    @Test
    fun repeatWhenWindowIsVisible_becomesInvisible_cancelsBlock() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            whenever(view.windowVisibility).thenReturn(View.VISIBLE)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenWindowIsVisible { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)

            whenever(view.windowVisibility).thenReturn(View.INVISIBLE)
            argCaptor { verify(viewTreeObserver).addOnWindowVisibilityChangeListener(capture()) }
                .forEach { it.onWindowVisibilityChanged(View.INVISIBLE) }
            runCurrent()

            assertThat(innerJob?.isActive).isNotEqualTo(true)
        }

    @Test
    fun repeatWhenAttached_startsWithFocusButInvisible_CREATED() =
        testScope.runTest {
@@ -171,6 +294,69 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            assertThat(block.latestLifecycleState).isEqualTo(Lifecycle.State.RESUMED)
        }

    @Test
    fun repeatWhenWindowHasFocus_startsWithFocus_immediatelyRunsBlock() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            whenever(view.windowVisibility).thenReturn(View.VISIBLE)
            whenever(view.hasWindowFocus()).thenReturn(true)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenWindowHasFocus { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)
        }

    @Test
    fun repeatWhenWindowHasFocus_startsWithoutFocus_runsBlockWhenFocused() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            whenever(view.windowVisibility).thenReturn(View.VISIBLE)
            whenever(view.hasWindowFocus()).thenReturn(false)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenWindowHasFocus { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isNotEqualTo(true)

            whenever(view.hasWindowFocus()).thenReturn(true)

            argCaptor { verify(viewTreeObserver).addOnWindowFocusChangeListener(capture()) }
                .forEach { it.onWindowFocusChanged(true) }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)
        }

    @Test
    fun repeatWhenWindowHasFocus_losesFocus_cancelsBlock() =
        testScope.runTest {
            whenever(view.isAttachedToWindow).thenReturn(true)
            whenever(view.windowVisibility).thenReturn(View.VISIBLE)
            whenever(view.hasWindowFocus()).thenReturn(true)

            var innerJob: Job? = null
            backgroundScope.launch {
                view.repeatWhenWindowHasFocus { innerJob = launch { awaitCancellation() } }
            }
            runCurrent()

            assertThat(innerJob?.isActive).isEqualTo(true)

            whenever(view.hasWindowFocus()).thenReturn(false)
            argCaptor { verify(viewTreeObserver).addOnWindowFocusChangeListener(capture()) }
                .forEach { it.onWindowFocusChanged(false) }
            runCurrent()

            assertThat(innerJob?.isActive).isNotEqualTo(true)
        }

    @Test
    fun repeatWhenAttached_becomesVisibleWithoutFocus_STARTED() =
        testScope.runTest {
@@ -180,7 +366,7 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            verify(viewTreeObserver).addOnWindowVisibilityChangeListener(listenerCaptor.capture())

            whenever(view.windowVisibility).thenReturn(View.VISIBLE)
            listenerCaptor.value.onWindowVisibilityChanged(View.VISIBLE)
            listenerCaptor.lastValue.onWindowVisibilityChanged(View.VISIBLE)

            runCurrent()
            assertThat(block.invocationCount).isEqualTo(1)
@@ -196,7 +382,7 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            verify(viewTreeObserver).addOnWindowFocusChangeListener(listenerCaptor.capture())

            whenever(view.hasWindowFocus()).thenReturn(true)
            listenerCaptor.value.onWindowFocusChanged(true)
            listenerCaptor.lastValue.onWindowFocusChanged(true)

            runCurrent()
            assertThat(block.invocationCount).isEqualTo(1)
@@ -214,9 +400,9 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
            verify(viewTreeObserver).addOnWindowFocusChangeListener(focusCaptor.capture())

            whenever(view.windowVisibility).thenReturn(View.VISIBLE)
            visibleCaptor.value.onWindowVisibilityChanged(View.VISIBLE)
            visibleCaptor.lastValue.onWindowVisibilityChanged(View.VISIBLE)
            whenever(view.hasWindowFocus()).thenReturn(true)
            focusCaptor.value.onWindowFocusChanged(true)
            focusCaptor.lastValue.onWindowFocusChanged(true)

            runCurrent()
            assertThat(block.invocationCount).isEqualTo(1)
@@ -314,6 +500,7 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
        val invocations: List<Invocation> = _invocations
        val invocationCount: Int
            get() = _invocations.size

        val latestLifecycleState: Lifecycle.State
            get() = _invocations.last().lifecycleState

@@ -322,3 +509,9 @@ class RepeatWhenAttachedTest : SysuiTestCase() {
        }
    }
}

private inline fun <reified T : Any> argCaptor(block: KArgumentCaptor<T>.() -> Unit) =
    argumentCaptor<T>().apply { block() }

private inline fun <reified T : Any> KArgumentCaptor<T>.forEach(block: (T) -> Unit): Unit =
    allValues.forEach(block)