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

Commit 6078c273 authored by Fabián Kozynski's avatar Fabián Kozynski
Browse files

Add media to QSFragmentCompose

This adds a basic media composable that tracks the visibility of the
host. Because the transitions between the hosts are handled by
QuickSettingsControllerImpl, there's nothing that needs to be done for
it.

The following has not been implemented yet:

* Proper clipping when expanding above FooterActions
* Proper padding and positioning for animations
* Media squishiness
* Landscape media

Test: atest QSFragmentComposeViewModelTest
Test: atest android.platform.test.scenario.sysui.media
Bug: 353253280
Fixes: 373580826
Flag: com.android.systemui.qs_ui_refactor_compose_fragment

Change-Id: I900f5393458d0b58d6ed28cc47260bd3f54b236d
parent cfe2434e
Loading
Loading
Loading
Loading
+10 −2
Original line number Diff line number Diff line
@@ -24,12 +24,16 @@ import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.testDispatcher
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.media.controls.domain.pipeline.legacyMediaDataManagerImpl
import com.android.systemui.media.controls.domain.pipeline.mediaDataManager
import com.android.systemui.qs.composefragment.dagger.usingMediaInComposeFragment
import com.android.systemui.testKosmos
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
@@ -39,7 +43,7 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
abstract class AbstractQSFragmentComposeViewModelTest : SysuiTestCase() {
    protected val kosmos = testKosmos()
    protected val kosmos = testKosmos().apply { mediaDataManager = legacyMediaDataManagerImpl }

    protected val lifecycleOwner =
        TestLifecycleOwner(
@@ -62,11 +66,15 @@ abstract class AbstractQSFragmentComposeViewModelTest : SysuiTestCase() {
    }

    protected inline fun TestScope.testWithinLifecycle(
        crossinline block: suspend TestScope.() -> TestResult
        usingMedia: Boolean = true,
        crossinline block: suspend TestScope.() -> TestResult,
    ): TestResult {
        return runTest {
            kosmos.usingMediaInComposeFragment = usingMedia

            lifecycleOwner.setCurrentState(Lifecycle.State.RESUMED)
            underTest.activateIn(kosmos.testScope)
            runCurrent()
            block().also { lifecycleOwner.setCurrentState(Lifecycle.State.DESTROYED) }
        }
    }
+103 −0
Original line number Diff line number Diff line
@@ -25,6 +25,15 @@ import androidx.test.filters.SmallTest
import com.android.systemui.common.ui.data.repository.fakeConfigurationRepository
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.domain.pipeline.legacyMediaDataManagerImpl
import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
import com.android.systemui.media.controls.ui.controller.mediaCarouselController
import com.android.systemui.media.controls.ui.view.MediaHostState
import com.android.systemui.media.controls.ui.view.qqsMediaHost
import com.android.systemui.media.controls.ui.view.qsMediaHost
import com.android.systemui.qs.composefragment.viewmodel.MediaState.ACTIVE_MEDIA
import com.android.systemui.qs.composefragment.viewmodel.MediaState.ANY_MEDIA
import com.android.systemui.qs.composefragment.viewmodel.MediaState.NO_MEDIA
import com.android.systemui.qs.fgsManagerController
import com.android.systemui.qs.panels.domain.interactor.tileSquishinessInteractor
import com.android.systemui.res.R
@@ -35,9 +44,11 @@ import com.android.systemui.statusbar.disableflags.data.repository.fakeDisableFl
import com.android.systemui.statusbar.sysuiStatusBarStateController
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.whenever

@SmallTest
@RunWith(AndroidJUnit4::class)
@@ -185,6 +196,92 @@ class QSFragmentComposeViewModelTest : AbstractQSFragmentComposeViewModelTest()
            }
        }

    @Test
    fun qqsMediaHost_initializedCorrectly() =
        with(kosmos) {
            testScope.testWithinLifecycle {
                assertThat(underTest.qqsMediaHost.location)
                    .isEqualTo(MediaHierarchyManager.LOCATION_QQS)
                assertThat(underTest.qqsMediaHost.expansion).isEqualTo(MediaHostState.EXPANDED)
                assertThat(underTest.qqsMediaHost.showsOnlyActiveMedia).isTrue()
                assertThat(underTest.qqsMediaHost.hostView).isNotNull()
            }
        }

    @Test
    fun qsMediaHost_initializedCorrectly() =
        with(kosmos) {
            testScope.testWithinLifecycle {
                assertThat(underTest.qsMediaHost.location)
                    .isEqualTo(MediaHierarchyManager.LOCATION_QS)
                assertThat(underTest.qsMediaHost.expansion).isEqualTo(MediaHostState.EXPANDED)
                assertThat(underTest.qsMediaHost.showsOnlyActiveMedia).isFalse()
                assertThat(underTest.qsMediaHost.hostView).isNotNull()
            }
        }

    @Test
    fun qqsMediaVisible_onlyWhenActiveMedia() =
        with(kosmos) {
            testScope.testWithinLifecycle {
                whenever(mediaCarouselController.isLockedAndHidden()).thenReturn(false)

                assertThat(underTest.qqsMediaVisible).isEqualTo(underTest.qqsMediaHost.visible)

                setMediaState(NO_MEDIA)
                assertThat(underTest.qqsMediaVisible).isFalse()

                setMediaState(ANY_MEDIA)
                assertThat(underTest.qqsMediaVisible).isFalse()

                setMediaState(ACTIVE_MEDIA)
                assertThat(underTest.qqsMediaVisible).isTrue()
            }
        }

    @Test
    fun qsMediaVisible_onAnyMedia() =
        with(kosmos) {
            testScope.testWithinLifecycle {
                whenever(mediaCarouselController.isLockedAndHidden()).thenReturn(false)

                assertThat(underTest.qsMediaVisible).isEqualTo(underTest.qsMediaHost.visible)

                setMediaState(NO_MEDIA)
                assertThat(underTest.qsMediaVisible).isFalse()

                setMediaState(ANY_MEDIA)
                assertThat(underTest.qsMediaVisible).isTrue()

                setMediaState(ACTIVE_MEDIA)
                assertThat(underTest.qsMediaVisible).isTrue()
            }
        }

    @Test
    fun notUsingMedia_mediaNotVisible() =
        with(kosmos) {
            testScope.testWithinLifecycle(usingMedia = false) {
                setMediaState(ACTIVE_MEDIA)

                assertThat(underTest.qqsMediaVisible).isFalse()
                assertThat(underTest.qsMediaVisible).isFalse()
            }
        }

    private fun TestScope.setMediaState(state: MediaState) {
        with(kosmos) {
            val activeMedia = state == ACTIVE_MEDIA
            val anyMedia = state != NO_MEDIA
            whenever(legacyMediaDataManagerImpl.hasActiveMediaOrRecommendation())
                .thenReturn(activeMedia)
            whenever(legacyMediaDataManagerImpl.hasAnyMediaOrRecommendation()).thenReturn(anyMedia)
            qqsMediaHost.updateViewVisibility()
            qsMediaHost.updateViewVisibility()
        }
        runCurrent()
    }

    companion object {
        private const val QS_DISABLE_FLAG = StatusBarManager.DISABLE2_QUICK_SETTINGS

@@ -195,3 +292,9 @@ class QSFragmentComposeViewModelTest : AbstractQSFragmentComposeViewModelTest()
        private const val epsilon = 0.001f
    }
}

private enum class MediaState {
    ACTIVE_MEDIA,
    ANY_MEDIA,
    NO_MEDIA,
}
+65 −16
Original line number Diff line number Diff line
@@ -38,12 +38,14 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement.spacedBy
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -73,13 +75,14 @@ import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.compose.animation.scene.ContentKey
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.ElementMatcher
@@ -98,10 +101,7 @@ import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.dump.DumpManager
import com.android.systemui.lifecycle.repeatWhenAttached
import com.android.systemui.lifecycle.setSnapshotBinding
import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
import com.android.systemui.media.controls.ui.view.MediaHost
import com.android.systemui.media.dagger.MediaModule.QS_PANEL
import com.android.systemui.media.dagger.MediaModule.QUICK_QS_PANEL
import com.android.systemui.plugins.qs.QS
import com.android.systemui.plugins.qs.QSContainerController
import com.android.systemui.qs.composefragment.SceneKeys.QuickQuickSettings
@@ -127,7 +127,6 @@ import com.android.systemui.util.println
import java.io.PrintWriter
import java.util.function.Consumer
import javax.inject.Inject
import javax.inject.Named
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
@@ -135,6 +134,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

@SuppressLint("ValidFragment")
class QSFragmentCompose
@@ -142,11 +142,11 @@ class QSFragmentCompose
constructor(
    private val qsFragmentComposeViewModelFactory: QSFragmentComposeViewModel.Factory,
    private val dumpManager: DumpManager,
    @Named(QUICK_QS_PANEL) private val qqsMediaHost: MediaHost,
    @Named(QS_PANEL) private val qsMediaHost: MediaHost,
) : LifecycleFragment(), QS, Dumpable {

    private val scrollListener = MutableStateFlow<QS.ScrollListener?>(null)
    private val collapsedMediaVisibilityChangedListener =
        MutableStateFlow<(Consumer<Boolean>)?>(null)
    private val heightListener = MutableStateFlow<QS.HeightListener?>(null)
    private val qsContainerController = MutableStateFlow<QSContainerController?>(null)

@@ -183,8 +183,6 @@ constructor(
        QSComposeFragment.isUnexpectedlyInLegacyMode()
        viewModel = qsFragmentComposeViewModelFactory.create(lifecycleScope)

        qqsMediaHost.init(MediaHierarchyManager.LOCATION_QQS)
        qsMediaHost.init(MediaHierarchyManager.LOCATION_QS)
        setListenerCollections()
        lifecycleScope.launch { viewModel.activate() }
    }
@@ -491,7 +489,7 @@ constructor(
    }

    override fun setCollapsedMediaVisibilityChangedListener(listener: Consumer<Boolean>?) {
        // TODO (b/353253280)
        collapsedMediaVisibilityChangedListener.value = listener
    }

    override fun setScrollListener(scrollListener: QS.ScrollListener?) {
@@ -534,6 +532,7 @@ constructor(
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                this@QSFragmentCompose.view?.setSnapshotBinding {
                    scrollListener.value?.onQsPanelScrollChanged(scrollState.value)
                    collapsedMediaVisibilityChangedListener.value?.accept(viewModel.qqsMediaVisible)
                }
                launch {
                    setListenerJob(
@@ -569,7 +568,7 @@ constructor(
                .squishiness
                .collectAsStateWithLifecycle()

        Column(modifier = Modifier.sysuiResTag("quick_qs_panel")) {
        Column(modifier = Modifier.sysuiResTag(ResIdTags.quickQsPanel)) {
            Box(
                modifier =
                    Modifier.fillMaxWidth()
@@ -581,6 +580,9 @@ constructor(
                                leftFromRoot + coordinates.size.width,
                                topFromRoot + coordinates.size.height,
                            )
                            if (squishiness == 1f) {
                                viewModel.qqsHeight = coordinates.size.height
                            }
                        }
                        // Use an approach layout to determien the height without squishiness, as
                        // that's the value that NPVC and QuickSettingsController care about
@@ -595,8 +597,7 @@ constructor(
                        .padding(top = { qqsPadding }, bottom = { bottomPadding })
            ) {
                if (viewModel.isQsEnabled) {
                    QuickQuickSettings(
                        viewModel = viewModel.containerViewModel.quickQuickSettingsViewModel,
                    Column(
                        modifier =
                            Modifier.collapseExpandSemanticAction(
                                    stringResource(
@@ -608,7 +609,16 @@ constructor(
                                        QuickSettingsShade.Dimensions.Padding.roundToPx()
                                    }
                                ),
                        verticalArrangement =
                            spacedBy(dimensionResource(R.dimen.qs_tile_margin_vertical)),
                    ) {
                        QuickQuickSettings(
                            viewModel = viewModel.containerViewModel.quickQuickSettingsViewModel
                        )
                        if (viewModel.qqsMediaVisible) {
                            MediaObject(mediaHost = viewModel.qqsMediaHost)
                        }
                    }
                }
            }
            Spacer(modifier = Modifier.weight(1f))
@@ -645,22 +655,35 @@ constructor(
                                }
                                .onSizeChanged { viewModel.qsScrollHeight = it.height }
                                .verticalScroll(scrollState)
                                .sysuiResTag(ResIdTags.qsScroll)
                    ) {
                        Spacer(
                            modifier = Modifier.height { qqsPadding + qsExtraPadding.roundToPx() }
                        )
                        QuickSettingsLayout(
                            viewModel = viewModel.containerViewModel,
                            modifier = Modifier.sysuiResTag("quick_settings_panel"),
                            modifier = Modifier.sysuiResTag(ResIdTags.quickSettingsPanel),
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                        if (viewModel.qsMediaVisible) {
                            MediaObject(
                                mediaHost = viewModel.qsMediaHost,
                                modifier =
                                    Modifier.padding(
                                        horizontal = {
                                            QuickSettingsShade.Dimensions.Padding.roundToPx()
                                        }
                                    ),
                            )
                        }
                    }
                }
                QuickSettingsTheme {
                    FooterActions(
                        viewModel = viewModel.footerActionsViewModel,
                        qsVisibilityLifecycleOwner = this@QSFragmentCompose,
                        modifier =
                            Modifier.sysuiResTag("qs_footer_actions")
                            Modifier.sysuiResTag(ResIdTags.qsFooterActions)
                                .element(ElementKeys.FooterActions),
                    )
                }
@@ -914,3 +937,29 @@ private fun Modifier.gesturesDisabled(disabled: Boolean) =
    } else {
        this
    }

@Composable
private fun MediaObject(mediaHost: MediaHost, modifier: Modifier = Modifier) {
    Box {
        AndroidView(
            modifier = modifier,
            factory = {
                mediaHost.hostView.apply {
                    layoutParams =
                        FrameLayout.LayoutParams(
                            FrameLayout.LayoutParams.MATCH_PARENT,
                            FrameLayout.LayoutParams.WRAP_CONTENT,
                        )
                }
            },
            onReset = {},
        )
    }
}

private object ResIdTags {
    const val quickSettingsPanel = "quick_settings_panel"
    const val quickQsPanel = "quick_qs_panel"
    const val qsScroll = "expanded_qs_scroll_view"
    const val qsFooterActions = "qs_footer_actions"
}
+40 −0
Original line number Diff line number Diff line
/*
 * 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.dagger

import android.content.Context
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.util.Utils
import dagger.Module
import dagger.Provides
import javax.inject.Named

@Module
interface QSFragmentComposeModule {

    companion object {
        const val QS_USING_MEDIA_PLAYER = "compose_fragment_using_media_player"

        @Provides
        @SysUISingleton
        @Named(QS_USING_MEDIA_PLAYER)
        fun providesUsingMedia(@Application context: Context): Boolean {
            return Utils.useQsMediaPlayer(context)
        }
    }
}
+73 −3
Original line number Diff line number Diff line
@@ -38,8 +38,14 @@ import com.android.systemui.keyguard.shared.model.Edge
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.lifecycle.Hydrator
import com.android.systemui.media.controls.ui.controller.MediaHierarchyManager
import com.android.systemui.media.controls.ui.view.MediaHost
import com.android.systemui.media.controls.ui.view.MediaHostState
import com.android.systemui.media.dagger.MediaModule.QS_PANEL
import com.android.systemui.media.dagger.MediaModule.QUICK_QS_PANEL
import com.android.systemui.plugins.statusbar.StatusBarStateController
import com.android.systemui.qs.FooterActionsController
import com.android.systemui.qs.composefragment.dagger.QSFragmentComposeModule
import com.android.systemui.qs.footer.ui.viewmodel.FooterActionsViewModel
import com.android.systemui.qs.panels.domain.interactor.TileSquishinessInteractor
import com.android.systemui.qs.panels.ui.viewmodel.PaginatedGridViewModel
@@ -60,10 +66,14 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import java.io.PrintWriter
import javax.inject.Named
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart

@@ -84,6 +94,9 @@ constructor(
    private val largeScreenHeaderHelper: LargeScreenHeaderHelper,
    private val squishinessInteractor: TileSquishinessInteractor,
    private val paginatedGridViewModel: PaginatedGridViewModel,
    @Named(QUICK_QS_PANEL) val qqsMediaHost: MediaHost,
    @Named(QS_PANEL) val qsMediaHost: MediaHost,
    @Named(QSFragmentComposeModule.QS_USING_MEDIA_PLAYER) private val usingMedia: Boolean,
    @Assisted private val lifecycleScope: LifecycleCoroutineScope,
) : Dumpable, ExclusiveActivatable() {

@@ -222,6 +235,30 @@ constructor(
        }
    }

    val showingMirror: Boolean
        get() = containerViewModel.brightnessSliderViewModel.showMirror

    // The initial values in these two are not meaningful. The flow will emit on start the correct
    // values. This is because we need to lazily fetch them after initMediaHosts.
    val qqsMediaVisible by
        hydrator.hydratedStateOf(
            traceName = "qqsMediaVisible",
            initialValue = usingMedia,
            source =
                if (usingMedia) {
                    mediaHostVisible(qqsMediaHost)
                } else {
                    flowOf(false)
                },
        )

    val qsMediaVisible by
        hydrator.hydratedStateOf(
            traceName = "qsMediaVisible",
            initialValue = usingMedia,
            source = if (usingMedia) mediaHostVisible(qsMediaHost) else flowOf(false),
        )

    private var qsBounds by mutableStateOf(Rect())

    private val constrainedSquishinessFraction: Float
@@ -259,9 +296,6 @@ constructor(
                    .onStart { emit(sysuiStatusBarStateController.state) },
        )

    val showingMirror: Boolean
        get() = containerViewModel.brightnessSliderViewModel.showMirror

    private val isKeyguardState: Boolean
        get() = statusBarState == StatusBarState.KEYGUARD

@@ -323,6 +357,7 @@ constructor(
        )

    override suspend fun onActivated(): Nothing {
        initMediaHosts() // init regardless of using media (same as current QS).
        coroutineScope {
            launch { hydrateSquishinessInteractor() }
            launch { hydrator.activate() }
@@ -331,6 +366,19 @@ constructor(
        }
    }

    private fun initMediaHosts() {
        qqsMediaHost.apply {
            expansion = MediaHostState.EXPANDED
            showsOnlyActiveMedia = true
            init(MediaHierarchyManager.LOCATION_QQS)
        }
        qsMediaHost.apply {
            expansion = MediaHostState.EXPANDED
            showsOnlyActiveMedia = false
            init(MediaHierarchyManager.LOCATION_QS)
        }
    }

    private suspend fun hydrateSquishinessInteractor() {
        snapshotFlow { constrainedSquishinessFraction }
            .collect { squishinessInteractor.setSquishinessValue(it) }
@@ -373,6 +421,10 @@ constructor(
                println("qqsHeight", "${qqsHeight}px")
                println("qsScrollHeight", "${qsScrollHeight}px")
            }
            printSection("Media") {
                println("qqsMediaVisible", qqsMediaVisible)
                println("qsMediaVisible", qsMediaVisible)
            }
        }
    }

@@ -390,3 +442,21 @@ private fun Float.constrainSquishiness(): Float {
}

private val SHORT_PARALLAX_AMOUNT = 0.1f

/**
 * Returns a flow to track the visibility of a [MediaHost]. The flow will emit on start the visible
 * state of the view.
 */
private fun mediaHostVisible(mediaHost: MediaHost): Flow<Boolean> {
    return callbackFlow {
            val listener: (Boolean) -> Unit = { visible: Boolean -> trySend(visible) }
            mediaHost.addVisibilityChangeListener(listener)

            awaitClose { mediaHost.removeVisibilityChangeListener(listener) }
        }
        // Need to use this to set initial state because on creation of the media host, the
        // view visibility is not in sync with [MediaHost.visible], which is what we track with
        // the listener. The correct state is set as part of init, so we need to get the state
        // lazily.
        .onStart { emit(mediaHost.visible) }
}
Loading