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

Commit 328fca2c authored by Michael Mikhail's avatar Michael Mikhail Committed by Android (Google) Code Review
Browse files

Merge "Add loading states for media changes." into main

parents 89dd306e cdb6ccb8
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