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

Commit 68d3198c authored by Ioana Alexandru's avatar Ioana Alexandru Committed by Android (Google) Code Review
Browse files

Merge "Create inflation utility that reinflates on config changes." into main

parents 57cca107 9f39bd62
Loading
Loading
Loading
Loading
+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()
    }
}
+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()
    }
}
+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()
    }
}