1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.media.controls.pipeline
18 
19 import android.content.Context
20 import android.os.SystemProperties
21 import android.util.Log
22 import com.android.internal.annotations.VisibleForTesting
23 import com.android.systemui.broadcast.BroadcastSender
24 import com.android.systemui.dagger.qualifiers.Main
25 import com.android.systemui.media.controls.models.player.MediaData
26 import com.android.systemui.media.controls.models.recommendation.EXTRA_KEY_TRIGGER_RESUME
27 import com.android.systemui.media.controls.models.recommendation.SmartspaceMediaData
28 import com.android.systemui.media.controls.util.MediaFlags
29 import com.android.systemui.media.controls.util.MediaUiEventLogger
30 import com.android.systemui.settings.UserTracker
31 import com.android.systemui.statusbar.NotificationLockscreenUserManager
32 import com.android.systemui.util.time.SystemClock
33 import java.util.SortedMap
34 import java.util.concurrent.Executor
35 import java.util.concurrent.TimeUnit
36 import javax.inject.Inject
37 import kotlin.collections.LinkedHashMap
38 
39 private const val TAG = "MediaDataFilter"
40 private const val DEBUG = true
41 private const val EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME =
42     ("com.google" +
43         ".android.apps.gsa.staticplugins.opa.smartspace.ExportedSmartspaceTrampolineActivity")
44 private const val RESUMABLE_MEDIA_MAX_AGE_SECONDS_KEY = "resumable_media_max_age_seconds"
45 
46 /**
47  * Maximum age of a media control to re-activate on smartspace signal. If there is no media control
48  * available within this time window, smartspace recommendations will be shown instead.
49  */
50 @VisibleForTesting
51 internal val SMARTSPACE_MAX_AGE =
52     SystemProperties.getLong("debug.sysui.smartspace_max_age", TimeUnit.MINUTES.toMillis(30))
53 
54 /**
55  * Filters data updates from [MediaDataCombineLatest] based on the current user ID, and handles user
56  * switches (removing entries for the previous user, adding back entries for the current user). Also
57  * filters out smartspace updates in favor of local recent media, when avaialble.
58  *
59  * This is added at the end of the pipeline since we may still need to handle callbacks from
60  * background users (e.g. timeouts).
61  */
62 class MediaDataFilter
63 @Inject
64 constructor(
65     private val context: Context,
66     private val userTracker: UserTracker,
67     private val broadcastSender: BroadcastSender,
68     private val lockscreenUserManager: NotificationLockscreenUserManager,
69     @Main private val executor: Executor,
70     private val systemClock: SystemClock,
71     private val logger: MediaUiEventLogger,
72     private val mediaFlags: MediaFlags,
73 ) : MediaDataManager.Listener {
74     private val _listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf()
75     internal val listeners: Set<MediaDataManager.Listener>
76         get() = _listeners.toSet()
77     internal lateinit var mediaDataManager: MediaDataManager
78 
79     private val allEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
80     // The filtered userEntries, which will be a subset of all userEntries in MediaDataManager
81     private val userEntries: LinkedHashMap<String, MediaData> = LinkedHashMap()
82     private var smartspaceMediaData: SmartspaceMediaData = EMPTY_SMARTSPACE_MEDIA_DATA
83     private var reactivatedKey: String? = null
84 
85     private val userTrackerCallback =
86         object : UserTracker.Callback {
87             override fun onUserChanged(newUser: Int, userContext: Context) {
88                 handleUserSwitched(newUser)
89             }
90         }
91 
92     init {
93         userTracker.addCallback(userTrackerCallback, executor)
94     }
95 
96     override fun onMediaDataLoaded(
97         key: String,
98         oldKey: String?,
99         data: MediaData,
100         immediately: Boolean,
101         receivedSmartspaceCardLatency: Int,
102         isSsReactivated: Boolean
103     ) {
104         if (oldKey != null && oldKey != key) {
105             allEntries.remove(oldKey)
106         }
107         allEntries.put(key, data)
108 
109         if (!lockscreenUserManager.isCurrentProfile(data.userId)) {
110             return
111         }
112 
113         if (oldKey != null && oldKey != key) {
114             userEntries.remove(oldKey)
115         }
116         userEntries.put(key, data)
117 
118         // Notify listeners
119         listeners.forEach { it.onMediaDataLoaded(key, oldKey, data) }
120     }
121 
122     override fun onSmartspaceMediaDataLoaded(
123         key: String,
124         data: SmartspaceMediaData,
125         shouldPrioritize: Boolean
126     ) {
127         // With persistent recommendation card, we could get a background update while inactive
128         // Otherwise, consider it an invalid update
129         if (!data.isActive && !mediaFlags.isPersistentSsCardEnabled()) {
130             Log.d(TAG, "Inactive recommendation data. Skip triggering.")
131             return
132         }
133 
134         // Override the pass-in value here, as the order of Smartspace card is only determined here.
135         var shouldPrioritizeMutable = false
136         smartspaceMediaData = data
137 
138         // Before forwarding the smartspace target, first check if we have recently inactive media
139         val sorted = userEntries.toSortedMap(compareBy { userEntries.get(it)?.lastActive ?: -1 })
140         val timeSinceActive = timeSinceActiveForMostRecentMedia(sorted)
141         var smartspaceMaxAgeMillis = SMARTSPACE_MAX_AGE
142         data.cardAction?.extras?.let {
143             val smartspaceMaxAgeSeconds = it.getLong(RESUMABLE_MEDIA_MAX_AGE_SECONDS_KEY, 0)
144             if (smartspaceMaxAgeSeconds > 0) {
145                 smartspaceMaxAgeMillis = TimeUnit.SECONDS.toMillis(smartspaceMaxAgeSeconds)
146             }
147         }
148 
149         // Check if smartspace has explicitly specified whether to re-activate resumable media.
150         // The default behavior is to trigger if the smartspace data is active.
151         val shouldTriggerResume =
152             data.cardAction?.extras?.getBoolean(EXTRA_KEY_TRIGGER_RESUME, true) ?: true
153         val shouldReactivate =
154             shouldTriggerResume && !hasActiveMedia() && hasAnyMedia() && data.isActive
155 
156         if (timeSinceActive < smartspaceMaxAgeMillis) {
157             // It could happen there are existing active media resume cards, then we don't need to
158             // reactivate.
159             if (shouldReactivate) {
160                 val lastActiveKey = sorted.lastKey() // most recently active
161                 // Notify listeners to consider this media active
162                 Log.d(TAG, "reactivating $lastActiveKey instead of smartspace")
163                 reactivatedKey = lastActiveKey
164                 val mediaData = sorted.get(lastActiveKey)!!.copy(active = true)
165                 logger.logRecommendationActivated(
166                     mediaData.appUid,
167                     mediaData.packageName,
168                     mediaData.instanceId
169                 )
170                 listeners.forEach {
171                     it.onMediaDataLoaded(
172                         lastActiveKey,
173                         lastActiveKey,
174                         mediaData,
175                         receivedSmartspaceCardLatency =
176                             (systemClock.currentTimeMillis() - data.headphoneConnectionTimeMillis)
177                                 .toInt(),
178                         isSsReactivated = true
179                     )
180                 }
181             }
182         } else if (data.isActive) {
183             // Mark to prioritize Smartspace card if no recent media.
184             shouldPrioritizeMutable = true
185         }
186 
187         if (!data.isValid()) {
188             Log.d(TAG, "Invalid recommendation data. Skip showing the rec card")
189             return
190         }
191         logger.logRecommendationAdded(
192             smartspaceMediaData.packageName,
193             smartspaceMediaData.instanceId
194         )
195         listeners.forEach { it.onSmartspaceMediaDataLoaded(key, data, shouldPrioritizeMutable) }
196     }
197 
198     override fun onMediaDataRemoved(key: String) {
199         allEntries.remove(key)
200         userEntries.remove(key)?.let {
201             // Only notify listeners if something actually changed
202             listeners.forEach { it.onMediaDataRemoved(key) }
203         }
204     }
205 
206     override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
207         // First check if we had reactivated media instead of forwarding smartspace
208         reactivatedKey?.let {
209             val lastActiveKey = it
210             reactivatedKey = null
211             Log.d(TAG, "expiring reactivated key $lastActiveKey")
212             // Notify listeners to update with actual active value
213             userEntries.get(lastActiveKey)?.let { mediaData ->
214                 listeners.forEach {
215                     it.onMediaDataLoaded(lastActiveKey, lastActiveKey, mediaData, immediately)
216                 }
217             }
218         }
219 
220         if (smartspaceMediaData.isActive) {
221             smartspaceMediaData =
222                 EMPTY_SMARTSPACE_MEDIA_DATA.copy(
223                     targetId = smartspaceMediaData.targetId,
224                     instanceId = smartspaceMediaData.instanceId
225                 )
226         }
227         listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
228     }
229 
230     @VisibleForTesting
231     internal fun handleUserSwitched(id: Int) {
232         // If the user changes, remove all current MediaData objects and inform listeners
233         val listenersCopy = listeners
234         val keyCopy = userEntries.keys.toMutableList()
235         // Clear the list first, to make sure callbacks from listeners if we have any entries
236         // are up to date
237         userEntries.clear()
238         keyCopy.forEach {
239             if (DEBUG) Log.d(TAG, "Removing $it after user change")
240             listenersCopy.forEach { listener -> listener.onMediaDataRemoved(it) }
241         }
242 
243         allEntries.forEach { (key, data) ->
244             if (lockscreenUserManager.isCurrentProfile(data.userId)) {
245                 if (DEBUG) Log.d(TAG, "Re-adding $key after user change")
246                 userEntries.put(key, data)
247                 listenersCopy.forEach { listener -> listener.onMediaDataLoaded(key, null, data) }
248             }
249         }
250     }
251 
252     /** Invoked when the user has dismissed the media carousel */
253     fun onSwipeToDismiss() {
254         if (DEBUG) Log.d(TAG, "Media carousel swiped away")
255         val mediaKeys = userEntries.keys.toSet()
256         mediaKeys.forEach {
257             // Force updates to listeners, needed for re-activated card
258             mediaDataManager.setTimedOut(it, timedOut = true, forceUpdate = true)
259         }
260         if (smartspaceMediaData.isActive) {
261             val dismissIntent = smartspaceMediaData.dismissIntent
262             if (dismissIntent == null) {
263                 Log.w(
264                     TAG,
265                     "Cannot create dismiss action click action: extras missing dismiss_intent."
266                 )
267             } else if (
268                 dismissIntent.component?.className == EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME
269             ) {
270                 // Dismiss the card Smartspace data through Smartspace trampoline activity.
271                 context.startActivity(dismissIntent)
272             } else {
273                 broadcastSender.sendBroadcast(dismissIntent)
274             }
275 
276             if (mediaFlags.isPersistentSsCardEnabled()) {
277                 smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
278                 mediaDataManager.setRecommendationInactive(smartspaceMediaData.targetId)
279             } else {
280                 smartspaceMediaData =
281                     EMPTY_SMARTSPACE_MEDIA_DATA.copy(
282                         targetId = smartspaceMediaData.targetId,
283                         instanceId = smartspaceMediaData.instanceId,
284                     )
285                 mediaDataManager.dismissSmartspaceRecommendation(
286                     smartspaceMediaData.targetId,
287                     delay = 0L,
288                 )
289             }
290         }
291     }
292 
293     /** Are there any active media entries, including the recommendation? */
294     fun hasActiveMediaOrRecommendation() =
295         userEntries.any { it.value.active } ||
296             (smartspaceMediaData.isActive &&
297                 (smartspaceMediaData.isValid() || reactivatedKey != null))
298 
299     /** Are there any media entries we should display? */
300     fun hasAnyMediaOrRecommendation(): Boolean {
301         val hasSmartspace =
302             if (mediaFlags.isPersistentSsCardEnabled()) {
303                 smartspaceMediaData.isValid()
304             } else {
305                 smartspaceMediaData.isActive && smartspaceMediaData.isValid()
306             }
307         return userEntries.isNotEmpty() || hasSmartspace
308     }
309 
310     /** Are there any media notifications active (excluding the recommendation)? */
311     fun hasActiveMedia() = userEntries.any { it.value.active }
312 
313     /** Are there any media entries we should display (excluding the recommendation)? */
314     fun hasAnyMedia() = userEntries.isNotEmpty()
315 
316     /** Add a listener for filtered [MediaData] changes */
317     fun addListener(listener: MediaDataManager.Listener) = _listeners.add(listener)
318 
319     /** Remove a listener that was registered with addListener */
320     fun removeListener(listener: MediaDataManager.Listener) = _listeners.remove(listener)
321 
322     /**
323      * Return the time since last active for the most-recent media.
324      *
325      * @param sortedEntries userEntries sorted from the earliest to the most-recent.
326      * @return The duration in milliseconds from the most-recent media's last active timestamp to
327      *   the present. MAX_VALUE will be returned if there is no media.
328      */
329     private fun timeSinceActiveForMostRecentMedia(
330         sortedEntries: SortedMap<String, MediaData>
331     ): Long {
332         if (sortedEntries.isEmpty()) {
333             return Long.MAX_VALUE
334         }
335 
336         val now = systemClock.elapsedRealtime()
337         val lastActiveKey = sortedEntries.lastKey() // most recently active
338         return sortedEntries.get(lastActiveKey)?.let { now - it.lastActive } ?: Long.MAX_VALUE
339     }
340 }
341