• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * 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             if (data.cardAction?.extras?.containsKey(EXTRA_KEY_TRIGGER_RESUME) == true) {
153                 data.cardAction.extras.getBoolean(EXTRA_KEY_TRIGGER_RESUME, true)
154             } else {
155                 true
156             }
157         val shouldReactivate =
158             shouldTriggerResume && !hasActiveMedia() && hasAnyMedia() && data.isActive
159 
160         if (timeSinceActive < smartspaceMaxAgeMillis) {
161             // It could happen there are existing active media resume cards, then we don't need to
162             // reactivate.
163             if (shouldReactivate) {
164                 val lastActiveKey = sorted.lastKey() // most recently active
165                 // Notify listeners to consider this media active
166                 Log.d(TAG, "reactivating $lastActiveKey instead of smartspace")
167                 reactivatedKey = lastActiveKey
168                 val mediaData = sorted.get(lastActiveKey)!!.copy(active = true)
169                 logger.logRecommendationActivated(
170                     mediaData.appUid,
171                     mediaData.packageName,
172                     mediaData.instanceId
173                 )
174                 listeners.forEach {
175                     it.onMediaDataLoaded(
176                         lastActiveKey,
177                         lastActiveKey,
178                         mediaData,
179                         receivedSmartspaceCardLatency =
180                             (systemClock.currentTimeMillis() - data.headphoneConnectionTimeMillis)
181                                 .toInt(),
182                         isSsReactivated = true
183                     )
184                 }
185             }
186         } else if (data.isActive) {
187             // Mark to prioritize Smartspace card if no recent media.
188             shouldPrioritizeMutable = true
189         }
190 
191         if (!data.isValid()) {
192             Log.d(TAG, "Invalid recommendation data. Skip showing the rec card")
193             return
194         }
195         logger.logRecommendationAdded(
196             smartspaceMediaData.packageName,
197             smartspaceMediaData.instanceId
198         )
199         listeners.forEach { it.onSmartspaceMediaDataLoaded(key, data, shouldPrioritizeMutable) }
200     }
201 
202     override fun onMediaDataRemoved(key: String) {
203         allEntries.remove(key)
204         userEntries.remove(key)?.let {
205             // Only notify listeners if something actually changed
206             listeners.forEach { it.onMediaDataRemoved(key) }
207         }
208     }
209 
210     override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
211         // First check if we had reactivated media instead of forwarding smartspace
212         reactivatedKey?.let {
213             val lastActiveKey = it
214             reactivatedKey = null
215             Log.d(TAG, "expiring reactivated key $lastActiveKey")
216             // Notify listeners to update with actual active value
217             userEntries.get(lastActiveKey)?.let { mediaData ->
218                 listeners.forEach {
219                     it.onMediaDataLoaded(lastActiveKey, lastActiveKey, mediaData, immediately)
220                 }
221             }
222         }
223 
224         if (smartspaceMediaData.isActive) {
225             smartspaceMediaData =
226                 EMPTY_SMARTSPACE_MEDIA_DATA.copy(
227                     targetId = smartspaceMediaData.targetId,
228                     instanceId = smartspaceMediaData.instanceId
229                 )
230         }
231         listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
232     }
233 
234     @VisibleForTesting
235     internal fun handleUserSwitched(id: Int) {
236         // If the user changes, remove all current MediaData objects and inform listeners
237         val listenersCopy = listeners
238         val keyCopy = userEntries.keys.toMutableList()
239         // Clear the list first, to make sure callbacks from listeners if we have any entries
240         // are up to date
241         userEntries.clear()
242         keyCopy.forEach {
243             if (DEBUG) Log.d(TAG, "Removing $it after user change")
244             listenersCopy.forEach { listener -> listener.onMediaDataRemoved(it) }
245         }
246 
247         allEntries.forEach { (key, data) ->
248             if (lockscreenUserManager.isCurrentProfile(data.userId)) {
249                 if (DEBUG) Log.d(TAG, "Re-adding $key after user change")
250                 userEntries.put(key, data)
251                 listenersCopy.forEach { listener -> listener.onMediaDataLoaded(key, null, data) }
252             }
253         }
254     }
255 
256     /** Invoked when the user has dismissed the media carousel */
257     fun onSwipeToDismiss() {
258         if (DEBUG) Log.d(TAG, "Media carousel swiped away")
259         val mediaKeys = userEntries.keys.toSet()
260         mediaKeys.forEach {
261             // Force updates to listeners, needed for re-activated card
262             mediaDataManager.setTimedOut(it, timedOut = true, forceUpdate = true)
263         }
264         if (smartspaceMediaData.isActive) {
265             val dismissIntent = smartspaceMediaData.dismissIntent
266             if (dismissIntent == null) {
267                 Log.w(
268                     TAG,
269                     "Cannot create dismiss action click action: extras missing dismiss_intent."
270                 )
271             } else if (
272                 dismissIntent.getComponent() != null &&
273                     dismissIntent.getComponent().getClassName() ==
274                         EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME
275             ) {
276                 // Dismiss the card Smartspace data through Smartspace trampoline activity.
277                 context.startActivity(dismissIntent)
278             } else {
279                 broadcastSender.sendBroadcast(dismissIntent)
280             }
281 
282             if (mediaFlags.isPersistentSsCardEnabled()) {
283                 smartspaceMediaData = smartspaceMediaData.copy(isActive = false)
284                 mediaDataManager.setRecommendationInactive(smartspaceMediaData.targetId)
285             } else {
286                 smartspaceMediaData =
287                     EMPTY_SMARTSPACE_MEDIA_DATA.copy(
288                         targetId = smartspaceMediaData.targetId,
289                         instanceId = smartspaceMediaData.instanceId,
290                     )
291                 mediaDataManager.dismissSmartspaceRecommendation(
292                     smartspaceMediaData.targetId,
293                     delay = 0L,
294                 )
295             }
296         }
297     }
298 
299     /** Are there any active media entries, including the recommendation? */
300     fun hasActiveMediaOrRecommendation() =
301         userEntries.any { it.value.active } ||
302             (smartspaceMediaData.isActive &&
303                 (smartspaceMediaData.isValid() || reactivatedKey != null))
304 
305     /** Are there any media entries we should display? */
306     fun hasAnyMediaOrRecommendation(): Boolean {
307         val hasSmartspace =
308             if (mediaFlags.isPersistentSsCardEnabled()) {
309                 smartspaceMediaData.isValid()
310             } else {
311                 smartspaceMediaData.isActive && smartspaceMediaData.isValid()
312             }
313         return userEntries.isNotEmpty() || hasSmartspace
314     }
315 
316     /** Are there any media notifications active (excluding the recommendation)? */
317     fun hasActiveMedia() = userEntries.any { it.value.active }
318 
319     /** Are there any media entries we should display (excluding the recommendation)? */
320     fun hasAnyMedia() = userEntries.isNotEmpty()
321 
322     /** Add a listener for filtered [MediaData] changes */
323     fun addListener(listener: MediaDataManager.Listener) = _listeners.add(listener)
324 
325     /** Remove a listener that was registered with addListener */
326     fun removeListener(listener: MediaDataManager.Listener) = _listeners.remove(listener)
327 
328     /**
329      * Return the time since last active for the most-recent media.
330      *
331      * @param sortedEntries userEntries sorted from the earliest to the most-recent.
332      * @return The duration in milliseconds from the most-recent media's last active timestamp to
333      *   the present. MAX_VALUE will be returned if there is no media.
334      */
335     private fun timeSinceActiveForMostRecentMedia(
336         sortedEntries: SortedMap<String, MediaData>
337     ): Long {
338         if (sortedEntries.isEmpty()) {
339             return Long.MAX_VALUE
340         }
341 
342         val now = systemClock.elapsedRealtime()
343         val lastActiveKey = sortedEntries.lastKey() // most recently active
344         return sortedEntries.get(lastActiveKey)?.let { now - it.lastActive } ?: Long.MAX_VALUE
345     }
346 }
347