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

Commit e359b8ec authored by Michael Mikhail's avatar Michael Mikhail
Browse files

Add media carousel view model

Flag: ACONFIG media_controls_refactor DISABLED
Bug: 328207006
Test: atest SystemUiRoboTests:MediaCarouselViewModelTest
Change-Id: I08cd62194074191809044fa8484ed2b5845b8055
parent a7ef1059
Loading
Loading
Loading
Loading
+147 −0
Original line number Original line 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.media.controls.ui.viewmodel

import android.R
import android.content.packageManager
import android.content.pm.ApplicationInfo
import android.graphics.drawable.Icon
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.internal.logging.InstanceId
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.flags.Flags
import com.android.systemui.flags.fakeFeatureFlagsClassic
import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.MediaTestHelper
import com.android.systemui.media.controls.domain.pipeline.MediaDataFilterImpl
import com.android.systemui.media.controls.domain.pipeline.interactor.mediaCarouselInteractor
import com.android.systemui.media.controls.domain.pipeline.mediaDataFilter
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.statusbar.notificationLockscreenUserManager
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.whenever
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
import org.mockito.Mockito

@SmallTest
@RunWith(AndroidJUnit4::class)
class MediaCarouselViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope

    private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter
    private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager
    private val packageManager = kosmos.packageManager
    private val icon = Icon.createWithResource(context, R.drawable.ic_media_play)
    private val drawable = context.getDrawable(R.drawable.ic_media_play)
    private val smartspaceMediaData: SmartspaceMediaData =
        SmartspaceMediaData(
            targetId = KEY_MEDIA_SMARTSPACE,
            isActive = true,
            packageName = PACKAGE_NAME,
            recommendations = MediaTestHelper.getValidRecommendationList(icon),
        )

    private val underTest: MediaCarouselViewModel = kosmos.mediaCarouselViewModel

    @Before
    fun setUp() {
        kosmos.mediaCarouselInteractor.start()

        whenever(packageManager.getApplicationIcon(Mockito.anyString())).thenReturn(drawable)
        whenever(packageManager.getApplicationIcon(any(ApplicationInfo::class.java)))
            .thenReturn(drawable)
        whenever(packageManager.getApplicationInfo(eq(PACKAGE_NAME), ArgumentMatchers.anyInt()))
            .thenReturn(ApplicationInfo())
        whenever(packageManager.getApplicationLabel(any())).thenReturn(PACKAGE_NAME)

        context.setMockPackageManager(packageManager)
    }

    @Test
    fun loadMediaControls_mediaItemsAreUpdated() =
        testScope.runTest {
            val sortedMedia by collectLastValue(underTest.mediaItems)
            val instanceId1 = InstanceId.fakeInstanceId(123)
            val instanceId2 = InstanceId.fakeInstanceId(456)

            loadMediaControl(KEY, instanceId1)
            loadMediaControl(KEY_2, instanceId2)

            val firstMediaControl = sortedMedia?.get(0) as MediaCommonViewModel.MediaControl
            val secondMediaControl = sortedMedia?.get(1) as MediaCommonViewModel.MediaControl
            assertThat(firstMediaControl.instanceId).isEqualTo(instanceId2)
            assertThat(secondMediaControl.instanceId).isEqualTo(instanceId1)
        }

    @Test
    fun loadMediaControlsAndRecommendations_mediaItemsAreUpdated() =
        testScope.runTest {
            val sortedMedia by collectLastValue(underTest.mediaItems)
            kosmos.fakeFeatureFlagsClassic.set(Flags.MEDIA_RETAIN_RECOMMENDATIONS, false)
            val instanceId1 = InstanceId.fakeInstanceId(123)
            val instanceId2 = InstanceId.fakeInstanceId(456)

            loadMediaControl(KEY, instanceId1)
            loadMediaControl(KEY_2, instanceId2)
            loadMediaRecommendations()

            val firstMediaControl = sortedMedia?.get(0) as MediaCommonViewModel.MediaControl
            val secondMediaControl = sortedMedia?.get(1) as MediaCommonViewModel.MediaControl
            val recsCard = sortedMedia?.get(2) as MediaCommonViewModel.MediaRecommendations
            assertThat(firstMediaControl.instanceId).isEqualTo(instanceId2)
            assertThat(secondMediaControl.instanceId).isEqualTo(instanceId1)
            assertThat(recsCard.key).isEqualTo(KEY_MEDIA_SMARTSPACE)
        }

    private fun loadMediaControl(key: String, instanceId: InstanceId) {
        whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
        whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
        val mediaData =
            MediaData(
                userId = USER_ID,
                packageName = PACKAGE_NAME,
                notificationKey = key,
                instanceId = instanceId
            )

        mediaDataFilter.onMediaDataLoaded(key, key, mediaData)
    }

    private fun loadMediaRecommendations(key: String = KEY_MEDIA_SMARTSPACE) {
        mediaDataFilter.onSmartspaceMediaDataLoaded(key, smartspaceMediaData)
    }

    companion object {
        private const val USER_ID = 0
        private const val KEY = "key"
        private const val KEY_2 = "key2"
        private const val PACKAGE_NAME = "com.example.app"
        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
    }
}
+5 −0
Original line number Original line Diff line number Diff line
@@ -21,6 +21,7 @@ import android.media.MediaDescription
import android.media.session.MediaSession
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.media.session.PlaybackState
import android.service.notification.StatusBarNotification
import android.service.notification.StatusBarNotification
import com.android.internal.logging.InstanceId
import com.android.systemui.CoreStartable
import com.android.systemui.CoreStartable
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Application
@@ -215,6 +216,10 @@ constructor(
        return mediaDataProcessor.dismissMediaData(key, delay)
        return mediaDataProcessor.dismissMediaData(key, delay)
    }
    }


    fun removeMediaControl(instanceId: InstanceId, delay: Long) {
        mediaDataProcessor.dismissMediaData(instanceId, delay)
    }

    override fun dismissSmartspaceRecommendation(key: String, delay: Long) {
    override fun dismissSmartspaceRecommendation(key: String, delay: Long) {
        return mediaDataProcessor.dismissSmartspaceRecommendation(key, delay)
        return mediaDataProcessor.dismissSmartspaceRecommendation(key, delay)
    }
    }
+6 −2
Original line number Original line Diff line number Diff line
@@ -43,14 +43,18 @@ import com.android.systemui.plugins.ActivityStarter
import com.android.systemui.statusbar.NotificationLockscreenUserManager
import com.android.systemui.statusbar.NotificationLockscreenUserManager
import com.android.systemui.statusbar.policy.KeyguardStateController
import com.android.systemui.statusbar.policy.KeyguardStateController
import com.android.systemui.util.kotlin.pairwiseBy
import com.android.systemui.util.kotlin.pairwiseBy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.map


/** Encapsulates business logic for single media control. */
/** Encapsulates business logic for single media control. */
class MediaControlInteractor(
class MediaControlInteractor
@AssistedInject
constructor(
    @Application applicationContext: Context,
    @Application applicationContext: Context,
    private val instanceId: InstanceId,
    @Assisted private val instanceId: InstanceId,
    repository: MediaFilterRepository,
    repository: MediaFilterRepository,
    private val mediaDataProcessor: MediaDataProcessor,
    private val mediaDataProcessor: MediaDataProcessor,
    private val keyguardStateController: KeyguardStateController,
    private val keyguardStateController: KeyguardStateController,
+30 −0
Original line number Original line 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.media.controls.domain.pipeline.interactor.factory

import com.android.internal.logging.InstanceId
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.media.controls.domain.pipeline.interactor.MediaControlInteractor
import dagger.assisted.AssistedFactory

/** Factory to create [MediaControlInteractor] for each media control. */
@SysUISingleton
@AssistedFactory
interface MediaControlInteractorFactory {

    fun create(instanceId: InstanceId): MediaControlInteractor
}
+209 −0
Original line number Original line 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.media.controls.ui.viewmodel

import android.content.Context
import com.android.internal.logging.InstanceId
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Background
import com.android.systemui.media.controls.domain.pipeline.interactor.MediaCarouselInteractor
import com.android.systemui.media.controls.domain.pipeline.interactor.factory.MediaControlInteractorFactory
import com.android.systemui.media.controls.shared.model.MediaCommonModel
import com.android.systemui.media.controls.util.MediaFlags
import com.android.systemui.media.controls.util.MediaUiEventLogger
import com.android.systemui.statusbar.notification.collection.provider.OnReorderingAllowedListener
import com.android.systemui.statusbar.notification.collection.provider.VisualStabilityProvider
import com.android.systemui.util.Utils
import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow
import java.util.concurrent.Executor
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn

/** Models UI state and handles user inputs for media carousel */
@SysUISingleton
class MediaCarouselViewModel
@Inject
constructor(
    @Application private val applicationScope: CoroutineScope,
    @Application private val applicationContext: Context,
    @Background private val backgroundDispatcher: CoroutineDispatcher,
    @Background private val backgroundExecutor: Executor,
    private val visualStabilityProvider: VisualStabilityProvider,
    private val interactor: MediaCarouselInteractor,
    private val controlInteractorFactory: MediaControlInteractorFactory,
    private val recommendationsViewModel: MediaRecommendationsViewModel,
    private val logger: MediaUiEventLogger,
    private val mediaFlags: MediaFlags,
) {

    @OptIn(ExperimentalCoroutinesApi::class)
    val mediaItems: StateFlow<List<MediaCommonViewModel>> =
        conflatedCallbackFlow {
                val listener = OnReorderingAllowedListener { trySend(Unit) }
                visualStabilityProvider.addPersistentReorderingAllowedListener(listener)
                trySend(Unit)
                awaitClose { visualStabilityProvider.removeReorderingAllowedListener(listener) }
            }
            .flatMapLatest {
                interactor.sortedMedia.map { sortedItems ->
                    buildList {
                        val reorderAllowed = isReorderingAllowed()
                        sortedItems.forEach { commonModel ->
                            if (!reorderAllowed || !modelsPendingRemoval.contains(commonModel)) {
                                when (commonModel) {
                                    is MediaCommonModel.MediaControl ->
                                        add(toViewModel(commonModel))
                                    is MediaCommonModel.MediaRecommendations ->
                                        add(toViewModel(commonModel))
                                }
                            }
                        }
                        if (reorderAllowed) {
                            modelsPendingRemoval.clear()
                        }
                    }
                }
            }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = emptyList(),
            )

    private val mediaControlByInstanceId =
        mutableMapOf<InstanceId, MediaCommonViewModel.MediaControl>()

    private var mediaRecs: MediaCommonViewModel.MediaRecommendations? = null

    private var modelsPendingRemoval: MutableSet<MediaCommonModel> = mutableSetOf()

    fun onSwipeToDismiss() {
        logger.logSwipeDismiss()
        interactor.onSwipeToDismiss()
    }

    private fun toViewModel(
        commonModel: MediaCommonModel.MediaControl
    ): MediaCommonViewModel.MediaControl {
        val instanceId = commonModel.mediaLoadedModel.instanceId
        return mediaControlByInstanceId[instanceId]?.copy(
            immediatelyUpdateUi = commonModel.mediaLoadedModel.immediatelyUpdateUi
        )
            ?: MediaCommonViewModel.MediaControl(
                    instanceId = instanceId,
                    immediatelyUpdateUi = commonModel.mediaLoadedModel.immediatelyUpdateUi,
                    controlViewModel = createMediaControlViewModel(instanceId),
                    onAdded = { onMediaControlAddedOrUpdated(it, commonModel) },
                    onRemoved = { _, _ ->
                        interactor.removeMediaControl(instanceId, delay = 0L)
                        mediaControlByInstanceId.remove(instanceId)
                    },
                    onUpdated = { onMediaControlAddedOrUpdated(it, commonModel) },
                )
                .also { mediaControlByInstanceId[instanceId] = it }
    }

    private fun createMediaControlViewModel(instanceId: InstanceId): MediaControlViewModel {
        return MediaControlViewModel(
            applicationContext = applicationContext,
            backgroundDispatcher = backgroundDispatcher,
            backgroundExecutor = backgroundExecutor,
            interactor = controlInteractorFactory.create(instanceId),
            logger = logger,
        )
    }

    private fun toViewModel(
        commonModel: MediaCommonModel.MediaRecommendations
    ): MediaCommonViewModel.MediaRecommendations {
        return mediaRecs?.copy(
            key = commonModel.recsLoadingModel.key,
            loadingEnabled =
                interactor.isRecommendationActive() || mediaFlags.isPersistentSsCardEnabled()
        )
            ?: MediaCommonViewModel.MediaRecommendations(
                    key = commonModel.recsLoadingModel.key,
                    loadingEnabled =
                        interactor.isRecommendationActive() ||
                            mediaFlags.isPersistentSsCardEnabled(),
                    recsViewModel = recommendationsViewModel,
                    onAdded = { commonViewModel ->
                        onMediaRecommendationAddedOrUpdated(commonViewModel)
                    },
                    onRemoved = { _, immediatelyRemove ->
                        onMediaRecommendationRemoved(commonModel, immediatelyRemove)
                    },
                    onUpdated = { commonViewModel ->
                        onMediaRecommendationAddedOrUpdated(commonViewModel)
                    },
                )
                .also { mediaRecs = it }
    }

    private fun onMediaControlAddedOrUpdated(
        commonViewModel: MediaCommonViewModel,
        commonModel: MediaCommonModel.MediaControl
    ) {
        // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_RECEIVED)
        if (commonModel.canBeRemoved && !Utils.useMediaResumption(applicationContext)) {
            // This media control is due for removal as it is now paused + timed out, and resumption
            // setting is off.
            if (isReorderingAllowed()) {
                commonViewModel.onRemoved(commonViewModel, true)
            } else {
                modelsPendingRemoval.add(commonModel)
            }
        } else {
            modelsPendingRemoval.remove(commonModel)
        }
    }

    private fun onMediaRecommendationAddedOrUpdated(commonViewModel: MediaCommonViewModel) {
        if (!interactor.isRecommendationActive()) {
            if (!mediaFlags.isPersistentSsCardEnabled()) {
                commonViewModel.onRemoved(commonViewModel, true)
            }
        } else {
            // TODO (b/330897926) log smartspace card reported (SMARTSPACE_CARD_RECEIVED)
        }
    }

    private fun onMediaRecommendationRemoved(
        commonModel: MediaCommonModel.MediaRecommendations,
        immediatelyRemove: Boolean
    ) {
        if (immediatelyRemove || isReorderingAllowed()) {
            interactor.dismissSmartspaceRecommendation(commonModel.recsLoadingModel.key, 0L)
            // TODO if not immediate remove update host visibility
        } else {
            modelsPendingRemoval.add(commonModel)
        }
    }

    private fun isReorderingAllowed(): Boolean {
        return visualStabilityProvider.isReorderingAllowed
    }
}
Loading