summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Ioana Alexandru <aioana@google.com> 2023-10-17 12:59:42 +0200
committer Ioana Alexandru <aioana@google.com> 2023-10-20 21:35:39 +0200
commit9f39bd6285b84bfc4a85c9bd3bb5103734c81f8e (patch)
treefa826e92a1a3d781e12c31d509bec1e3d958ddce
parentcbff326cd0b478125caab167e03bce565af83e57 (diff)
Create inflation utility that reinflates on config changes.
Bug: 293167744 Test: LayoutInflaterUtilTest Change-Id: I2591f6f40912d3bfb988ccc1e74dd50b67270a63
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt38
-rw-r--r--packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt44
-rw-r--r--packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt82
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt137
4 files changed, 290 insertions, 11 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt
index 21acfb41f10c..25d67aff50a9 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/policy/ConfigurationControllerExt.kt
@@ -13,7 +13,7 @@
*/
package com.android.systemui.statusbar.policy
-import com.android.systemui.common.coroutine.ConflatedCallbackFlow
+import com.android.systemui.common.coroutine.ConflatedCallbackFlow.conflatedCallbackFlow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -23,14 +23,30 @@ import kotlinx.coroutines.flow.Flow
* @see ConfigurationController.ConfigurationListener.onDensityOrFontScaleChanged
*/
val ConfigurationController.onDensityOrFontScaleChanged: Flow<Unit>
- get() =
- ConflatedCallbackFlow.conflatedCallbackFlow {
- val listener =
- object : ConfigurationController.ConfigurationListener {
- override fun onDensityOrFontScaleChanged() {
- trySend(Unit)
- }
+ get() = conflatedCallbackFlow {
+ val listener =
+ object : ConfigurationController.ConfigurationListener {
+ override fun onDensityOrFontScaleChanged() {
+ trySend(Unit)
}
- addCallback(listener)
- awaitClose { removeCallback(listener) }
- }
+ }
+ addCallback(listener)
+ awaitClose { removeCallback(listener) }
+ }
+
+/**
+ * A [Flow] that emits whenever the theme has changed.
+ *
+ * @see ConfigurationController.ConfigurationListener.onThemeChanged
+ */
+val ConfigurationController.onThemeChanged: Flow<Unit>
+ get() = conflatedCallbackFlow {
+ val listener =
+ object : ConfigurationController.ConfigurationListener {
+ override fun onThemeChanged() {
+ trySend(Unit)
+ }
+ }
+ addCallback(listener)
+ awaitClose { removeCallback(listener) }
+ }
diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt
new file mode 100644
index 000000000000..909a18be4b9e
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2023 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 com.android.systemui.util.kotlin
+
+import com.android.systemui.lifecycle.repeatWhenAttached
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.awaitCancellation
+
+/**
+ * Suspends to keep getting updates until cancellation. Once cancelled, mark this as eligible for
+ * garbage collection.
+ *
+ * This utility is useful if you want to bind a [repeatWhenAttached] invocation to the lifetime of a
+ * coroutine, such that cancelling the coroutine cleans up the handle. For example:
+ * ```
+ * myFlow.collectLatest { value ->
+ * val disposableHandle = myView.repeatWhenAttached { doStuff() }
+ * doSomethingWith(value)
+ * // un-bind when done
+ * disposableHandle.awaitCancellationThenDispose()
+ * }
+ * ```
+ */
+suspend fun DisposableHandle.awaitCancellationThenDispose() {
+ try {
+ awaitCancellation()
+ } finally {
+ dispose()
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt b/packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt
new file mode 100644
index 000000000000..6d45d23879e9
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2023 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 com.android.systemui.util.view
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.android.systemui.lifecycle.repeatWhenAttached
+import com.android.systemui.util.kotlin.awaitCancellationThenDispose
+import com.android.systemui.util.kotlin.stateFlow
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+/**
+ * Perform an inflation right away, then re-inflate whenever the [flow] emits, and call [onInflate]
+ * on the resulting view each time. Dispose of the [DisposableHandle] returned by [onInflate] when
+ * done.
+ *
+ * This never completes unless cancelled, it just suspends and waits for updates.
+ *
+ * For parameters [resource], [root] and [attachToRoot], see [LayoutInflater.inflate].
+ *
+ * An example use-case of this is when a view needs to be re-inflated whenever a configuration
+ * change occurs, which would require the ViewBinder to then re-bind the new view. For example, the
+ * code in the parent view's binder would look like:
+ * ```
+ * parentView.repeatWhenAttached {
+ * LayoutInflater.from(parentView.context)
+ * .reinflateOnChange(
+ * R.layout.my_layout,
+ * parentView,
+ * attachToRoot = false,
+ * coroutineScope = lifecycleScope,
+ * configurationController.onThemeChanged,
+ * ),
+ * ) { view ->
+ * ChildViewBinder.bind(view as ChildView, childViewModel)
+ * }
+ * }
+ * ```
+ *
+ * In turn, the bind method (passed through [onInflate]) uses [repeatWhenAttached], which returns a
+ * [DisposableHandle].
+ */
+suspend fun LayoutInflater.reinflateAndBindLatest(
+ resource: Int,
+ root: ViewGroup?,
+ attachToRoot: Boolean,
+ flow: Flow<Unit>,
+ onInflate: (View) -> DisposableHandle?,
+) = coroutineScope {
+ val viewFlow: Flow<View> = stateFlow(flow) { inflate(resource, root, attachToRoot) }
+ viewFlow.bindLatest(onInflate)
+}
+
+/**
+ * Use the [bind] method to bind the view every time this flow emits, and suspend to await for more
+ * updates. New emissions lead to the previous binding call being cancelled if not completed.
+ * Dispose of the [DisposableHandle] returned by [bind] when done.
+ */
+suspend fun Flow<View>.bindLatest(bind: (View) -> DisposableHandle?) {
+ this.collectLatest { view ->
+ val disposableHandle = bind(view)
+ disposableHandle?.awaitCancellationThenDispose()
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt b/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt
new file mode 100644
index 000000000000..1c8465a482de
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2023 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 com.android.systemui.util.kotlin
+
+import android.content.Context
+import android.testing.AndroidTestingRunner
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.view.reinflateAndBindLatest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.DisposableHandle
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.junit.MockitoJUnit
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class LayoutInflaterUtilTest : SysuiTestCase() {
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+
+ private var inflationCount = 0
+ private var callbackCount = 0
+ @Mock private lateinit var disposableHandle: DisposableHandle
+
+ inner class TestLayoutInflater : LayoutInflater(context) {
+ override fun inflate(resource: Int, root: ViewGroup?, attachToRoot: Boolean): View {
+ inflationCount++
+ return View(context)
+ }
+
+ override fun cloneInContext(p0: Context?): LayoutInflater {
+ // not needed for this test
+ return this
+ }
+ }
+
+ val underTest = TestLayoutInflater()
+
+ @After
+ fun cleanUp() {
+ inflationCount = 0
+ callbackCount = 0
+ }
+
+ @Test
+ fun testReinflateAndBindLatest_inflatesWithoutEmission() = runTest {
+ backgroundScope.launch {
+ underTest.reinflateAndBindLatest(
+ resource = 0,
+ root = null,
+ attachToRoot = false,
+ emptyFlow<Unit>()
+ ) {
+ callbackCount++
+ null
+ }
+ }
+
+ // Inflates without an emission
+ runCurrent()
+ assertThat(inflationCount).isEqualTo(1)
+ assertThat(callbackCount).isEqualTo(1)
+ }
+
+ @Test
+ fun testReinflateAndBindLatest_reinflatesOnEmission() = runTest {
+ val observable = MutableSharedFlow<Unit>()
+ val flow = observable.asSharedFlow()
+ backgroundScope.launch {
+ underTest.reinflateAndBindLatest(
+ resource = 0,
+ root = null,
+ attachToRoot = false,
+ flow
+ ) {
+ callbackCount++
+ null
+ }
+ }
+
+ listOf(1, 2, 3).forEach { count ->
+ runCurrent()
+ assertThat(inflationCount).isEqualTo(count)
+ assertThat(callbackCount).isEqualTo(count)
+ observable.emit(Unit)
+ }
+ }
+
+ @Test
+ fun testReinflateAndBindLatest_disposesOnCancel() = runTest {
+ val job = launch {
+ underTest.reinflateAndBindLatest(
+ resource = 0,
+ root = null,
+ attachToRoot = false,
+ emptyFlow()
+ ) {
+ callbackCount++
+ disposableHandle
+ }
+ }
+
+ runCurrent()
+ job.cancelAndJoin()
+ verify(disposableHandle).dispose()
+ }
+}