Merge "Show recently active media on smartspace signal" into sc-dev
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
index 0ed96ee..6ef29d6 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
@@ -104,7 +104,12 @@
/**
* Set from the notification and used as fallback when PlaybackState cannot be determined
*/
- val isClearable: Boolean = true
+ val isClearable: Boolean = true,
+
+ /**
+ * Timestamp when this player was last active.
+ */
+ var lastActive: Long = 0L
)
/** State of a media action. */
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataFilter.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataFilter.kt
index a274eab..5f73ae0 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaDataFilter.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataFilter.kt
@@ -17,6 +17,7 @@
package com.android.systemui.media
import android.app.smartspace.SmartspaceTarget
+import android.os.SystemProperties
import android.util.Log
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.broadcast.BroadcastDispatcher
@@ -24,14 +25,23 @@
import com.android.systemui.settings.CurrentUserTracker
import com.android.systemui.statusbar.NotificationLockscreenUserManager
import java.util.concurrent.Executor
+import java.util.concurrent.TimeUnit
import javax.inject.Inject
private const val TAG = "MediaDataFilter"
private const val DEBUG = true
/**
+ * Maximum age of a media control to re-activate on smartspace signal. If there is no media control
+ * available within this time window, smartspace recommendations will be shown instead.
+ */
+private val SMARTSPACE_MAX_AGE = SystemProperties
+ .getLong("debug.sysui.smartspace_max_age", TimeUnit.HOURS.toMillis(3))
+
+/**
* Filters data updates from [MediaDataCombineLatest] based on the current user ID, and handles user
- * switches (removing entries for the previous user, adding back entries for the current user)
+ * switches (removing entries for the previous user, adding back entries for the current user). Also
+ * filters out smartspace updates in favor of local recent media, when avaialble.
*
* This is added at the end of the pipeline since we may still need to handle callbacks from
* background users (e.g. timeouts).
@@ -52,6 +62,7 @@
// The filtered userEntries, which will be a subset of all userEntries in MediaDataManager
private val userEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
private var hasSmartspace: Boolean = false
+ private var reactivatedKey: String? = null
init {
userTracker = object : CurrentUserTracker(broadcastDispatcher) {
@@ -86,6 +97,30 @@
override fun onSmartspaceMediaDataLoaded(key: String, data: SmartspaceTarget) {
hasSmartspace = true
+
+ // Before forwarding the smartspace target, first check if we have recently inactive media
+ val now = System.currentTimeMillis()
+ val sorted = userEntries.toSortedMap(compareBy {
+ userEntries.get(it)?.lastActive ?: -1
+ })
+ if (sorted.size > 0) {
+ val lastActiveKey = sorted.lastKey() // most recently active
+ val timeSinceActive = sorted.get(lastActiveKey)?.let {
+ now - it.lastActive
+ } ?: Long.MAX_VALUE
+ if (timeSinceActive < SMARTSPACE_MAX_AGE) {
+ // Notify listeners to consider this media active
+ Log.d(TAG, "reactivating $lastActiveKey instead of smartspace")
+ reactivatedKey = lastActiveKey
+ val mediaData = sorted.get(lastActiveKey)!!.copy(active = true)
+ listeners.forEach {
+ it.onMediaDataLoaded(lastActiveKey, lastActiveKey, mediaData)
+ }
+ return
+ }
+ }
+
+ // If no recent media, continue with smartspace update
listeners.forEach { it.onSmartspaceMediaDataLoaded(key, data) }
}
@@ -101,6 +136,21 @@
override fun onSmartspaceMediaDataRemoved(key: String) {
hasSmartspace = false
+
+ // First check if we had reactivated media instead of forwarding smartspace
+ reactivatedKey?.let {
+ val lastActiveKey = it
+ reactivatedKey = null
+ Log.d(TAG, "expiring reactivated key $lastActiveKey")
+ // Notify listeners to update with actual active value
+ userEntries.get(lastActiveKey)?.let { mediaData ->
+ listeners.forEach {
+ it.onMediaDataLoaded(lastActiveKey, lastActiveKey, mediaData)
+ }
+ }
+ return
+ }
+
listeners.forEach { it.onSmartspaceMediaDataRemoved(key) }
}
@@ -137,7 +187,8 @@
if (DEBUG) Log.d(TAG, "Media carousel swiped away")
val mediaKeys = userEntries.keys.toSet()
mediaKeys.forEach {
- mediaDataManager.setTimedOut(it, timedOut = true)
+ // Force updates to listeners, needed for re-activated card
+ mediaDataManager.setTimedOut(it, timedOut = true, forceUpdate = true)
}
if (hasSmartspace) {
mediaDataManager.dismissSmartspaceRecommendation()
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
index 5ba04a0..09ebeed3 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
@@ -394,9 +394,9 @@
* This will make the player not active anymore, hiding it from QQS and Keyguard.
* @see MediaData.active
*/
- internal fun setTimedOut(token: String, timedOut: Boolean) {
+ internal fun setTimedOut(token: String, timedOut: Boolean, forceUpdate: Boolean = false) {
mediaEntries[token]?.let {
- if (it.active == !timedOut) {
+ if (it.active == !timedOut && !forceUpdate) {
return
}
it.active = !timedOut
@@ -470,12 +470,13 @@
}
val mediaAction = getResumeMediaAction(resumeAction)
+ val lastActive = System.currentTimeMillis()
foregroundExecutor.execute {
onMediaDataLoaded(packageName, null, MediaData(userId, true, bgColor, appName,
null, desc.subtitle, desc.title, artworkIcon, listOf(mediaAction), listOf(0),
packageName, token, appIntent, device = null, active = false,
resumeAction = resumeAction, resumption = true, notificationKey = packageName,
- hasCheckedForResume = true))
+ hasCheckedForResume = true, lastActive = lastActive))
}
}
@@ -588,7 +589,7 @@
val isLocalSession = mediaController.playbackInfo?.playbackType ==
MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL ?: true
val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
-
+ val lastActive = System.currentTimeMillis()
foregroundExecutor.execute {
val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
@@ -598,7 +599,8 @@
actionsToShowCollapsed, sbn.packageName, token, notif.contentIntent, null,
active, resumeAction = resumeAction, isLocalSession = isLocalSession,
notificationKey = key, hasCheckedForResume = hasCheckedForResume,
- isPlaying = isPlaying, isClearable = sbn.isClearable()))
+ isPlaying = isPlaying, isClearable = sbn.isClearable(),
+ lastActive = lastActive))
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
index 609b847..4a487be 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
@@ -74,7 +74,7 @@
mMediaData = new MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null,
new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null, true, null, true,
- false, KEY, false, false, false);
+ false, KEY, false, false, false, 0L);
mDeviceData = new MediaDeviceData(true, null, DEVICE_NAME);
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt
index 36b6527..a9d256b 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt
@@ -16,6 +16,7 @@
package com.android.systemui.media
+import android.app.smartspace.SmartspaceTarget
import android.graphics.Color
import androidx.test.filters.SmallTest
import android.testing.AndroidTestingRunner
@@ -47,6 +48,7 @@
private const val ARTIST = "ARTIST"
private const val TITLE = "TITLE"
private const val DEVICE_NAME = "DEVICE_NAME"
+private const val SMARTSPACE_KEY = "SMARTSPACE_KEY"
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
private fun <T> any(): T = Mockito.any()
@@ -68,6 +70,8 @@
private lateinit var lockscreenUserManager: NotificationLockscreenUserManager
@Mock
private lateinit var executor: Executor
+ @Mock
+ private lateinit var smartspaceData: SmartspaceTarget
private lateinit var mediaDataFilter: MediaDataFilter
private lateinit var dataMain: MediaData
@@ -91,6 +95,8 @@
dataGuest = MediaData(USER_GUEST, true, BG_COLOR, APP, null, ARTIST, TITLE, null,
emptyList(), emptyList(), PACKAGE, null, null, device, true, null)
+
+ `when`(smartspaceData.smartspaceTargetId).thenReturn(SMARTSPACE_KEY)
}
private fun setUser(id: Int) {
@@ -212,6 +218,61 @@
mediaDataFilter.onMediaDataLoaded(KEY, null, dataMain)
mediaDataFilter.onSwipeToDismiss()
- verify(mediaDataManager).setTimedOut(eq(KEY), eq(true))
+ verify(mediaDataManager).setTimedOut(eq(KEY), eq(true), eq(true))
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_noMedia_usesSmartspace() {
+ mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+ verify(listener).onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData))
+ assertThat(mediaDataFilter.hasActiveMedia()).isTrue()
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_noRecentMedia_usesSmartspace() {
+ val dataOld = dataMain.copy(active = false, lastActive = 0L)
+ mediaDataFilter.onMediaDataLoaded(KEY, null, dataOld)
+ mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+ verify(listener).onSmartspaceMediaDataLoaded(eq(SMARTSPACE_KEY), eq(smartspaceData))
+ assertThat(mediaDataFilter.hasActiveMedia()).isTrue()
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataLoaded_hasRecentMedia_usesMedia() {
+ // WHEN we have media that was recently played, but not currently active
+ val dataCurrent = dataMain.copy(active = false, lastActive = System.currentTimeMillis())
+ mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), eq(dataCurrent))
+
+ // AND we get a smartspace signal
+ mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+ // THEN we should tell listeners to treat the media as active instead
+ val dataCurrentAndActive = dataCurrent.copy(active = true)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY), eq(dataCurrentAndActive))
+ assertThat(mediaDataFilter.hasActiveMedia()).isTrue()
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataRemoved_usedSmartspace_clearsMedia() {
+ mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+ mediaDataFilter.onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+
+ verify(listener).onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+ assertThat(mediaDataFilter.hasActiveMedia()).isFalse()
+ }
+
+ @Test
+ fun testOnSmartspaceMediaDataRemoved_usedMedia_clearsMedia() {
+ val dataCurrent = dataMain.copy(active = false, lastActive = System.currentTimeMillis())
+ mediaDataFilter.onMediaDataLoaded(KEY, null, dataCurrent)
+ mediaDataFilter.onSmartspaceMediaDataLoaded(SMARTSPACE_KEY, smartspaceData)
+
+ mediaDataFilter.onSmartspaceMediaDataRemoved(SMARTSPACE_KEY)
+
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY), eq(dataCurrent))
+ assertThat(mediaDataFilter.hasActiveMedia()).isFalse()
}
}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
index 96eb4b0..7486612 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
@@ -280,11 +280,12 @@
@Test
fun testAddResumptionControls() {
- // WHEN resumption controls are added`
+ // WHEN resumption controls are added
val desc = MediaDescription.Builder().run {
setTitle(SESSION_TITLE)
build()
}
+ val currentTimeMillis = System.currentTimeMillis()
mediaDataManager.addResumptionControls(USER_ID, desc, Runnable {}, session.sessionToken,
APP_NAME, pendingIntent, PACKAGE_NAME)
assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
@@ -296,6 +297,7 @@
assertThat(data.song).isEqualTo(SESSION_TITLE)
assertThat(data.app).isEqualTo(APP_NAME)
assertThat(data.actions).hasSize(1)
+ assertThat(data.lastActive).isAtLeast(currentTimeMillis)
}
@Test
@@ -350,4 +352,52 @@
smartspaceMediaDataProvider.onTargetsAvailable(listOf())
verify(listener).onSmartspaceMediaDataRemoved(KEY_MEDIA_SMARTSPACE)
}
+
+ @Test
+ fun testOnMediaDataChanged_updatesLastActiveTime() {
+ val currentTimeMillis = System.currentTimeMillis()
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor))
+ assertThat(mediaDataCaptor.value!!.lastActive).isAtLeast(currentTimeMillis)
+ }
+
+ @Test
+ fun testOnMediaDataTimedOut_doesNotUpdateLastActiveTime() {
+ // GIVEN that the manager has a notification
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+
+ // WHEN the notification times out
+ val currentTimeMillis = System.currentTimeMillis()
+ mediaDataManager.setTimedOut(KEY, true, true)
+
+ // THEN the last active time is not changed
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(KEY), capture(mediaDataCaptor))
+ assertThat(mediaDataCaptor.value.lastActive).isLessThan(currentTimeMillis)
+ }
+
+ @Test
+ fun testOnActiveMediaConverted_doesNotUpdateLastActiveTime() {
+ // GIVEN that the manager has a notification with a resume action
+ whenever(controller.metadata).thenReturn(metadataBuilder.build())
+ mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+ assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+ assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+ verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor))
+ val data = mediaDataCaptor.value
+ assertThat(data.resumption).isFalse()
+ mediaDataManager.onMediaDataLoaded(KEY, null, data.copy(resumeAction = Runnable {}))
+
+ // WHEN the notification is removed
+ val currentTimeMillis = System.currentTimeMillis()
+ mediaDataManager.onNotificationRemoved(KEY)
+
+ // THEN the last active time is not changed
+ verify(listener).onMediaDataLoaded(eq(PACKAGE_NAME), eq(KEY), capture(mediaDataCaptor))
+ assertThat(mediaDataCaptor.value.resumption).isTrue()
+ assertThat(mediaDataCaptor.value.lastActive).isLessThan(currentTimeMillis)
+ }
}