diff options
| -rw-r--r-- | packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt | 107 | ||||
| -rw-r--r-- | packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt | 203 |
2 files changed, 305 insertions, 5 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt index bf80e1870f1b..661da6d2af13 100644 --- a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt +++ b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt @@ -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 @@ -216,6 +227,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 * tracing on each call to [repeatWhenAttached]. It is okay to reuse a single instance of the diff --git a/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt b/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt index afab25092278..d3409c7256fd 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt @@ -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 @@ -96,6 +100,14 @@ class RepeatWhenAttachedTest : SysuiTestCase() { } @Test(expected = IllegalStateException::class) + fun repeatWhenAttachedToWindow_enforcesMainThread() = + testScope.runTest { + Assert.setTestThread(null) + + view.repeatWhenAttachedToWindow {} + } + + @Test(expected = IllegalStateException::class) fun repeatWhenAttached_disposeEnforcesMainThread() = testScope.runTest { val disposableHandle = repeatWhenAttached() @@ -120,6 +132,58 @@ class RepeatWhenAttachedTest : SysuiTestCase() { } @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 { whenever(view.isAttachedToWindow).thenReturn(true) @@ -145,6 +209,65 @@ class RepeatWhenAttachedTest : SysuiTestCase() { } @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 { whenever(view.isAttachedToWindow).thenReturn(true) @@ -172,6 +295,69 @@ class RepeatWhenAttachedTest : SysuiTestCase() { } @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 { whenever(view.isAttachedToWindow).thenReturn(true) @@ -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) |