diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index ffd6b522e3943..1344144ee6fcb 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -225,6 +225,7 @@ android_library { "androidx.exifinterface_exifinterface", "kotlinx-coroutines-android", "kotlinx-coroutines-core", + "kotlinx_coroutines_test", "iconloader_base", "SystemUI-tags", "SystemUI-proto", diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt new file mode 100644 index 0000000000000..e3649187b0a74 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/lifecycle/RepeatWhenAttached.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2022 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.lifecycle + +import android.view.View +import android.view.ViewTreeObserver +import androidx.annotation.MainThread +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import com.android.systemui.util.Assert +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.launch + +/** + * 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. + * + * When [block] is run, it is run in the context of a [ViewLifecycleOwner] which the caller can use + * to launch jobs, with confidence that the jobs will be properly canceled when the view is + * detached. + * + * The [block] may be run multiple times, running once per every time the view is attached. Each + * time the block is run for a new attachment event, the [ViewLifecycleOwner] provided will be a + * fresh one. + * + * @param coroutineContext An optional [CoroutineContext] to replace the dispatcher [block] is + * invoked on. + * @param block The block of code that should be run when the view becomes attached. It can end up + * being invoked multiple times if the view is reattached after being detached. + * @return A [DisposableHandle] to invoke when the caller of the function destroys its [View] and is + * no longer interested in the [block] being run the next time its attached. Calling this is an + * optional optimization as the logic will be properly cleaned up and destroyed each time the view + * is detached. Using this is not *thread-safe* and should only be used on the main thread. + */ +@MainThread +fun View.repeatWhenAttached( + coroutineContext: CoroutineContext = EmptyCoroutineContext, + block: suspend LifecycleOwner.(View) -> Unit, +): DisposableHandle { + Assert.isMainThread() + val view = this + // The suspend block will run on the app's main thread unless the caller supplies a different + // dispatcher to use. We don't want it to run on the Dispatchers.Default thread pool as + // default behavior. Instead, we want it to run on the view's UI thread since the user will + // presumably want to call view methods that require being called from said UI thread. + val lifecycleCoroutineContext = Dispatchers.Main + coroutineContext + var lifecycleOwner: ViewLifecycleOwner? = null + val onAttachListener = + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View?) { + Assert.isMainThread() + lifecycleOwner?.onDestroy() + lifecycleOwner = + createLifecycleOwnerAndRun( + view, + lifecycleCoroutineContext, + block, + ) + } + + override fun onViewDetachedFromWindow(v: View?) { + lifecycleOwner?.onDestroy() + lifecycleOwner = null + } + } + + addOnAttachStateChangeListener(onAttachListener) + if (view.isAttachedToWindow) { + lifecycleOwner = + createLifecycleOwnerAndRun( + view, + lifecycleCoroutineContext, + block, + ) + } + + return object : DisposableHandle { + override fun dispose() { + Assert.isMainThread() + + lifecycleOwner?.onDestroy() + lifecycleOwner = null + view.removeOnAttachStateChangeListener(onAttachListener) + } + } +} + +private fun createLifecycleOwnerAndRun( + view: View, + coroutineContext: CoroutineContext, + block: suspend LifecycleOwner.(View) -> Unit, +): ViewLifecycleOwner { + return ViewLifecycleOwner(view).apply { + onCreate() + lifecycleScope.launch(coroutineContext) { block(view) } + } +} + +/** + * A [LifecycleOwner] for a [View] for exclusive use by the [repeatWhenAttached] extension function. + * + * The implementation requires the caller to call [onCreate] and [onDestroy] when the view is + * attached to or detached from a view hierarchy. After [onCreate] and before [onDestroy] is called, + * the implementation monitors window state in the following way + * + * * If the window is not visible, we are in the [Lifecycle.State.CREATED] state + * * If the window is visible but not focused, we are in the [Lifecycle.State.STARTED] state + * * If the window is visible and focused, we are in the [Lifecycle.State.RESUMED] state + * + * Or in table format: + * ``` + * ┌───────────────┬───────────────────┬──────────────┬─────────────────┐ + * │ View attached │ Window Visibility │ Window Focus │ Lifecycle State │ + * ├───────────────┼───────────────────┴──────────────┼─────────────────┤ + * │ Not attached │ Any │ N/A │ + * ├───────────────┼───────────────────┬──────────────┼─────────────────┤ + * │ │ Not visible │ Any │ CREATED │ + * │ ├───────────────────┼──────────────┼─────────────────┤ + * │ Attached │ │ No focus │ STARTED │ + * │ │ Visible ├──────────────┼─────────────────┤ + * │ │ │ Has focus │ RESUMED │ + * └───────────────┴───────────────────┴──────────────┴─────────────────┘ + * ``` + */ +private class ViewLifecycleOwner( + private val view: View, +) : LifecycleOwner { + + private val windowVisibleListener = + ViewTreeObserver.OnWindowVisibilityChangeListener { updateState() } + private val windowFocusListener = ViewTreeObserver.OnWindowFocusChangeListener { updateState() } + + private val registry = LifecycleRegistry(this) + + fun onCreate() { + registry.currentState = Lifecycle.State.CREATED + view.viewTreeObserver.addOnWindowVisibilityChangeListener(windowVisibleListener) + view.viewTreeObserver.addOnWindowFocusChangeListener(windowFocusListener) + updateState() + } + + fun onDestroy() { + view.viewTreeObserver.removeOnWindowVisibilityChangeListener(windowVisibleListener) + view.viewTreeObserver.removeOnWindowFocusChangeListener(windowFocusListener) + registry.currentState = Lifecycle.State.DESTROYED + } + + override fun getLifecycle(): Lifecycle { + return registry + } + + private fun updateState() { + registry.currentState = + when { + view.windowVisibility != View.VISIBLE -> Lifecycle.State.CREATED + !view.hasWindowFocus() -> Lifecycle.State.STARTED + else -> Lifecycle.State.RESUMED + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/WindowAddedViewLifecycleOwner.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/WindowAddedViewLifecycleOwner.kt deleted file mode 100644 index 55c7ac9fb0cc7..0000000000000 --- a/packages/SystemUI/src/com/android/systemui/lifecycle/WindowAddedViewLifecycleOwner.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.android.systemui.lifecycle - -import android.view.View -import android.view.ViewTreeObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry - -/** - * [LifecycleOwner] for Window-added Views. - * - * These are [View] instances that are added to a `Window` using the `WindowManager` API. - * - * This implementation goes to: - * * The CREATED `Lifecycle.State` when the view gets attached to the window but the window - * is not yet visible - * * The STARTED `Lifecycle.State` when the view is attached to the window and the window is - * visible - * * The RESUMED `Lifecycle.State` when the view is attached to the window and the window is - * visible and the window receives focus - * - * In table format: - * ``` - * | ----------------------------------------------------------------------------- | - * | View attached to window | Window visible | Window has focus | Lifecycle state | - * | ----------------------------------------------------------------------------- | - * | not attached | Any | INITIALIZED | - * | ----------------------------------------------------------------------------- | - * | | not visible | Any | CREATED | - * | ----------------------------------------------------- | - * | attached | | not focused | STARTED | - * | | is visible |----------------------------------- | - * | | | has focus | RESUMED | - * | ----------------------------------------------------------------------------- | - * ``` - * ### Notes - * * [dispose] must be invoked when the [LifecycleOwner] is done and won't be reused - * * It is always better for [LifecycleOwner] implementations to be more explicit than just - * listening to the state of the `Window`. E.g. if the code that added the `View` to the `Window` - * already has access to the correct state to know when that `View` should become visible and when - * it is ready to receive interaction from the user then it already knows when to move to `STARTED` - * and `RESUMED`, respectively. In that case, it's better to implement your own `LifecycleOwner` - * instead of relying on the `Window` callbacks. - */ -class WindowAddedViewLifecycleOwner -@JvmOverloads -constructor( - private val view: View, - registryFactory: (LifecycleOwner) -> LifecycleRegistry = { LifecycleRegistry(it) }, -) : LifecycleOwner { - - private val windowAttachListener = - object : ViewTreeObserver.OnWindowAttachListener { - override fun onWindowAttached() { - updateCurrentState() - } - - override fun onWindowDetached() { - updateCurrentState() - } - } - private val windowFocusListener = - ViewTreeObserver.OnWindowFocusChangeListener { updateCurrentState() } - private val windowVisibilityListener = - ViewTreeObserver.OnWindowVisibilityChangeListener { updateCurrentState() } - - private val registry = registryFactory(this) - - init { - setCurrentState(Lifecycle.State.INITIALIZED) - - with(view.viewTreeObserver) { - addOnWindowAttachListener(windowAttachListener) - addOnWindowVisibilityChangeListener(windowVisibilityListener) - addOnWindowFocusChangeListener(windowFocusListener) - } - - updateCurrentState() - } - - override fun getLifecycle(): Lifecycle { - return registry - } - - /** - * Disposes of this [LifecycleOwner], performing proper clean-up. - * - *
Invoke this when the instance is finished and won't be reused.
- */
- fun dispose() {
- with(view.viewTreeObserver) {
- removeOnWindowAttachListener(windowAttachListener)
- removeOnWindowVisibilityChangeListener(windowVisibilityListener)
- removeOnWindowFocusChangeListener(windowFocusListener)
- }
- }
-
- private fun updateCurrentState() {
- val state =
- when {
- !view.isAttachedToWindow -> Lifecycle.State.INITIALIZED
- view.windowVisibility != View.VISIBLE -> Lifecycle.State.CREATED
- !view.hasWindowFocus() -> Lifecycle.State.STARTED
- else -> Lifecycle.State.RESUMED
- }
- setCurrentState(state)
- }
-
- private fun setCurrentState(state: Lifecycle.State) {
- if (registry.currentState != state) {
- registry.currentState = state
- }
- }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java
index 4b71b2c94f5ff..15e1129e43b5f 100644
--- a/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java
+++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationShadeWindowControllerImpl.java
@@ -44,8 +44,6 @@ import android.view.WindowManager;
import android.view.WindowManager.LayoutParams;
import android.view.WindowManagerGlobal;
-import androidx.lifecycle.ViewTreeLifecycleOwner;
-
import com.android.keyguard.KeyguardUpdateMonitor;
import com.android.systemui.Dumpable;
import com.android.systemui.R;
@@ -54,7 +52,6 @@ import com.android.systemui.colorextraction.SysuiColorExtractor;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.dump.DumpManager;
import com.android.systemui.keyguard.KeyguardViewMediator;
-import com.android.systemui.lifecycle.WindowAddedViewLifecycleOwner;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.plugins.statusbar.StatusBarStateController.StateListener;
import com.android.systemui.statusbar.NotificationShadeWindowController;
@@ -251,15 +248,6 @@ public class NotificationShadeWindowControllerImpl implements NotificationShadeW
mWindowManager.addView(mNotificationShadeView, mLp);
- // Set up and "inject" a LifecycleOwner bound to the Window-View relationship such that all
- // views in the sub-tree rooted under this view can access the LifecycleOwner using
- // ViewTreeLifecycleOwner.get(...).
- if (ViewTreeLifecycleOwner.get(mNotificationShadeView) == null) {
- ViewTreeLifecycleOwner.set(
- mNotificationShadeView,
- new WindowAddedViewLifecycleOwner(mNotificationShadeView));
- }
-
mLpChanged.copyFrom(mLp);
onThemeChanged();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt b/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt
new file mode 100644
index 0000000000000..80f3e46b848f0
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/lifecycle/RepeatWhenAttachedTest.kt
@@ -0,0 +1,319 @@
+/*
+ * Copyright (C) 2022 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.lifecycle
+
+import android.testing.TestableLooper.RunWithLooper
+import android.view.View
+import android.view.ViewTreeObserver
+import androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.TaskExecutor
+import androidx.lifecycle.Lifecycle
+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.DisposableHandle
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.mockito.Mock
+import org.mockito.Mockito.any
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when` as whenever
+import org.mockito.junit.MockitoJUnit
+
+@SmallTest
+@RunWith(JUnit4::class)
+@RunWithLooper
+class RepeatWhenAttachedTest : SysuiTestCase() {
+
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+ @JvmField @Rule val instantTaskExecutor = InstantTaskExecutorRule()
+
+ @Mock private lateinit var view: View
+ @Mock private lateinit var viewTreeObserver: ViewTreeObserver
+
+ private lateinit var block: Block
+ private lateinit var attachListeners: MutableList