Loading packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt 0 → 100644 +44 −0 Original line number Diff line number Diff line /* * 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() } } packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt 0 → 100644 +82 −0 Original line number Diff line number Diff line /* * 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() } } packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt 0 → 100644 +137 −0 Original line number Diff line number Diff line /* * 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() } } Loading
packages/SystemUI/src/com/android/systemui/util/kotlin/DisposableHandleExt.kt 0 → 100644 +44 −0 Original line number Diff line number Diff line /* * 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() } }
packages/SystemUI/src/com/android/systemui/util/view/LayoutInflaterExt.kt 0 → 100644 +82 −0 Original line number Diff line number Diff line /* * 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() } }
packages/SystemUI/tests/src/com/android/systemui/util/kotlin/LayoutInflaterUtilTest.kt 0 → 100644 +137 −0 Original line number Diff line number Diff line /* * 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() } }