Loading packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryTest.kt +33 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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" Loading packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt +92 −3 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 { Loading @@ -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) Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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" } } packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaFilterRepository.kt +33 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 } } packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImpl.kt +38 −79 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. Loading Loading @@ -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( Loading Loading @@ -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) Loading @@ -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 Loading @@ -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) ) } } } Loading @@ -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) ) } } Loading @@ -227,7 +224,9 @@ constructor( ) ) } listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) } mediaFilterRepository.setRecommedationsLoadingState( SmartspaceMediaLoadingModel.Removed(key, immediately) ) } @VisibleForTesting Loading @@ -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) ) } } } Loading Loading @@ -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. * Loading @@ -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 Loading packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractor.kt +11 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/data/repository/MediaFilterRepositoryTest.kt +33 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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" Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/interactor/MediaCarouselInteractorTest.kt +92 −3 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 { Loading @@ -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) Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -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" } }
packages/SystemUI/src/com/android/systemui/media/controls/data/repository/MediaFilterRepository.kt +33 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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 } }
packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataFilterImpl.kt +38 −79 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. Loading Loading @@ -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( Loading Loading @@ -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) Loading @@ -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 Loading @@ -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) ) } } } Loading @@ -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) ) } } Loading @@ -227,7 +224,9 @@ constructor( ) ) } listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) } mediaFilterRepository.setRecommedationsLoadingState( SmartspaceMediaLoadingModel.Removed(key, immediately) ) } @VisibleForTesting Loading @@ -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) ) } } } Loading Loading @@ -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. * Loading @@ -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 Loading
packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/interactor/MediaCarouselInteractor.kt +11 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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