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