summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt107
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt203
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)