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)
+    }
 }