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