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