diff options
author | 2024-08-15 15:20:41 +0000 | |
---|---|---|
committer | 2024-08-15 15:20:41 +0000 | |
commit | 6b1a31b441b96d3ca8446f47df9e0550f429ab1b (patch) | |
tree | c48e2ad42dc7f3aacc4f27958c03b1688ac72d6c | |
parent | bc1ab097e55ae438d6f65dc56bbe2755839a52c2 (diff) | |
parent | c7280ebf4187dc2593dc6e02de11eebbabbbbe49 (diff) |
Merge "Create ViewModel for QS compose and a basic fragment" into main
16 files changed, 915 insertions, 22 deletions
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index 6d78705d1fbc..5ea75be11c47 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -748,6 +748,7 @@ android_library { "//frameworks/libs/systemui:motion_tool_lib", "//frameworks/libs/systemui:contextualeducationlib", "androidx.core_core-animation-testing", + "androidx.lifecycle_lifecycle-runtime-testing", "androidx.compose.ui_ui", "flag-junit", "ravenwood-junit", @@ -789,6 +790,7 @@ android_library { "SystemUI-tests-base", "androidx.test.uiautomator_uiautomator", "androidx.core_core-animation-testing", + "androidx.lifecycle_lifecycle-runtime-testing", "mockito-target-extended-minus-junit4", "mockito-kotlin-nodeps", "androidx.test.ext.junit", diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt index eea00c4f2935..fb7c42254caa 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/qs/ui/composable/QuickSettingsShadeScene.kt @@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -42,6 +40,7 @@ import com.android.compose.animation.scene.UserAction import com.android.compose.animation.scene.UserActionResult import com.android.systemui.battery.BatteryMeterViewController import com.android.systemui.brightness.ui.compose.BrightnessSliderContainer +import com.android.systemui.compose.modifiers.sysuiResTag import com.android.systemui.dagger.SysUISingleton import com.android.systemui.keyguard.ui.composable.LockscreenContent import com.android.systemui.lifecycle.rememberViewModel @@ -114,7 +113,7 @@ constructor( } @Composable -private fun ShadeBody( +fun ShadeBody( viewModel: QuickSettingsContainerViewModel, ) { val isEditing by viewModel.editModeViewModel.isEditing.collectAsStateWithLifecycle() @@ -131,6 +130,7 @@ private fun ShadeBody( } else { QuickSettingsLayout( viewModel = viewModel, + modifier = Modifier.sysuiResTag("quick_settings_panel") ) } } @@ -158,11 +158,6 @@ private fun QuickSettingsLayout( Modifier.fillMaxWidth().heightIn(max = QuickSettingsShade.Dimensions.GridMaxHeight), viewModel.editModeViewModel::startEditing, ) - Button( - onClick = { viewModel.editModeViewModel.startEditing() }, - ) { - Text("Edit mode") - } } } diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelTest.kt new file mode 100644 index 000000000000..59992650cfc7 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelTest.kt @@ -0,0 +1,151 @@ +/* + * 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.qs.composefragment.viewmodel + +import android.content.testableContext +import android.testing.TestableLooper.RunWithLooper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.testing.TestLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.kosmos.testDispatcher +import com.android.systemui.kosmos.testScope +import com.android.systemui.qs.fgsManagerController +import com.android.systemui.res.R +import com.android.systemui.shade.largeScreenHeaderHelper +import com.android.systemui.testKosmos +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +@RunWithLooper +@OptIn(ExperimentalCoroutinesApi::class) +class QSFragmentComposeViewModelTest : SysuiTestCase() { + private val kosmos = testKosmos() + + private val lifecycleOwner = + TestLifecycleOwner( + initialState = Lifecycle.State.CREATED, + coroutineDispatcher = kosmos.testDispatcher, + ) + + private val underTest by lazy { + kosmos.qsFragmentComposeViewModelFactory.create(lifecycleOwner.lifecycleScope) + } + + @Before + fun setUp() { + Dispatchers.setMain(kosmos.testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + // For now the state changes at 0.5f expansion. This will change once we implement animation + // (and this test will fail) + @Test + fun qsExpansionValueChanges_correctExpansionState() = + with(kosmos) { + testScope.testWithinLifecycle { + val expansionState by collectLastValue(underTest.expansionState) + + underTest.qsExpansionValue = 0f + assertThat(expansionState) + .isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QQS) + + underTest.qsExpansionValue = 0.3f + assertThat(expansionState) + .isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QQS) + + underTest.qsExpansionValue = 0.7f + assertThat(expansionState).isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QS) + + underTest.qsExpansionValue = 1f + assertThat(expansionState).isEqualTo(QSFragmentComposeViewModel.QSExpansionState.QS) + } + } + + @Test + fun qqsHeaderHeight_largeScreenHeader_0() = + with(kosmos) { + testScope.testWithinLifecycle { + val qqsHeaderHeight by collectLastValue(underTest.qqsHeaderHeight) + + testableContext.orCreateTestableResources.addOverride( + R.bool.config_use_large_screen_shade_header, + true + ) + fakeConfigurationRepository.onConfigurationChange() + + assertThat(qqsHeaderHeight).isEqualTo(0) + } + } + + @Test + fun qqsHeaderHeight_noLargeScreenHeader_providedByHelper() = + with(kosmos) { + testScope.testWithinLifecycle { + val qqsHeaderHeight by collectLastValue(underTest.qqsHeaderHeight) + + testableContext.orCreateTestableResources.addOverride( + R.bool.config_use_large_screen_shade_header, + false + ) + fakeConfigurationRepository.onConfigurationChange() + + assertThat(qqsHeaderHeight) + .isEqualTo(largeScreenHeaderHelper.getLargeScreenHeaderHeight()) + } + } + + @Test + fun footerActionsControllerInit() = + with(kosmos) { + testScope.testWithinLifecycle { + underTest + runCurrent() + assertThat(fgsManagerController.initialized).isTrue() + } + } + + private inline fun TestScope.testWithinLifecycle( + crossinline block: suspend TestScope.() -> TestResult + ): TestResult { + return runTest { + lifecycleOwner.setCurrentState(Lifecycle.State.RESUMED) + block().also { lifecycleOwner.setCurrentState(Lifecycle.State.DESTROYED) } + } + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneContentViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneContentViewModelTest.kt index 956353845506..1118a6150fcc 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneContentViewModelTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneContentViewModelTest.kt @@ -17,6 +17,7 @@ package com.android.systemui.qs.ui.viewmodel import android.testing.TestableLooper.RunWithLooper +import androidx.lifecycle.LifecycleOwner import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase @@ -59,7 +60,7 @@ class QuickSettingsSceneContentViewModelTest : SysuiTestCase() { private val footerActionsViewModel = mock<FooterActionsViewModel>() private val footerActionsViewModelFactory = mock<FooterActionsViewModel.Factory> { - whenever(create(any())).thenReturn(footerActionsViewModel) + whenever(create(any<LifecycleOwner>())).thenReturn(footerActionsViewModel) } private val footerActionsController = mock<FooterActionsController>() diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSFragmentStartable.kt b/packages/SystemUI/src/com/android/systemui/qs/QSFragmentStartable.kt index 9fa6769fe5f3..bb238f2e20fe 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/QSFragmentStartable.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/QSFragmentStartable.kt @@ -19,6 +19,7 @@ package com.android.systemui.qs import com.android.systemui.CoreStartable import com.android.systemui.dagger.SysUISingleton import com.android.systemui.fragments.FragmentService +import com.android.systemui.qs.composefragment.QSFragmentCompose import dagger.Binds import dagger.Module import dagger.multibindings.ClassKey @@ -31,13 +32,18 @@ class QSFragmentStartable @Inject constructor( private val fragmentService: FragmentService, - private val qsFragmentLegacyProvider: Provider<QSFragmentLegacy> + private val qsFragmentLegacyProvider: Provider<QSFragmentLegacy>, + private val qsFragmentComposeProvider: Provider<QSFragmentCompose>, ) : CoreStartable { override fun start() { fragmentService.addFragmentInstantiationProvider( QSFragmentLegacy::class.java, qsFragmentLegacyProvider ) + fragmentService.addFragmentInstantiationProvider( + QSFragmentCompose::class.java, + qsFragmentComposeProvider + ) } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt new file mode 100644 index 000000000000..5d81d4f3f9ec --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/composefragment/QSFragmentCompose.kt @@ -0,0 +1,442 @@ +/* + * 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.qs.composefragment + +import android.annotation.SuppressLint +import android.graphics.Rect +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.round +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.compose.modifiers.height +import com.android.compose.modifiers.padding +import com.android.compose.theme.PlatformTheme +import com.android.systemui.compose.modifiers.sysuiResTag +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.plugins.qs.QS +import com.android.systemui.plugins.qs.QSContainerController +import com.android.systemui.qs.composefragment.viewmodel.QSFragmentComposeViewModel +import com.android.systemui.qs.flags.QSComposeFragment +import com.android.systemui.qs.footer.ui.compose.FooterActions +import com.android.systemui.qs.panels.ui.compose.QuickQuickSettings +import com.android.systemui.qs.ui.composable.QuickSettingsTheme +import com.android.systemui.qs.ui.composable.ShadeBody +import com.android.systemui.res.R +import com.android.systemui.util.LifecycleFragment +import java.util.function.Consumer +import javax.inject.Inject +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@SuppressLint("ValidFragment") +class QSFragmentCompose +@Inject +constructor( + private val qsFragmentComposeViewModelFactory: QSFragmentComposeViewModel.Factory, +) : LifecycleFragment(), QS { + + private val scrollListener = MutableStateFlow<QS.ScrollListener?>(null) + private val heightListener = MutableStateFlow<QS.HeightListener?>(null) + private val qsContainerController = MutableStateFlow<QSContainerController?>(null) + + private lateinit var viewModel: QSFragmentComposeViewModel + + // Starting with a non-zero value makes it so that it has a non-zero height on first expansion + // This is important for `QuickSettingsControllerImpl.mMinExpansionHeight` to detect a "change". + private val qqsHeight = MutableStateFlow(1) + private val qsHeight = MutableStateFlow(0) + private val qqsVisible = MutableStateFlow(false) + private val qqsPositionOnRoot = Rect() + private val composeViewPositionOnScreen = Rect() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + QSComposeFragment.isUnexpectedlyInLegacyMode() + viewModel = qsFragmentComposeViewModelFactory.create(lifecycleScope) + + setListenerCollections() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val context = inflater.context + return ComposeView(context).apply { + setBackPressedDispatcher() + setContent { + PlatformTheme { + val visible by viewModel.qsVisible.collectAsStateWithLifecycle() + val qsState by viewModel.expansionState.collectAsStateWithLifecycle() + + AnimatedVisibility( + visible = visible, + modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars) + ) { + AnimatedContent(targetState = qsState) { + when (it) { + QSFragmentComposeViewModel.QSExpansionState.QQS -> { + QuickQuickSettingsElement() + } + QSFragmentComposeViewModel.QSExpansionState.QS -> { + QuickSettingsElement() + } + else -> {} + } + } + } + } + } + } + } + + override fun setPanelView(notificationPanelView: QS.HeightListener?) { + heightListener.value = notificationPanelView + } + + override fun hideImmediately() { + // view?.animate()?.cancel() + // view?.y = -qsMinExpansionHeight.toFloat() + } + + override fun getQsMinExpansionHeight(): Int { + // TODO (b/353253277) implement split screen + return qqsHeight.value + } + + override fun getDesiredHeight(): Int { + /* + * Looking at the code, it seems that + * * If customizing, then the height is that of the view post-layout, which is set by + * QSContainerImpl.calculateContainerHeight, which is the height the customizer takes + * * If not customizing, it's the measured height. So we may want to surface that. + */ + return view?.height ?: 0 + } + + override fun setHeightOverride(desiredHeight: Int) { + viewModel.heightOverrideValue = desiredHeight + } + + override fun setHeaderClickable(qsExpansionEnabled: Boolean) { + // Empty method + } + + override fun isCustomizing(): Boolean { + return viewModel.containerViewModel.editModeViewModel.isEditing.value + } + + override fun closeCustomizer() { + viewModel.containerViewModel.editModeViewModel.stopEditing() + } + + override fun setOverscrolling(overscrolling: Boolean) { + viewModel.stackScrollerOverscrollingValue = overscrolling + } + + override fun setExpanded(qsExpanded: Boolean) { + viewModel.isQSExpanded = qsExpanded + } + + override fun setListening(listening: Boolean) { + // Not needed, views start listening and collection when composed + } + + override fun setQsVisible(qsVisible: Boolean) { + viewModel.isQSVisible = qsVisible + } + + override fun isShowingDetail(): Boolean { + return isCustomizing + } + + override fun closeDetail() { + closeCustomizer() + } + + override fun animateHeaderSlidingOut() { + // TODO(b/353254353) + } + + override fun setQsExpansion( + qsExpansionFraction: Float, + panelExpansionFraction: Float, + headerTranslation: Float, + squishinessFraction: Float + ) { + viewModel.qsExpansionValue = qsExpansionFraction + viewModel.panelExpansionFractionValue = panelExpansionFraction + viewModel.squishinessFractionValue = squishinessFraction + + // TODO(b/353254353) Handle header translation + } + + override fun setHeaderListening(listening: Boolean) { + // Not needed, header will start listening as soon as it's composed + } + + override fun notifyCustomizeChanged() { + // Not needed, only called from inside customizer + } + + override fun setContainerController(controller: QSContainerController?) { + qsContainerController.value = controller + } + + override fun setCollapseExpandAction(action: Runnable?) { + // Nothing to do yet. But this should be wired to a11y + } + + override fun getHeightDiff(): Int { + return 0 // For now TODO(b/353254353) + } + + override fun getHeader(): View? { + QSComposeFragment.isUnexpectedlyInLegacyMode() + return null + } + + override fun setShouldUpdateSquishinessOnMedia(shouldUpdate: Boolean) { + super.setShouldUpdateSquishinessOnMedia(shouldUpdate) + // TODO (b/353253280) + } + + override fun setInSplitShade(shouldTranslate: Boolean) { + // TODO (b/356435605) + } + + override fun setTransitionToFullShadeProgress( + isTransitioningToFullShade: Boolean, + qsTransitionFraction: Float, + qsSquishinessFraction: Float + ) { + super.setTransitionToFullShadeProgress( + isTransitioningToFullShade, + qsTransitionFraction, + qsSquishinessFraction + ) + } + + override fun setFancyClipping( + leftInset: Int, + top: Int, + rightInset: Int, + bottom: Int, + cornerRadius: Int, + visible: Boolean, + fullWidth: Boolean + ) {} + + override fun isFullyCollapsed(): Boolean { + return !viewModel.isQSVisible + } + + override fun setCollapsedMediaVisibilityChangedListener(listener: Consumer<Boolean>?) { + // TODO (b/353253280) + } + + override fun setScrollListener(scrollListener: QS.ScrollListener?) { + this.scrollListener.value = scrollListener + } + + override fun setOverScrollAmount(overScrollAmount: Int) { + super.setOverScrollAmount(overScrollAmount) + } + + override fun setIsNotificationPanelFullWidth(isFullWidth: Boolean) { + viewModel.isSmallScreenValue = isFullWidth + } + + override fun getHeaderTop(): Int { + return viewModel.qqsHeaderHeight.value + } + + override fun getHeaderBottom(): Int { + return headerTop + qqsHeight.value + } + + override fun getHeaderLeft(): Int { + return qqsPositionOnRoot.left + } + + override fun getHeaderBoundsOnScreen(outBounds: Rect) { + outBounds.set(qqsPositionOnRoot) + view?.getBoundsOnScreen(composeViewPositionOnScreen) + ?: run { composeViewPositionOnScreen.setEmpty() } + qqsPositionOnRoot.offset(composeViewPositionOnScreen.left, composeViewPositionOnScreen.top) + } + + override fun isHeaderShown(): Boolean { + return qqsVisible.value + } + + private fun setListenerCollections() { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + // TODO + // setListenerJob( + // scrollListener, + // + // ) + } + launch { + setListenerJob( + heightListener, + viewModel.containerViewModel.editModeViewModel.isEditing + ) { + onQsHeightChanged() + } + } + launch { + setListenerJob( + qsContainerController, + viewModel.containerViewModel.editModeViewModel.isEditing + ) { + setCustomizerShowing(it) + } + } + } + } + } + + @Composable + private fun QuickQuickSettingsElement() { + val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle() + DisposableEffect(Unit) { + qqsVisible.value = true + + onDispose { qqsVisible.value = false } + } + Column(modifier = Modifier.sysuiResTag("quick_qs_panel")) { + QuickQuickSettings( + viewModel = viewModel.containerViewModel.quickQuickSettingsViewModel, + modifier = + Modifier.onGloballyPositioned { coordinates -> + val (leftFromRoot, topFromRoot) = coordinates.positionInRoot().round() + val (width, height) = coordinates.size + qqsPositionOnRoot.set( + leftFromRoot, + topFromRoot, + leftFromRoot + width, + topFromRoot + height + ) + } + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + qqsHeight.value = placeable.height + + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + .padding(top = { qqsPadding }) + ) + Spacer(modifier = Modifier.weight(1f)) + } + } + + @Composable + private fun QuickSettingsElement() { + val qqsPadding by viewModel.qqsHeaderHeight.collectAsStateWithLifecycle() + val qsExtraPadding = dimensionResource(R.dimen.qs_panel_padding_top) + Column { + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + Column { + Spacer(modifier = Modifier.height { qqsPadding + qsExtraPadding.roundToPx() }) + ShadeBody(viewModel = viewModel.containerViewModel) + } + } + QuickSettingsTheme { + FooterActions( + viewModel = viewModel.footerActionsViewModel, + qsVisibilityLifecycleOwner = this@QSFragmentCompose, + modifier = Modifier.sysuiResTag("qs_footer_actions") + ) + } + } + } +} + +private fun View.setBackPressedDispatcher() { + repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.CREATED) { + setViewTreeOnBackPressedDispatcherOwner( + object : OnBackPressedDispatcherOwner { + override val onBackPressedDispatcher = + OnBackPressedDispatcher().apply { + setOnBackInvokedDispatcher(it.viewRootImpl.onBackInvokedDispatcher) + } + + override val lifecycle: Lifecycle = this@repeatWhenAttached.lifecycle + } + ) + } + } +} + +private suspend inline fun <Listener : Any, Data> setListenerJob( + listenerFlow: MutableStateFlow<Listener?>, + dataFlow: Flow<Data>, + crossinline onCollect: suspend Listener.(Data) -> Unit +) { + coroutineScope { + try { + listenerFlow.collectLatest { listenerOrNull -> + listenerOrNull?.let { currentListener -> + launch { + // Called when editing mode changes + dataFlow.collect { currentListener.onCollect(it) } + } + } + } + awaitCancellation() + } finally { + listenerFlow.value = null + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModel.kt new file mode 100644 index 000000000000..9e109e436226 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModel.kt @@ -0,0 +1,196 @@ +/* + * 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.qs.composefragment.viewmodel + +import android.content.res.Resources +import android.graphics.Rect +import androidx.lifecycle.LifecycleCoroutineScope +import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.FooterActionsController +import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel +import com.android.systemui.qs.ui.viewmodel.QuickSettingsContainerViewModel +import com.android.systemui.shade.LargeScreenHeaderHelper +import com.android.systemui.shade.transition.LargeScreenShadeInterpolator +import com.android.systemui.statusbar.SysuiStatusBarStateController +import com.android.systemui.statusbar.disableflags.data.repository.DisableFlagsRepository +import com.android.systemui.statusbar.phone.KeyguardBypassController +import com.android.systemui.util.LargeScreenUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class QSFragmentComposeViewModel +@AssistedInject +constructor( + val containerViewModel: QuickSettingsContainerViewModel, + @Main private val resources: Resources, + private val footerActionsViewModelFactory: FooterActionsViewModel.Factory, + private val footerActionsController: FooterActionsController, + private val sysuiStatusBarStateController: SysuiStatusBarStateController, + private val keyguardBypassController: KeyguardBypassController, + private val disableFlagsRepository: DisableFlagsRepository, + private val largeScreenShadeInterpolator: LargeScreenShadeInterpolator, + private val configurationInteractor: ConfigurationInteractor, + private val largeScreenHeaderHelper: LargeScreenHeaderHelper, + @Assisted private val lifecycleScope: LifecycleCoroutineScope, +) { + val footerActionsViewModel = + footerActionsViewModelFactory.create(lifecycleScope).also { + lifecycleScope.launch { footerActionsController.init() } + } + + private val _qsBounds = MutableStateFlow(Rect()) + + private val _qsExpanded = MutableStateFlow(false) + var isQSExpanded: Boolean + get() = _qsExpanded.value + set(value) { + _qsExpanded.value = value + } + + private val _qsVisible = MutableStateFlow(false) + val qsVisible = _qsVisible.asStateFlow() + var isQSVisible: Boolean + get() = qsVisible.value + set(value) { + _qsVisible.value = value + } + + private val _qsExpansion = MutableStateFlow(0f) + var qsExpansionValue: Float + get() = _qsExpansion.value + set(value) { + _qsExpansion.value = value + } + + private val _panelFraction = MutableStateFlow(0f) + var panelExpansionFractionValue: Float + get() = _panelFraction.value + set(value) { + _panelFraction.value = value + } + + private val _squishinessFraction = MutableStateFlow(0f) + var squishinessFractionValue: Float + get() = _squishinessFraction.value + set(value) { + _squishinessFraction.value = value + } + + val qqsHeaderHeight = + configurationInteractor.onAnyConfigurationChange + .map { + if (LargeScreenUtils.shouldUseLargeScreenShadeHeader(resources)) { + 0 + } else { + largeScreenHeaderHelper.getLargeScreenHeaderHeight() + } + } + .stateIn(lifecycleScope, SharingStarted.WhileSubscribed(), 0) + + private val _headerAnimating = MutableStateFlow(false) + + private val _stackScrollerOverscrolling = MutableStateFlow(false) + var stackScrollerOverscrollingValue: Boolean + get() = _stackScrollerOverscrolling.value + set(value) { + _stackScrollerOverscrolling.value = value + } + + private val qsDisabled = + disableFlagsRepository.disableFlags + .map { !it.isQuickSettingsEnabled() } + .stateIn( + lifecycleScope, + SharingStarted.WhileSubscribed(), + !disableFlagsRepository.disableFlags.value.isQuickSettingsEnabled() + ) + + private val _showCollapsedOnKeyguard = MutableStateFlow(false) + + private val _keyguardAndExpanded = MutableStateFlow(false) + + private val _statusBarState = MutableStateFlow(-1) + + private val _viewHeight = MutableStateFlow(0) + + private val _headerTranslation = MutableStateFlow(0f) + + private val _inSplitShade = MutableStateFlow(false) + + private val _transitioningToFullShade = MutableStateFlow(false) + + private val _lockscreenToShadeProgress = MutableStateFlow(false) + + private val _overscrolling = MutableStateFlow(false) + + private val _isSmallScreen = MutableStateFlow(false) + var isSmallScreenValue: Boolean + get() = _isSmallScreen.value + set(value) { + _isSmallScreen.value = value + } + + private val _shouldUpdateMediaSquishiness = MutableStateFlow(false) + + private val _heightOverride = MutableStateFlow(-1) + val heightOverride = _heightOverride.asStateFlow() + var heightOverrideValue: Int + get() = heightOverride.value + set(value) { + _heightOverride.value = value + } + + val expansionState: StateFlow<QSExpansionState> = + combine( + _stackScrollerOverscrolling, + _qsExpanded, + _qsExpansion, + ) { args: Array<Any> -> + val expansion = args[2] as Float + if (expansion > 0.5f) { + QSExpansionState.QS + } else { + QSExpansionState.QQS + } + } + .stateIn(lifecycleScope, SharingStarted.WhileSubscribed(), QSExpansionState.QQS) + + @AssistedFactory + interface Factory { + fun create(lifecycleScope: LifecycleCoroutineScope): QSFragmentComposeViewModel + } + + sealed interface QSExpansionState { + data object QQS : QSExpansionState + + data object QS : QSExpansionState + + @JvmInline value class Expanding(val progress: Float) : QSExpansionState + + @JvmInline value class Collapsing(val progress: Float) : QSExpansionState + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt index ba45d172b082..6dc101a63f09 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/footer/ui/viewmodel/FooterActionsViewModel.kt @@ -21,6 +21,7 @@ import android.util.Log import android.view.ContextThemeWrapper import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleOwner import com.android.settingslib.Utils import com.android.systemui.animation.Expandable @@ -41,6 +42,7 @@ import javax.inject.Inject import javax.inject.Named import javax.inject.Provider import kotlin.math.max +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -48,6 +50,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch private const val TAG = "FooterActionsViewModel" @@ -140,6 +144,30 @@ class FooterActionsViewModel( showPowerButton, ) } + + fun create(lifecycleCoroutineScope: LifecycleCoroutineScope): FooterActionsViewModel { + val globalActionsDialogLite = globalActionsDialogLiteProvider.get() + if (lifecycleCoroutineScope.isActive) { + lifecycleCoroutineScope.launch { + try { + awaitCancellation() + } finally { + globalActionsDialogLite.destroy() + } + } + } else { + globalActionsDialogLite.destroy() + } + + return FooterActionsViewModel( + context, + footerActionsInteractor, + falsingManager, + globalActionsDialogLite, + activityStarter, + showPowerButton, + ) + } } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt index 2ee957e89f48..08a56bf29f66 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PaginatedGridLayout.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.systemui.compose.modifiers.sysuiResTag import com.android.systemui.qs.panels.dagger.PaginatedBaseLayoutType import com.android.systemui.qs.panels.ui.compose.PaginatedGridLayout.Dimensions.FooterHeight import com.android.systemui.qs.panels.ui.compose.PaginatedGridLayout.Dimensions.InterPageSpacing @@ -77,7 +78,7 @@ constructor( Column { HorizontalPager( state = pagerState, - modifier = Modifier, + modifier = Modifier.sysuiResTag("qs_pager"), pageSpacing = if (pages.size > 1) InterPageSpacing else 0.dp, beyondViewportPageCount = 1, verticalAlignment = Alignment.Top, diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt index af3803b6ff34..a9027ff92996 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.systemui.compose.modifiers.sysuiResTag import com.android.systemui.qs.panels.ui.viewmodel.QuickQuickSettingsViewModel import com.android.systemui.res.R @@ -44,7 +45,10 @@ fun QuickQuickSettings( } val columns by viewModel.columns.collectAsStateWithLifecycle() - TileLazyGrid(modifier = modifier, columns = GridCells.Fixed(columns)) { + TileLazyGrid( + modifier = modifier.sysuiResTag("qqs_tile_layout"), + columns = GridCells.Fixed(columns) + ) { items( tiles.size, key = { index -> sizedTiles[index].tile.spec.spec }, diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt index 7e6ccd635a96..9c0701e974ec 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/Tile.kt @@ -22,7 +22,6 @@ import android.graphics.drawable.Animatable import android.service.quicksettings.Tile.STATE_ACTIVE import android.service.quicksettings.Tile.STATE_INACTIVE import android.text.TextUtils -import androidx.appcompat.content.res.AppCompatResources import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -593,15 +592,15 @@ enum class ClickAction { } @Composable -private fun getTileIcon(icon: Supplier<QSTile.Icon>): Icon { +private fun getTileIcon(icon: Supplier<QSTile.Icon?>): Icon { val context = LocalContext.current - return icon.get().let { + return icon.get()?.let { if (it is QSTileImpl.ResourceIcon) { Icon.Resource(it.resId, null) } else { Icon.Loaded(it.getDrawable(context), null) } - } + } ?: Icon.Resource(R.drawable.ic_error_outline, null) } @OptIn(ExperimentalAnimationGraphicsApi::class) @@ -618,7 +617,7 @@ private fun TileIcon( remember(icon, context) { when (icon) { is Icon.Loaded -> icon.drawable - is Icon.Resource -> AppCompatResources.getDrawable(context, icon.res) + is Icon.Resource -> context.getDrawable(icon.res) } } if (loadedDrawable !is Animatable) { diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt index 4ec59c969a59..c83e3b2a0e06 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/TileUiState.kt @@ -25,7 +25,7 @@ data class TileUiState( val label: String, val secondaryLabel: String, val state: Int, - val icon: Supplier<QSTile.Icon>, + val icon: Supplier<QSTile.Icon?>, ) fun QSTile.State.toUiState(): TileUiState { @@ -33,6 +33,6 @@ fun QSTile.State.toUiState(): TileUiState { label?.toString() ?: "", secondaryLabel?.toString() ?: "", state, - icon?.let { Supplier { icon } } ?: iconSupplier, + icon?.let { Supplier { icon } } ?: iconSupplier ?: Supplier { null }, ) } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java index c4fbc37b2dd5..94dd9bbefaa3 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/CentralSurfacesImpl.java @@ -160,6 +160,8 @@ import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.power.domain.interactor.PowerInteractor; import com.android.systemui.qs.QSFragmentLegacy; import com.android.systemui.qs.QSPanelController; +import com.android.systemui.qs.composefragment.QSFragmentCompose; +import com.android.systemui.qs.flags.QSComposeFragment; import com.android.systemui.res.R; import com.android.systemui.scene.domain.interactor.WindowRootViewVisibilityInteractor; import com.android.systemui.scene.shared.flag.SceneContainerFlag; @@ -1432,9 +1434,15 @@ public class CentralSurfacesImpl implements CoreStartable, CentralSurfaces { } protected QS createDefaultQSFragment() { + Class<? extends QS> klass; + if (QSComposeFragment.isEnabled()) { + klass = QSFragmentCompose.class; + } else { + klass = QSFragmentLegacy.class; + } return mFragmentService .getFragmentHostManager(getNotificationShadeWindowView()) - .create(QSFragmentLegacy.class); + .create(klass); } private void setUpPresenter() { diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSImplTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSImplTest.java index 206bbbfba753..4ce2d7c6d78b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSImplTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSImplTest.java @@ -51,6 +51,7 @@ import android.widget.FrameLayout; import androidx.compose.ui.platform.ComposeView; import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.SmallTest; @@ -611,7 +612,8 @@ public class QSImplTest extends SysuiTestCase { when(mQSContainerImplController.getView()).thenReturn(mContainer); when(mQSPanelController.getTileLayout()).thenReturn(mQQsTileLayout); when(mQuickQSPanelController.getTileLayout()).thenReturn(mQsTileLayout); - when(mFooterActionsViewModelFactory.create(any())).thenReturn(mFooterActionsViewModel); + when(mFooterActionsViewModelFactory.create(any(LifecycleOwner.class))) + .thenReturn(mFooterActionsViewModel); } private void setUpMedia() { diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt index 9ff7dd590781..ffe6918a56f8 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeFgsManagerController.kt @@ -27,6 +27,9 @@ class FakeFgsManagerController( numRunningPackages: Int = 0, ) : FgsManagerController { + var initialized = false + private set + override var numRunningPackages = numRunningPackages set(value) { if (value != field) { @@ -53,7 +56,9 @@ class FakeFgsManagerController( dialogDismissedListeners.forEach { it.onDialogDismissed() } } - override fun init() {} + override fun init() { + initialized = true + } override fun showDialog(expandable: Expandable?) {} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelKosmos.kt new file mode 100644 index 000000000000..d37d8f39b9ee --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/composefragment/viewmodel/QSFragmentComposeViewModelKosmos.kt @@ -0,0 +1,53 @@ +/* + * 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.qs.composefragment.viewmodel + +import android.content.res.mainResources +import androidx.lifecycle.LifecycleCoroutineScope +import com.android.systemui.common.ui.domain.interactor.configurationInteractor +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.footerActionsController +import com.android.systemui.qs.footerActionsViewModelFactory +import com.android.systemui.qs.ui.viewmodel.quickSettingsContainerViewModel +import com.android.systemui.shade.largeScreenHeaderHelper +import com.android.systemui.shade.transition.largeScreenShadeInterpolator +import com.android.systemui.statusbar.disableflags.data.repository.disableFlagsRepository +import com.android.systemui.statusbar.phone.keyguardBypassController +import com.android.systemui.statusbar.sysuiStatusBarStateController + +val Kosmos.qsFragmentComposeViewModelFactory by + Kosmos.Fixture { + object : QSFragmentComposeViewModel.Factory { + override fun create( + lifecycleScope: LifecycleCoroutineScope + ): QSFragmentComposeViewModel { + return QSFragmentComposeViewModel( + quickSettingsContainerViewModel, + mainResources, + footerActionsViewModelFactory, + footerActionsController, + sysuiStatusBarStateController, + keyguardBypassController, + disableFlagsRepository, + largeScreenShadeInterpolator, + configurationInteractor, + largeScreenHeaderHelper, + lifecycleScope, + ) + } + } + } |