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

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

Add loading states for media changes.

Flag: ACONFIG media_controls_refactor DISABLED
Bug: 326281896
Test: atest SystemUiRoboTests:MediaCarouselInteractorTest
Change-Id: I09477546282bc297b317f695e81871404b7dd417
parent b2818816
Loading
Loading
Loading
Loading
+33 −0
Original line number Diff line number Diff line
@@ -26,7 +26,9 @@ import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.MediaTestHelper
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.controls.shared.model.MediaDataLoadingModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.media.controls.shared.model.SmartspaceMediaLoadingModel
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
@@ -144,6 +146,37 @@ class MediaFilterRepositoryTest : SysuiTestCase() {
            assertThat(smartspaceMediaData?.isActive).isFalse()
        }

    @Test
    fun addMediaDataLoadingState() =
        testScope.runTest {
            val mediaDataLoadedStates by collectLastValue(underTest.mediaDataLoadedStates)
            val instanceId = InstanceId.fakeInstanceId(123)
            val mediaLoadedStates = mutableListOf(MediaDataLoadingModel.Loaded(instanceId))

            underTest.addMediaDataLoadingState(MediaDataLoadingModel.Loaded(instanceId))

            assertThat(mediaDataLoadedStates).isEqualTo(mediaLoadedStates)

            mediaLoadedStates.remove(MediaDataLoadingModel.Loaded(instanceId))

            underTest.addMediaDataLoadingState(MediaDataLoadingModel.Removed(instanceId))

            assertThat(mediaDataLoadedStates).isEqualTo(mediaLoadedStates)
        }

    @Test
    fun setRecommendationsLoadingState() =
        testScope.runTest {
            val recommendationsLoadingState by
                collectLastValue(underTest.recommendationsLoadingState)
            val recommendationsLoadingModel =
                SmartspaceMediaLoadingModel.Loaded(KEY_MEDIA_SMARTSPACE)

            underTest.setRecommedationsLoadingState(recommendationsLoadingModel)

            assertThat(recommendationsLoadingState).isEqualTo(recommendationsLoadingModel)
        }

    companion object {
        private const val KEY = "KEY"
        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
+92 −3
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import android.R
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
@@ -28,13 +29,20 @@ import com.android.systemui.kosmos.testScope
import com.android.systemui.media.controls.MediaTestHelper
import com.android.systemui.media.controls.data.repository.MediaFilterRepository
import com.android.systemui.media.controls.data.repository.mediaFilterRepository
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.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.MediaDataLoadingModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.media.controls.shared.model.SmartspaceMediaLoadingModel
import com.android.systemui.statusbar.notificationLockscreenUserManager
import com.android.systemui.testKosmos
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

@@ -45,9 +53,17 @@ class MediaCarouselInteractorTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope

    private val mediaDataFilter: MediaDataFilterImpl = kosmos.mediaDataFilter
    private val notificationLockscreenUserManager = kosmos.notificationLockscreenUserManager
    private val mediaFilterRepository: MediaFilterRepository = kosmos.mediaFilterRepository

    private val underTest: MediaCarouselInteractor = kosmos.mediaCarouselInteractor

    @Before
    fun setUp() {
        underTest.start()
    }

    @Test
    fun addUserMediaEntry_activeThenInactivate() =
        testScope.runTest {
@@ -56,7 +72,7 @@ class MediaCarouselInteractorTest : SysuiTestCase() {
            val hasActiveMedia by collectLastValue(underTest.hasActiveMedia)
            val hasAnyMedia by collectLastValue(underTest.hasAnyMedia)

            val userMedia = MediaData().copy(active = true)
            val userMedia = MediaData(active = true)

            mediaFilterRepository.addSelectedUserMediaEntry(userMedia)

@@ -79,7 +95,7 @@ class MediaCarouselInteractorTest : SysuiTestCase() {
            val hasActiveMedia by collectLastValue(underTest.hasActiveMedia)
            val hasAnyMedia by collectLastValue(underTest.hasAnyMedia)

            val userMedia = MediaData().copy(active = false)
            val userMedia = MediaData(active = false)
            val instanceId = userMedia.instanceId

            mediaFilterRepository.addSelectedUserMediaEntry(userMedia)
@@ -112,7 +128,7 @@ class MediaCarouselInteractorTest : SysuiTestCase() {
                    isActive = true,
                    recommendations = MediaTestHelper.getValidRecommendationList(icon),
                )
            val userMedia = MediaData().copy(active = false)
            val userMedia = MediaData(active = false)

            mediaFilterRepository.setRecommendation(userMediaRecommendation)

@@ -199,7 +215,80 @@ class MediaCarouselInteractorTest : SysuiTestCase() {
    fun hasActiveMediaOrRecommendation_nothingSet_returnsFalse() =
        testScope.runTest { assertThat(underTest.hasActiveMediaOrRecommendation.value).isFalse() }

    @Test
    fun onMediaDataUpdated_updatesLoadingState() =
        testScope.runTest {
            whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
            whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
            val mediaDataLoadedStates by collectLastValue(underTest.mediaDataLoadedStates)
            val instanceId = InstanceId.fakeInstanceId(123)
            val mediaLoadedStates: MutableList<MediaDataLoadingModel> = mutableListOf()

            mediaLoadedStates.add(MediaDataLoadingModel.Loaded(instanceId))
            mediaDataFilter.onMediaDataLoaded(
                KEY,
                KEY,
                MediaData(userId = USER_ID, instanceId = instanceId)
            )

            assertThat(mediaDataLoadedStates).isEqualTo(mediaLoadedStates)

            val newInstanceId = InstanceId.fakeInstanceId(321)

            mediaLoadedStates.add(MediaDataLoadingModel.Loaded(newInstanceId))
            mediaDataFilter.onMediaDataLoaded(
                KEY_2,
                KEY_2,
                MediaData(userId = USER_ID, instanceId = newInstanceId)
            )

            assertThat(mediaDataLoadedStates).isEqualTo(mediaLoadedStates)

            mediaLoadedStates.remove(MediaDataLoadingModel.Loaded(instanceId))

            mediaDataFilter.onMediaDataRemoved(KEY)

            assertThat(mediaDataLoadedStates).isEqualTo(mediaLoadedStates)

            mediaLoadedStates.remove(MediaDataLoadingModel.Loaded(newInstanceId))

            mediaDataFilter.onMediaDataRemoved(KEY_2)

            assertThat(mediaDataLoadedStates).isEqualTo(mediaLoadedStates)
        }

    @Test
    fun onMediaRecommendationsUpdated_updatesLoadingState() =
        testScope.runTest {
            whenever(notificationLockscreenUserManager.isCurrentProfile(USER_ID)).thenReturn(true)
            whenever(notificationLockscreenUserManager.isProfileAvailable(USER_ID)).thenReturn(true)
            val recommendationsLoadingState by
                collectLastValue(underTest.recommendationsLoadingState)
            val icon = Icon.createWithResource(context, R.drawable.ic_media_play)
            val mediaRecommendations =
                SmartspaceMediaData(
                    targetId = KEY_MEDIA_SMARTSPACE,
                    isActive = true,
                    recommendations = MediaTestHelper.getValidRecommendationList(icon),
                )
            var recommendationsLoadingModel: SmartspaceMediaLoadingModel =
                SmartspaceMediaLoadingModel.Loaded(KEY_MEDIA_SMARTSPACE, isPrioritized = true)

            mediaDataFilter.onSmartspaceMediaDataLoaded(KEY_MEDIA_SMARTSPACE, mediaRecommendations)

            assertThat(recommendationsLoadingState).isEqualTo(recommendationsLoadingModel)

            recommendationsLoadingModel = SmartspaceMediaLoadingModel.Removed(KEY_MEDIA_SMARTSPACE)

            mediaDataFilter.onSmartspaceMediaDataRemoved(KEY_MEDIA_SMARTSPACE)

            assertThat(recommendationsLoadingState).isEqualTo(recommendationsLoadingModel)
        }

    companion object {
        private const val KEY = "key"
        private const val KEY_2 = "key2"
        private const val USER_ID = 0
        private const val KEY_MEDIA_SMARTSPACE = "MEDIA_SMARTSPACE_ID"
    }
}
+33 −0
Original line number Diff line number Diff line
@@ -19,7 +19,9 @@ package com.android.systemui.media.controls.data.repository
import com.android.internal.logging.InstanceId
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.controls.shared.model.MediaDataLoadingModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.media.controls.shared.model.SmartspaceMediaLoadingModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -46,6 +48,16 @@ class MediaFilterRepository @Inject constructor() {
        MutableStateFlow(LinkedHashMap())
    val allUserEntries: StateFlow<Map<String, MediaData>> = _allUserEntries.asStateFlow()

    private val _mediaDataLoadedStates: MutableStateFlow<List<MediaDataLoadingModel>> =
        MutableStateFlow(mutableListOf())
    val mediaDataLoadedStates: StateFlow<List<MediaDataLoadingModel>> =
        _mediaDataLoadedStates.asStateFlow()

    private val _recommendationsLoadingState: MutableStateFlow<SmartspaceMediaLoadingModel> =
        MutableStateFlow(SmartspaceMediaLoadingModel.Unknown)
    val recommendationsLoadingState: StateFlow<SmartspaceMediaLoadingModel> =
        _recommendationsLoadingState.asStateFlow()

    fun addMediaEntry(key: String, data: MediaData) {
        val entries = LinkedHashMap<String, MediaData>(_allUserEntries.value)
        entries[key] = data
@@ -110,4 +122,25 @@ class MediaFilterRepository @Inject constructor() {
    fun setReactivatedId(instanceId: InstanceId?) {
        _reactivatedId.value = instanceId
    }

    fun addMediaDataLoadingState(mediaDataLoadingModel: MediaDataLoadingModel) {
        // Filter out previous loading state that has same [InstanceId].
        val loadedStates =
            _mediaDataLoadedStates.value.filter { loadedModel ->
                loadedModel !is MediaDataLoadingModel.Loaded ||
                    !loadedModel.equalInstanceIds(mediaDataLoadingModel)
            }

        _mediaDataLoadedStates.value =
            loadedStates +
                if (mediaDataLoadingModel is MediaDataLoadingModel.Loaded) {
                    listOf(mediaDataLoadingModel)
                } else {
                    emptyList()
                }
    }

    fun setRecommedationsLoadingState(smartspaceMediaLoadingModel: SmartspaceMediaLoadingModel) {
        _recommendationsLoadingState.value = smartspaceMediaLoadingModel
    }
}
+38 −79
Original line number Diff line number Diff line
@@ -28,7 +28,9 @@ import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.media.controls.data.repository.MediaFilterRepository
import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_RESUME
import com.android.systemui.media.controls.shared.model.MediaData
import com.android.systemui.media.controls.shared.model.MediaDataLoadingModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
import com.android.systemui.media.controls.shared.model.SmartspaceMediaLoadingModel
import com.android.systemui.media.controls.util.MediaFlags
import com.android.systemui.media.controls.util.MediaUiEventLogger
import com.android.systemui.settings.UserTracker
@@ -67,9 +69,6 @@ constructor(
    private val mediaFlags: MediaFlags,
    private val mediaFilterRepository: MediaFilterRepository,
) : MediaDataManager.Listener {
    private val _listeners: MutableSet<Listener> = mutableSetOf()
    val listeners: Set<Listener>
        get() = _listeners.toSet()
    lateinit var mediaDataManager: MediaDataManager

    // Ensure the field (and associated reference) isn't removed during optimization.
@@ -111,8 +110,9 @@ constructor(

        mediaFilterRepository.addSelectedUserMediaEntry(data)

        // Notify listeners
        listeners.forEach { it.onMediaDataLoaded(data.instanceId) }
        mediaFilterRepository.addMediaDataLoadingState(
            MediaDataLoadingModel.Loaded(data.instanceId)
        )
    }

    override fun onSmartspaceMediaDataLoaded(
@@ -159,7 +159,7 @@ constructor(
            // reactivate.
            if (shouldReactivate) {
                val lastActiveId = sorted.lastKey() // most recently active id
                // Notify listeners to consider this media active
                // Update loading state to consider this media active
                Log.d(TAG, "reactivating $lastActiveId instead of smartspace")
                mediaFilterRepository.setReactivatedId(lastActiveId)
                val mediaData = sorted[lastActiveId]!!.copy(active = true)
@@ -168,16 +168,10 @@ constructor(
                    mediaData.packageName,
                    mediaData.instanceId
                )
                listeners.forEach {
                    it.onMediaDataLoaded(
                        lastActiveId,
                        receivedSmartspaceCardLatency =
                            (systemClock.currentTimeMillis() - data.headphoneConnectionTimeMillis)
                                .toInt(),
                        isSsReactivated = true
                mediaFilterRepository.addMediaDataLoadingState(
                    MediaDataLoadingModel.Loaded(lastActiveId)
                )
            }
            }
        } else if (data.isActive) {
            // Mark to prioritize Smartspace card if no recent media.
            shouldPrioritizeMutable = true
@@ -192,15 +186,18 @@ constructor(
            smartspaceMediaData.packageName,
            smartspaceMediaData.instanceId
        )
        listeners.forEach { it.onSmartspaceMediaDataLoaded(key, shouldPrioritizeMutable) }
        mediaFilterRepository.setRecommedationsLoadingState(
            SmartspaceMediaLoadingModel.Loaded(key, shouldPrioritizeMutable)
        )
    }

    override fun onMediaDataRemoved(key: String) {
        mediaFilterRepository.removeMediaEntry(key)?.let { mediaData ->
            val instanceId = mediaData.instanceId
            mediaFilterRepository.removeSelectedUserMediaEntry(instanceId)?.let {
                // Only notify listeners if something actually changed
                listeners.forEach { it.onMediaDataRemoved(instanceId) }
                mediaFilterRepository.addMediaDataLoadingState(
                    MediaDataLoadingModel.Removed(instanceId)
                )
            }
        }
    }
@@ -210,11 +207,11 @@ constructor(
        mediaFilterRepository.reactivatedId.value?.let { lastActiveId ->
            mediaFilterRepository.setReactivatedId(null)
            Log.d(TAG, "expiring reactivated key $lastActiveId")
            // Notify listeners to update with actual active value
            // Update loading state with actual active value
            mediaFilterRepository.selectedUserEntries.value[lastActiveId]?.let {
                listeners.forEach { listener ->
                    listener.onMediaDataLoaded(lastActiveId, immediately)
                }
                mediaFilterRepository.addMediaDataLoadingState(
                    MediaDataLoadingModel.Loaded(lastActiveId, immediately)
                )
            }
        }

@@ -227,7 +224,9 @@ constructor(
                )
            )
        }
        listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
        mediaFilterRepository.setRecommedationsLoadingState(
            SmartspaceMediaLoadingModel.Removed(key, immediately)
        )
    }

    @VisibleForTesting
@@ -238,29 +237,37 @@ constructor(
                // Only remove media when the profile is unavailable.
                if (DEBUG) Log.d(TAG, "Removing $key after profile change")
                mediaFilterRepository.removeSelectedUserMediaEntry(data.instanceId, data)
                listeners.forEach { listener -> listener.onMediaDataRemoved(data.instanceId) }
                mediaFilterRepository.addMediaDataLoadingState(
                    MediaDataLoadingModel.Removed(data.instanceId)
                )
            }
        }
    }

    @VisibleForTesting
    internal fun handleUserSwitched() {
        // If the user changes, remove all current MediaData objects and inform listeners
        val listenersCopy = listeners
        // If the user changes, remove all current MediaData objects.
        val keyCopy = mediaFilterRepository.selectedUserEntries.value.keys.toMutableList()
        // Clear the list first, to make sure callbacks from listeners if we have any entries
        // are up to date
        // Clear the list first and update loading state to remove media from UI.
        mediaFilterRepository.clearSelectedUserMedia()
        keyCopy.forEach { instanceId ->
            if (DEBUG) Log.d(TAG, "Removing $instanceId after user change")
            listenersCopy.forEach { listener -> listener.onMediaDataRemoved(instanceId) }
            mediaFilterRepository.addMediaDataLoadingState(
                MediaDataLoadingModel.Removed(instanceId)
            )
        }

        mediaFilterRepository.allUserEntries.value.forEach { (key, data) ->
            if (lockscreenUserManager.isCurrentProfile(data.userId)) {
                if (DEBUG) Log.d(TAG, "Re-adding $key after user change")
                if (DEBUG)
                    Log.d(
                        TAG,
                        "Re-adding $key with instanceId=${data.instanceId} after user change"
                    )
                mediaFilterRepository.addSelectedUserMediaEntry(data)
                listenersCopy.forEach { listener -> listener.onMediaDataLoaded(data.instanceId) }
                mediaFilterRepository.addMediaDataLoadingState(
                    MediaDataLoadingModel.Loaded(data.instanceId)
                )
            }
        }
    }
@@ -310,12 +317,6 @@ constructor(
        }
    }

    /** Add a listener for filtered [MediaData] changes */
    fun addListener(listener: Listener) = _listeners.add(listener)

    /** Remove a listener that was registered with addListener */
    fun removeListener(listener: Listener) = _listeners.remove(listener)

    /**
     * Return the time since last active for the most-recent media.
     *
@@ -335,48 +336,6 @@ constructor(
        return sortedEntries[lastActiveInstanceId]?.let { now - it.lastActive } ?: Long.MAX_VALUE
    }

    interface Listener {
        /**
         * Called whenever there's new MediaData Loaded for the consumption in views.
         *
         * @param immediately indicates should apply the UI changes immediately, otherwise wait
         *   until the next refresh-round before UI becomes visible. True by default to take in
         *   place immediately.
         * @param receivedSmartspaceCardLatency is the latency between headphone connects and sysUI
         *   displays Smartspace media targets. Will be 0 if the data is not activated by Smartspace
         *   signal.
         * @param isSsReactivated indicates resume media card is reactivated by Smartspace
         *   recommendation signal
         */
        fun onMediaDataLoaded(
            instanceId: InstanceId,
            immediately: Boolean = true,
            receivedSmartspaceCardLatency: Int = 0,
            isSsReactivated: Boolean = false,
        )

        /**
         * Called whenever there's new Smartspace media data loaded.
         *
         * @param shouldPrioritize indicates the sorting priority of the Smartspace card. If true,
         *   it will be prioritized as the first card. Otherwise, it will show up as the last card
         *   as default.
         */
        fun onSmartspaceMediaDataLoaded(key: String, shouldPrioritize: Boolean = false)

        /** Called whenever a previously existing Media notification was removed. */
        fun onMediaDataRemoved(instanceId: InstanceId)

        /**
         * Called whenever a previously existing Smartspace media data was removed.
         *
         * @param immediately indicates should apply the UI changes immediately, otherwise wait
         *   until the next refresh-round before UI becomes visible. True by default to take in
         *   place immediately.
         */
        fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean = true)
    }

    companion object {
        /**
         * Maximum age of a media control to re-activate on smartspace signal. If there is no media
+11 −0
Original line number Diff line number Diff line
@@ -34,11 +34,14 @@ import com.android.systemui.media.controls.domain.pipeline.MediaDeviceManager
import com.android.systemui.media.controls.domain.pipeline.MediaSessionBasedFilter
import com.android.systemui.media.controls.domain.pipeline.MediaTimeoutListener
import com.android.systemui.media.controls.domain.resume.MediaResumeListener
import com.android.systemui.media.controls.shared.model.MediaDataLoadingModel
import com.android.systemui.media.controls.shared.model.SmartspaceMediaLoadingModel
import com.android.systemui.media.controls.util.MediaFlags
import java.io.PrintWriter
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -109,6 +112,14 @@ constructor(
            .distinctUntilChanged()
            .stateIn(applicationScope, SharingStarted.WhileSubscribed(), false)

    /** The most recent list of loaded media controls. */
    val mediaDataLoadedStates: Flow<List<MediaDataLoadingModel>> =
        mediaFilterRepository.mediaDataLoadedStates

    /** The most recent change to loaded media recommendations. */
    val recommendationsLoadingState: Flow<SmartspaceMediaLoadingModel> =
        mediaFilterRepository.recommendationsLoadingState

    override fun start() {
        if (!mediaFlags.isMediaControlsRefactorEnabled()) {
            return
Loading