1 /*
<lambda>null2  * Copyright (C) 2024 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.domain.pipeline
18 
19 import android.content.Context
20 import android.content.pm.UserInfo
21 import android.os.SystemProperties
22 import android.util.Log
23 import com.android.internal.annotations.KeepForWeakReference
24 import com.android.internal.annotations.VisibleForTesting
25 import com.android.internal.logging.InstanceId
26 import com.android.systemui.broadcast.BroadcastSender
27 import com.android.systemui.dagger.SysUISingleton
28 import com.android.systemui.dagger.qualifiers.Main
29 import com.android.systemui.media.controls.data.repository.MediaFilterRepository
30 import com.android.systemui.media.controls.shared.model.EXTRA_KEY_TRIGGER_RESUME
31 import com.android.systemui.media.controls.shared.model.MediaData
32 import com.android.systemui.media.controls.shared.model.MediaDataLoadingModel
33 import com.android.systemui.media.controls.shared.model.SmartspaceMediaData
34 import com.android.systemui.media.controls.shared.model.SmartspaceMediaLoadingModel
35 import com.android.systemui.media.controls.util.MediaFlags
36 import com.android.systemui.media.controls.util.MediaUiEventLogger
37 import com.android.systemui.settings.UserTracker
38 import com.android.systemui.statusbar.NotificationLockscreenUserManager
39 import com.android.systemui.util.time.SystemClock
40 import java.util.SortedMap
41 import java.util.concurrent.Executor
42 import java.util.concurrent.TimeUnit
43 import javax.inject.Inject
44 
45 private const val TAG = "MediaDataFilter"
46 private const val DEBUG = true
47 private const val EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME =
48     ("com.google" +
49         ".android.apps.gsa.staticplugins.opa.smartspace.ExportedSmartspaceTrampolineActivity")
50 private const val RESUMABLE_MEDIA_MAX_AGE_SECONDS_KEY = "resumable_media_max_age_seconds"
51 
52 /**
53  * Filters data updates from [MediaDataCombineLatest] based on the current user ID, and handles user
54  * switches (removing entries for the previous user, adding back entries for the current user). Also
55  * filters out smartspace updates in favor of local recent media, when avaialble.
56  *
57  * This is added at the end of the pipeline since we may still need to handle callbacks from
58  * background users (e.g. timeouts).
59  */
60 @SysUISingleton
61 class MediaDataFilterImpl
62 @Inject
63 constructor(
64     private val context: Context,
65     userTracker: UserTracker,
66     private val broadcastSender: BroadcastSender,
67     private val lockscreenUserManager: NotificationLockscreenUserManager,
68     @Main private val executor: Executor,
69     private val systemClock: SystemClock,
70     private val logger: MediaUiEventLogger,
71     private val mediaFlags: MediaFlags,
72     private val mediaFilterRepository: MediaFilterRepository,
73     private val mediaLoadingLogger: MediaLoadingLogger,
74 ) : MediaDataManager.Listener {
75     /** Non-UI listeners to media changes. */
76     private val _listeners: MutableSet<MediaDataProcessor.Listener> = mutableSetOf()
77     val listeners: Set<MediaDataProcessor.Listener>
78         get() = _listeners.toSet()
79 
80     lateinit var mediaDataProcessor: MediaDataProcessor
81 
82     // Ensure the field (and associated reference) isn't removed during optimization.
83     @KeepForWeakReference
84     private val userTrackerCallback =
85         object : UserTracker.Callback {
86             override fun onUserChanged(newUser: Int, userContext: Context) {
87                 handleUserSwitched()
88             }
89 
90             override fun onProfilesChanged(profiles: List<UserInfo>) {
91                 handleProfileChanged()
92             }
93         }
94 
95     init {
96         userTracker.addCallback(userTrackerCallback, executor)
97     }
98 
99     override fun onMediaDataLoaded(
100         key: String,
101         oldKey: String?,
102         data: MediaData,
103         immediately: Boolean,
104         receivedSmartspaceCardLatency: Int,
105         isSsReactivated: Boolean
106     ) {
107         if (oldKey != null && oldKey != key) {
108             mediaFilterRepository.removeMediaEntry(oldKey)
109         }
110         mediaFilterRepository.addMediaEntry(key, data)
111 
112         if (
113             !lockscreenUserManager.isCurrentProfile(data.userId) ||
114                 !lockscreenUserManager.isProfileAvailable(data.userId)
115         ) {
116             return
117         }
118 
119         mediaFilterRepository.addSelectedUserMediaEntry(data)
120 
121         mediaLoadingLogger.logMediaLoaded(data.instanceId, data.active, "loading media")
122         mediaFilterRepository.addMediaDataLoadingState(
123             MediaDataLoadingModel.Loaded(data.instanceId)
124         )
125 
126         // Notify listeners
127         listeners.forEach { it.onMediaDataLoaded(key, oldKey, data) }
128     }
129 
130     override fun onSmartspaceMediaDataLoaded(
131         key: String,
132         data: SmartspaceMediaData,
133         shouldPrioritize: Boolean
134     ) {
135         // With persistent recommendation card, we could get a background update while inactive
136         // Otherwise, consider it an invalid update
137         if (!data.isActive && !mediaFlags.isPersistentSsCardEnabled()) {
138             Log.d(TAG, "Inactive recommendation data. Skip triggering.")
139             return
140         }
141 
142         // Override the pass-in value here, as the order of Smartspace card is only determined here.
143         var shouldPrioritizeMutable = false
144         mediaFilterRepository.setRecommendation(data)
145 
146         // Before forwarding the smartspace target, first check if we have recently inactive media
147         val selectedUserEntries = mediaFilterRepository.selectedUserEntries.value
148         val sorted =
149             selectedUserEntries.toSortedMap(compareBy { selectedUserEntries[it]?.lastActive ?: -1 })
150         val timeSinceActive = timeSinceActiveForMostRecentMedia(sorted)
151         var smartspaceMaxAgeMillis = SMARTSPACE_MAX_AGE
152         data.cardAction?.extras?.let {
153             val smartspaceMaxAgeSeconds = it.getLong(RESUMABLE_MEDIA_MAX_AGE_SECONDS_KEY, 0)
154             if (smartspaceMaxAgeSeconds > 0) {
155                 smartspaceMaxAgeMillis = TimeUnit.SECONDS.toMillis(smartspaceMaxAgeSeconds)
156             }
157         }
158 
159         // Check if smartspace has explicitly specified whether to re-activate resumable media.
160         // The default behavior is to trigger if the smartspace data is active.
161         val shouldTriggerResume =
162             data.cardAction?.extras?.getBoolean(EXTRA_KEY_TRIGGER_RESUME, true) ?: true
163         val shouldReactivate =
164             shouldTriggerResume &&
165                 !selectedUserEntries.any { it.value.active } &&
166                 selectedUserEntries.isNotEmpty() &&
167                 data.isActive
168 
169         if (timeSinceActive < smartspaceMaxAgeMillis) {
170             // It could happen there are existing active media resume cards, then we don't need to
171             // reactivate.
172             if (shouldReactivate) {
173                 val lastActiveId = sorted.lastKey() // most recently active id
174                 // Update loading state to consider this media active
175                 mediaFilterRepository.setReactivatedId(lastActiveId)
176                 val mediaData = sorted[lastActiveId]!!.copy(active = true)
177                 logger.logRecommendationActivated(
178                     mediaData.appUid,
179                     mediaData.packageName,
180                     mediaData.instanceId
181                 )
182                 mediaFilterRepository.addMediaDataLoadingState(
183                     MediaDataLoadingModel.Loaded(lastActiveId)
184                 )
185                 mediaLoadingLogger.logMediaLoaded(
186                     mediaData.instanceId,
187                     mediaData.active,
188                     "reactivating media instead of smartspace"
189                 )
190                 listeners.forEach { listener ->
191                     getKey(lastActiveId)?.let { lastActiveKey ->
192                         listener.onMediaDataLoaded(
193                             lastActiveKey,
194                             lastActiveKey,
195                             mediaData,
196                             receivedSmartspaceCardLatency =
197                                 (systemClock.currentTimeMillis() -
198                                         data.headphoneConnectionTimeMillis)
199                                     .toInt(),
200                             isSsReactivated = true
201                         )
202                     }
203                 }
204             }
205         } else if (data.isActive) {
206             // Mark to prioritize Smartspace card if no recent media.
207             shouldPrioritizeMutable = true
208         }
209 
210         if (!data.isValid()) {
211             Log.d(TAG, "Invalid recommendation data. Skip showing the rec card")
212             return
213         }
214         val smartspaceMediaData = mediaFilterRepository.smartspaceMediaData.value
215         logger.logRecommendationAdded(
216             smartspaceMediaData.packageName,
217             smartspaceMediaData.instanceId
218         )
219         mediaFilterRepository.setRecommendationsLoadingState(
220             SmartspaceMediaLoadingModel.Loaded(key, shouldPrioritizeMutable)
221         )
222         mediaLoadingLogger.logRecommendationLoaded(key, data.isActive, "loading recommendations")
223         listeners.forEach { it.onSmartspaceMediaDataLoaded(key, data, shouldPrioritizeMutable) }
224     }
225 
226     override fun onMediaDataRemoved(key: String, userInitiated: Boolean) {
227         mediaFilterRepository.removeMediaEntry(key)?.let { mediaData ->
228             val instanceId = mediaData.instanceId
229             mediaFilterRepository.removeSelectedUserMediaEntry(instanceId)?.let {
230                 mediaFilterRepository.addMediaDataLoadingState(
231                     MediaDataLoadingModel.Removed(instanceId)
232                 )
233                 mediaLoadingLogger.logMediaRemoved(instanceId, "removing media card")
234                 // Only notify listeners if something actually changed
235                 listeners.forEach { it.onMediaDataRemoved(key, userInitiated) }
236             }
237         }
238     }
239 
240     override fun onSmartspaceMediaDataRemoved(key: String, immediately: Boolean) {
241         // First check if we had reactivated media instead of forwarding smartspace
242         mediaFilterRepository.reactivatedId.value?.let { lastActiveId ->
243             mediaFilterRepository.setReactivatedId(null)
244             // Update loading state with actual active value
245             mediaFilterRepository.selectedUserEntries.value[lastActiveId]?.let {
246                 mediaFilterRepository.addMediaDataLoadingState(
247                     MediaDataLoadingModel.Loaded(lastActiveId, immediately)
248                 )
249                 mediaLoadingLogger.logMediaLoaded(
250                     lastActiveId,
251                     it.active,
252                     "expiring reactivated id"
253                 )
254                 listeners.forEach { listener ->
255                     getKey(lastActiveId)?.let { lastActiveKey ->
256                         listener.onMediaDataLoaded(lastActiveKey, lastActiveKey, it, immediately)
257                     }
258                 }
259             }
260         }
261 
262         val smartspaceMediaData = mediaFilterRepository.smartspaceMediaData.value
263         if (smartspaceMediaData.isActive) {
264             mediaFilterRepository.setRecommendation(
265                 EMPTY_SMARTSPACE_MEDIA_DATA.copy(
266                     targetId = smartspaceMediaData.targetId,
267                     instanceId = smartspaceMediaData.instanceId
268                 )
269             )
270         }
271         mediaFilterRepository.setRecommendationsLoadingState(
272             SmartspaceMediaLoadingModel.Removed(key, immediately)
273         )
274         mediaLoadingLogger.logRecommendationRemoved(
275             key,
276             immediately,
277             "removing recommendations card"
278         )
279         listeners.forEach { it.onSmartspaceMediaDataRemoved(key, immediately) }
280     }
281 
282     @VisibleForTesting
283     internal fun handleProfileChanged() {
284         // TODO(b/317221348) re-add media removed when profile is available.
285         mediaFilterRepository.allUserEntries.value.forEach { (key, data) ->
286             if (!lockscreenUserManager.isProfileAvailable(data.userId)) {
287                 // Only remove media when the profile is unavailable.
288                 mediaFilterRepository.removeSelectedUserMediaEntry(data.instanceId, data)
289                 mediaFilterRepository.addMediaDataLoadingState(
290                     MediaDataLoadingModel.Removed(data.instanceId)
291                 )
292                 mediaLoadingLogger.logMediaRemoved(
293                     data.instanceId,
294                     "Removing $key after profile change"
295                 )
296                 listeners.forEach { listener -> listener.onMediaDataRemoved(key, false) }
297             }
298         }
299     }
300 
301     @VisibleForTesting
302     internal fun handleUserSwitched() {
303         // If the user changes, remove all current MediaData objects.
304         val listenersCopy = listeners
305         val keyCopy = mediaFilterRepository.selectedUserEntries.value.keys.toMutableList()
306         // Clear the list first and update loading state to remove media from UI.
307         mediaFilterRepository.clearSelectedUserMedia()
308         keyCopy.forEach { instanceId ->
309             mediaFilterRepository.addMediaDataLoadingState(
310                 MediaDataLoadingModel.Removed(instanceId)
311             )
312             mediaLoadingLogger.logMediaRemoved(instanceId, "Removing media after user change")
313             getKey(instanceId)?.let {
314                 listenersCopy.forEach { listener -> listener.onMediaDataRemoved(it, false) }
315             }
316         }
317 
318         mediaFilterRepository.allUserEntries.value.forEach { (key, data) ->
319             if (lockscreenUserManager.isCurrentProfile(data.userId)) {
320                 mediaFilterRepository.addSelectedUserMediaEntry(data)
321                 mediaFilterRepository.addMediaDataLoadingState(
322                     MediaDataLoadingModel.Loaded(data.instanceId)
323                 )
324                 mediaLoadingLogger.logMediaLoaded(
325                     data.instanceId,
326                     data.active,
327                     "Re-adding $key after user change"
328                 )
329                 listenersCopy.forEach { listener -> listener.onMediaDataLoaded(key, null, data) }
330             }
331         }
332     }
333 
334     /** Invoked when the user has dismissed the media carousel */
335     fun onSwipeToDismiss() {
336         if (DEBUG) Log.d(TAG, "Media carousel swiped away")
337         val mediaEntries = mediaFilterRepository.allUserEntries.value.entries
338         mediaEntries.forEach { (key, data) ->
339             if (mediaFilterRepository.selectedUserEntries.value.containsKey(data.instanceId)) {
340                 // Force updates to listeners, needed for re-activated card
341                 mediaDataProcessor.setInactive(key, timedOut = true, forceUpdate = true)
342             }
343         }
344         val smartspaceMediaData = mediaFilterRepository.smartspaceMediaData.value
345         if (smartspaceMediaData.isActive) {
346             val dismissIntent = smartspaceMediaData.dismissIntent
347             if (dismissIntent == null) {
348                 Log.w(
349                     TAG,
350                     "Cannot create dismiss action click action: extras missing dismiss_intent."
351                 )
352             } else if (
353                 dismissIntent.component?.className == EXPORTED_SMARTSPACE_TRAMPOLINE_ACTIVITY_NAME
354             ) {
355                 // Dismiss the card Smartspace data through Smartspace trampoline activity.
356                 context.startActivity(dismissIntent)
357             } else {
358                 broadcastSender.sendBroadcast(dismissIntent)
359             }
360 
361             if (mediaFlags.isPersistentSsCardEnabled()) {
362                 mediaFilterRepository.setRecommendation(smartspaceMediaData.copy(isActive = false))
363                 mediaDataProcessor.setRecommendationInactive(smartspaceMediaData.targetId)
364             } else {
365                 mediaFilterRepository.setRecommendation(
366                     EMPTY_SMARTSPACE_MEDIA_DATA.copy(
367                         targetId = smartspaceMediaData.targetId,
368                         instanceId = smartspaceMediaData.instanceId,
369                     )
370                 )
371                 mediaDataProcessor.dismissSmartspaceRecommendation(
372                     smartspaceMediaData.targetId,
373                     delay = 0L,
374                 )
375             }
376         }
377     }
378 
379     /** Add a listener for filtered [MediaData] changes */
380     fun addListener(listener: MediaDataProcessor.Listener) = _listeners.add(listener)
381 
382     /** Remove a listener that was registered with addListener */
383     fun removeListener(listener: MediaDataProcessor.Listener) = _listeners.remove(listener)
384 
385     /**
386      * Return the time since last active for the most-recent media.
387      *
388      * @param sortedEntries selectedUserEntries sorted from the earliest to the most-recent.
389      * @return The duration in milliseconds from the most-recent media's last active timestamp to
390      *   the present. MAX_VALUE will be returned if there is no media.
391      */
392     private fun timeSinceActiveForMostRecentMedia(
393         sortedEntries: SortedMap<InstanceId, MediaData>
394     ): Long {
395         if (sortedEntries.isEmpty()) {
396             return Long.MAX_VALUE
397         }
398 
399         val now = systemClock.elapsedRealtime()
400         val lastActiveInstanceId = sortedEntries.lastKey() // most recently active
401         return sortedEntries[lastActiveInstanceId]?.let { now - it.lastActive } ?: Long.MAX_VALUE
402     }
403 
404     private fun getKey(instanceId: InstanceId): String? {
405         val allEntries = mediaFilterRepository.allUserEntries.value
406         val filteredEntries = allEntries.filter { (_, data) -> data.instanceId == instanceId }
407         return if (filteredEntries.isNotEmpty()) {
408             filteredEntries.keys.first()
409         } else {
410             null
411         }
412     }
413 
414     companion object {
415         /**
416          * Maximum age of a media control to re-activate on smartspace signal. If there is no media
417          * control available within this time window, smartspace recommendations will be shown
418          * instead.
419          */
420         @VisibleForTesting
421         internal val SMARTSPACE_MAX_AGE: Long
422             get() =
423                 SystemProperties.getLong(
424                     "debug.sysui.smartspace_max_age",
425                     TimeUnit.MINUTES.toMillis(30)
426                 )
427     }
428 }
429