diff options
12 files changed, 535 insertions, 10 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/ActivatableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/ActivatableTest.kt new file mode 100644 index 000000000000..67517a25ec87 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/ActivatableTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 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 androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class ActivatableTest : SysuiTestCase() { + + @get:Rule val composeRule = createComposeRule() + + @Test + fun rememberActivated() { + val keepAliveMutable = mutableStateOf(true) + var isActive = false + composeRule.setContent { + val keepAlive by keepAliveMutable + if (keepAlive) { + rememberActivated { + FakeActivatable( + onActivation = { isActive = true }, + onDeactivation = { isActive = false }, + ) + } + } + } + assertThat(isActive).isTrue() + } + + @Test + fun rememberActivated_leavingTheComposition() { + val keepAliveMutable = mutableStateOf(true) + var isActive = false + composeRule.setContent { + val keepAlive by keepAliveMutable + if (keepAlive) { + rememberActivated { + FakeActivatable( + onActivation = { isActive = true }, + onDeactivation = { isActive = false }, + ) + } + } + } + + // Tear down the composable. + composeRule.runOnUiThread { keepAliveMutable.value = false } + composeRule.waitForIdle() + + assertThat(isActive).isFalse() + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SafeActivatableTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SafeActivatableTest.kt new file mode 100644 index 000000000000..9484821eb7e4 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SafeActivatableTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2024 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.systemui.lifecycle + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.kosmos.testScope +import com.android.systemui.testKosmos +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SafeActivatableTest : SysuiTestCase() { + + private val kosmos = testKosmos() + private val testScope = kosmos.testScope + + private val underTest = FakeActivatable() + + @Test + fun activate() = + testScope.runTest { + assertThat(underTest.isActive).isFalse() + assertThat(underTest.activationCount).isEqualTo(0) + assertThat(underTest.cancellationCount).isEqualTo(0) + + underTest.activateIn(testScope) + runCurrent() + assertThat(underTest.isActive).isTrue() + assertThat(underTest.activationCount).isEqualTo(1) + assertThat(underTest.cancellationCount).isEqualTo(0) + } + + @Test + fun activate_andCancel() = + testScope.runTest { + assertThat(underTest.isActive).isFalse() + assertThat(underTest.activationCount).isEqualTo(0) + assertThat(underTest.cancellationCount).isEqualTo(0) + + val job = Job() + underTest.activateIn(testScope, context = job) + runCurrent() + assertThat(underTest.isActive).isTrue() + assertThat(underTest.activationCount).isEqualTo(1) + assertThat(underTest.cancellationCount).isEqualTo(0) + + job.cancel() + runCurrent() + assertThat(underTest.isActive).isFalse() + assertThat(underTest.activationCount).isEqualTo(1) + assertThat(underTest.cancellationCount).isEqualTo(1) + } + + @Test + fun activate_afterCancellation() = + testScope.runTest { + assertThat(underTest.isActive).isFalse() + assertThat(underTest.activationCount).isEqualTo(0) + assertThat(underTest.cancellationCount).isEqualTo(0) + + val job = Job() + underTest.activateIn(testScope, context = job) + runCurrent() + assertThat(underTest.isActive).isTrue() + assertThat(underTest.activationCount).isEqualTo(1) + assertThat(underTest.cancellationCount).isEqualTo(0) + + job.cancel() + runCurrent() + assertThat(underTest.isActive).isFalse() + assertThat(underTest.activationCount).isEqualTo(1) + assertThat(underTest.cancellationCount).isEqualTo(1) + + underTest.activateIn(testScope) + runCurrent() + assertThat(underTest.isActive).isTrue() + assertThat(underTest.activationCount).isEqualTo(2) + assertThat(underTest.cancellationCount).isEqualTo(1) + } + + @Test(expected = IllegalStateException::class) + fun activate_whileActive_throws() = + testScope.runTest { + assertThat(underTest.isActive).isFalse() + assertThat(underTest.activationCount).isEqualTo(0) + assertThat(underTest.cancellationCount).isEqualTo(0) + + underTest.activateIn(testScope) + runCurrent() + assertThat(underTest.isActive).isTrue() + assertThat(underTest.activationCount).isEqualTo(1) + assertThat(underTest.cancellationCount).isEqualTo(0) + + underTest.activateIn(testScope) + runCurrent() + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt new file mode 100644 index 000000000000..d1f908dfc795 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/lifecycle/SysUiViewModelTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2024 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 androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SysUiViewModelTest : SysuiTestCase() { + + @get:Rule val composeRule = createComposeRule() + + @Test + fun rememberActivated() { + val keepAliveMutable = mutableStateOf(true) + var isActive = false + composeRule.setContent { + val keepAlive by keepAliveMutable + if (keepAlive) { + rememberViewModel { + FakeSysUiViewModel( + onActivation = { isActive = true }, + onDeactivation = { isActive = false }, + ) + } + } + } + assertThat(isActive).isTrue() + } + + @Test + fun rememberActivated_withKey() { + val keyMutable = mutableStateOf(1) + var isActive1 = false + var isActive2 = false + composeRule.setContent { + val key by keyMutable + rememberViewModel(key) { + when (key) { + 1 -> + FakeSysUiViewModel( + onActivation = { isActive1 = true }, + onDeactivation = { isActive1 = false }, + ) + 2 -> + FakeSysUiViewModel( + onActivation = { isActive2 = true }, + onDeactivation = { isActive2 = false }, + ) + else -> error("unsupported key $key") + } + } + } + assertThat(isActive1).isTrue() + assertThat(isActive2).isFalse() + + composeRule.runOnUiThread { keyMutable.value = 2 } + composeRule.waitForIdle() + assertThat(isActive1).isFalse() + assertThat(isActive2).isTrue() + + composeRule.runOnUiThread { keyMutable.value = 1 } + composeRule.waitForIdle() + assertThat(isActive1).isTrue() + assertThat(isActive2).isFalse() + } + + @Test + fun rememberActivated_leavingTheComposition() { + val keepAliveMutable = mutableStateOf(true) + var isActive = false + composeRule.setContent { + val keepAlive by keepAliveMutable + if (keepAlive) { + rememberViewModel { + FakeSysUiViewModel( + onActivation = { isActive = true }, + onDeactivation = { isActive = false }, + ) + } + } + } + + // Tear down the composable. + composeRule.runOnUiThread { keepAliveMutable.value = false } + composeRule.waitForIdle() + + assertThat(isActive).isFalse() + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt index 343b6bd3472c..3b2c9819c9b7 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt @@ -25,7 +25,6 @@ import com.android.compose.animation.scene.SceneKey import com.android.compose.animation.scene.Swipe import com.android.compose.animation.scene.SwipeDirection import com.android.systemui.SysuiTestCase -import com.android.systemui.activatable.activateIn import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository @@ -37,6 +36,7 @@ import com.android.systemui.keyguard.data.repository.fakeDeviceEntryFingerprintA import com.android.systemui.keyguard.domain.interactor.keyguardEnabledInteractor import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus import com.android.systemui.kosmos.testScope +import com.android.systemui.lifecycle.activateIn import com.android.systemui.media.controls.data.repository.mediaFilterRepository import com.android.systemui.media.controls.shared.model.MediaData import com.android.systemui.qs.ui.adapter.fakeQSSceneAdapter diff --git a/packages/SystemUI/src/com/android/systemui/activatable/Activatable.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/Activatable.kt index dc2d931aad41..ebb0ea62a10c 100644 --- a/packages/SystemUI/src/com/android/systemui/activatable/Activatable.kt +++ b/packages/SystemUI/src/com/android/systemui/lifecycle/Activatable.kt @@ -14,7 +14,11 @@ * limitations under the License. */ -package com.android.systemui.activatable +package com.android.systemui.lifecycle + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember /** Defines interface for classes that can be activated to do coroutine work. */ interface Activatable { @@ -55,3 +59,20 @@ interface Activatable { */ suspend fun activate() } + +/** + * Returns a remembered [Activatable] of the type [T] that's automatically kept active until this + * composable leaves the composition. + * + * If the [key] changes, the old [Activatable] is deactivated and a new one will be instantiated, + * activated, and returned. + */ +@Composable +fun <T : Activatable> rememberActivated( + key: Any = Unit, + factory: () -> T, +): T { + val instance = remember(key) { factory() } + LaunchedEffect(instance) { instance.activate() } + return instance +} diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/SafeActivatable.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/SafeActivatable.kt new file mode 100644 index 000000000000..f080a421d295 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/lifecycle/SafeActivatable.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 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 java.util.concurrent.atomic.AtomicBoolean + +/** + * An [Activatable] that can be concurrently activated by no more than one owner. + * + * A previous call to [activate] must be canceled before a new call to [activate] can be made. + * Trying to call [activate] while already active will fail with an error. + */ +abstract class SafeActivatable : Activatable { + + private val _isActive = AtomicBoolean(false) + + var isActive: Boolean + get() = _isActive.get() + private set(value) { + _isActive.set(value) + } + + final override suspend fun activate() { + val allowed = _isActive.compareAndSet(false, true) + check(allowed) { "Cannot activate an already active activatable!" } + + try { + onActivated() + } finally { + isActive = false + } + } + + /** + * Notifies that the [Activatable] has been activated. + * + * Serves as an entrypoint to kick off coroutine work that the object requires in order to keep + * its state fresh and/or perform side-effects. + * + * The method suspends and doesn't return until all work required by the object is finished. In + * most cases, it's expected for the work to remain ongoing forever so this method will forever + * suspend its caller until the coroutine that called it is canceled. + * + * Implementations could follow this pattern: + * ```kotlin + * override suspend fun onActivated() { + * coroutineScope { + * launch { ... } + * launch { ... } + * launch { ... } + * } + * } + * ``` + * + * @see activate + */ + protected abstract suspend fun onActivated() +} diff --git a/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt b/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt new file mode 100644 index 000000000000..0af5feaff3b2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/lifecycle/SysUiViewModel.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 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 androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember + +/** Base class for all System UI view-models. */ +abstract class SysUiViewModel : SafeActivatable() { + + override suspend fun onActivated() = Unit +} + +/** + * Returns a remembered [SysUiViewModel] of the type [T] that's automatically kept active until this + * composable leaves the composition. + * + * If the [key] changes, the old [SysUiViewModel] is deactivated and a new one will be instantiated, + * activated, and returned. + */ +@Composable +fun <T : SysUiViewModel> rememberViewModel( + key: Any = Unit, + factory: () -> T, +): T { + val instance = remember(key) { factory() } + LaunchedEffect(instance) { instance.activate() } + return instance +} diff --git a/packages/SystemUI/src/com/android/systemui/scene/shared/model/Scene.kt b/packages/SystemUI/src/com/android/systemui/scene/shared/model/Scene.kt index c7190c3039a9..103b4a5ff7ef 100644 --- a/packages/SystemUI/src/com/android/systemui/scene/shared/model/Scene.kt +++ b/packages/SystemUI/src/com/android/systemui/scene/shared/model/Scene.kt @@ -19,7 +19,7 @@ package com.android.systemui.scene.shared.model import com.android.compose.animation.scene.SceneKey import com.android.compose.animation.scene.UserAction import com.android.compose.animation.scene.UserActionResult -import com.android.systemui.activatable.Activatable +import com.android.systemui.lifecycle.Activatable import kotlinx.coroutines.flow.Flow /** diff --git a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt index f90dd3c2936b..06298efc95a4 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModel.kt @@ -14,8 +14,6 @@ * limitations under the License. */ -@file:OptIn(ExperimentalCoroutinesApi::class) - package com.android.systemui.shade.ui.viewmodel import androidx.lifecycle.LifecycleOwner @@ -24,8 +22,8 @@ import com.android.compose.animation.scene.Swipe import com.android.compose.animation.scene.SwipeDirection import com.android.compose.animation.scene.UserAction import com.android.compose.animation.scene.UserActionResult -import com.android.systemui.activatable.Activatable import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.lifecycle.Activatable import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor import com.android.systemui.qs.FooterActionsController import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel @@ -41,7 +39,6 @@ import com.android.systemui.unfold.domain.interactor.UnfoldTransitionInteractor import com.android.systemui.utils.coroutines.flow.flatMapLatestConflated import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/activatable/ActivatableExt.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/ActivatableExt.kt index 1f04a44f172b..d2655594abbf 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/activatable/ActivatableExt.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/ActivatableExt.kt @@ -14,12 +14,17 @@ * limitations under the License. */ -package com.android.systemui.activatable +package com.android.systemui.lifecycle +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope /** Activates [activatable] for the duration of the test. */ -fun Activatable.activateIn(testScope: TestScope) { - testScope.backgroundScope.launch { activate() } +fun Activatable.activateIn( + testScope: TestScope, + context: CoroutineContext = EmptyCoroutineContext, +) { + testScope.backgroundScope.launch(context) { activate() } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/FakeActivatable.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/FakeActivatable.kt new file mode 100644 index 000000000000..e8b2dd232c1c --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/FakeActivatable.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 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 kotlinx.coroutines.awaitCancellation + +class FakeActivatable( + private val onActivation: () -> Unit = {}, + private val onDeactivation: () -> Unit = {}, +) : SafeActivatable() { + var activationCount = 0 + var cancellationCount = 0 + + override suspend fun onActivated() { + activationCount++ + onActivation() + try { + awaitCancellation() + } finally { + cancellationCount++ + onDeactivation() + } + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/FakeSysUiViewModel.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/FakeSysUiViewModel.kt new file mode 100644 index 000000000000..9a56f2419669 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/lifecycle/FakeSysUiViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 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 kotlinx.coroutines.awaitCancellation + +class FakeSysUiViewModel( + private val onActivation: () -> Unit = {}, + private val onDeactivation: () -> Unit = {}, +) : SysUiViewModel() { + var activationCount = 0 + var cancellationCount = 0 + + override suspend fun onActivated() { + activationCount++ + onActivation() + try { + awaitCancellation() + } finally { + cancellationCount++ + onDeactivation() + } + } +} |