1 /* <lambda>null2 * Copyright (C) 2022 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 @file:OptIn(ExperimentalCoroutinesApi::class) 18 19 package com.android.systemui.statusbar.notification.collection.coordinator 20 21 import android.app.NotificationManager 22 import android.os.UserHandle 23 import android.provider.Settings 24 import androidx.annotation.VisibleForTesting 25 import com.android.systemui.Dumpable 26 import com.android.systemui.dagger.qualifiers.Application 27 import com.android.systemui.dagger.qualifiers.Background 28 import com.android.systemui.dump.DumpManager 29 import com.android.systemui.keyguard.data.repository.KeyguardRepository 30 import com.android.systemui.keyguard.data.repository.KeyguardTransitionRepository 31 import com.android.systemui.keyguard.shared.model.KeyguardState 32 import com.android.systemui.plugins.statusbar.StatusBarStateController 33 import com.android.systemui.statusbar.StatusBarState 34 import com.android.systemui.statusbar.expansionChanges 35 import com.android.systemui.statusbar.notification.collection.GroupEntry 36 import com.android.systemui.statusbar.notification.collection.ListEntry 37 import com.android.systemui.statusbar.notification.collection.NotifPipeline 38 import com.android.systemui.statusbar.notification.collection.NotificationEntry 39 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope 40 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter 41 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter 42 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner 43 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener 44 import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider 45 import com.android.systemui.statusbar.notification.domain.interactor.SeenNotificationsInteractor 46 import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider 47 import com.android.systemui.statusbar.notification.shared.NotificationMinimalismPrototype 48 import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_ONGOING 49 import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_UNSEEN 50 import com.android.systemui.statusbar.policy.HeadsUpManager 51 import com.android.systemui.statusbar.policy.headsUpEvents 52 import com.android.systemui.util.asIndenting 53 import com.android.systemui.util.indentIfPossible 54 import com.android.systemui.util.settings.SecureSettings 55 import com.android.systemui.util.settings.SettingsProxyExt.observerFlow 56 import java.io.PrintWriter 57 import javax.inject.Inject 58 import kotlin.time.Duration.Companion.seconds 59 import kotlinx.coroutines.CoroutineDispatcher 60 import kotlinx.coroutines.CoroutineScope 61 import kotlinx.coroutines.ExperimentalCoroutinesApi 62 import kotlinx.coroutines.Job 63 import kotlinx.coroutines.coroutineScope 64 import kotlinx.coroutines.delay 65 import kotlinx.coroutines.flow.Flow 66 import kotlinx.coroutines.flow.MutableSharedFlow 67 import kotlinx.coroutines.flow.collectLatest 68 import kotlinx.coroutines.flow.conflate 69 import kotlinx.coroutines.flow.distinctUntilChanged 70 import kotlinx.coroutines.flow.flowOf 71 import kotlinx.coroutines.flow.flowOn 72 import kotlinx.coroutines.flow.map 73 import kotlinx.coroutines.flow.onEach 74 import kotlinx.coroutines.flow.onStart 75 import kotlinx.coroutines.launch 76 import kotlinx.coroutines.yield 77 78 /** 79 * Filters low priority and privacy-sensitive notifications from the lockscreen, and hides section 80 * headers on the lockscreen. If enabled, it will also track and hide seen notifications on the 81 * lockscreen. 82 */ 83 @CoordinatorScope 84 class KeyguardCoordinator 85 @Inject 86 constructor( 87 @Background private val bgDispatcher: CoroutineDispatcher, 88 private val dumpManager: DumpManager, 89 private val headsUpManager: HeadsUpManager, 90 private val keyguardNotificationVisibilityProvider: KeyguardNotificationVisibilityProvider, 91 private val keyguardRepository: KeyguardRepository, 92 private val keyguardTransitionRepository: KeyguardTransitionRepository, 93 private val logger: KeyguardCoordinatorLogger, 94 @Application private val scope: CoroutineScope, 95 private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider, 96 private val secureSettings: SecureSettings, 97 private val seenNotificationsInteractor: SeenNotificationsInteractor, 98 private val statusBarStateController: StatusBarStateController, 99 ) : Coordinator, Dumpable { 100 101 private val unseenNotifications = mutableSetOf<NotificationEntry>() 102 private val unseenEntryAdded = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) 103 private val unseenEntryRemoved = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) 104 private var unseenFilterEnabled = false 105 106 override fun attach(pipeline: NotifPipeline) { 107 setupInvalidateNotifListCallbacks() 108 // Filter at the "finalize" stage so that views remain bound by PreparationCoordinator 109 pipeline.addFinalizeFilter(notifFilter) 110 keyguardNotificationVisibilityProvider.addOnStateChangedListener(::invalidateListFromFilter) 111 updateSectionHeadersVisibility() 112 attachUnseenFilter(pipeline) 113 } 114 115 private fun attachUnseenFilter(pipeline: NotifPipeline) { 116 if (NotificationMinimalismPrototype.V2.isEnabled) { 117 pipeline.addPromoter(unseenNotifPromoter) 118 pipeline.addOnBeforeTransformGroupsListener(::pickOutTopUnseenNotifs) 119 } 120 pipeline.addFinalizeFilter(unseenNotifFilter) 121 pipeline.addCollectionListener(collectionListener) 122 scope.launch { trackUnseenFilterSettingChanges() } 123 dumpManager.registerDumpable(this) 124 } 125 126 private suspend fun trackSeenNotifications() { 127 // Whether or not keyguard is visible (or occluded). 128 val isKeyguardPresent: Flow<Boolean> = 129 keyguardTransitionRepository.transitions 130 .map { step -> step.to != KeyguardState.GONE } 131 .distinctUntilChanged() 132 .onEach { trackingUnseen -> logger.logTrackingUnseen(trackingUnseen) } 133 134 // Separately track seen notifications while the device is locked, applying once the device 135 // is unlocked. 136 val notificationsSeenWhileLocked = mutableSetOf<NotificationEntry>() 137 138 // Use [collectLatest] to cancel any running jobs when [trackingUnseen] changes. 139 isKeyguardPresent.collectLatest { isKeyguardPresent: Boolean -> 140 if (isKeyguardPresent) { 141 // Keyguard is not gone, notifications need to be visible for a certain threshold 142 // before being marked as seen 143 trackSeenNotificationsWhileLocked(notificationsSeenWhileLocked) 144 } else { 145 // Mark all seen-while-locked notifications as seen for real. 146 if (notificationsSeenWhileLocked.isNotEmpty()) { 147 unseenNotifications.removeAll(notificationsSeenWhileLocked) 148 logger.logAllMarkedSeenOnUnlock( 149 seenCount = notificationsSeenWhileLocked.size, 150 remainingUnseenCount = unseenNotifications.size 151 ) 152 notificationsSeenWhileLocked.clear() 153 } 154 unseenNotifFilter.invalidateList("keyguard no longer showing") 155 // Keyguard is gone, notifications can be immediately marked as seen when they 156 // become visible. 157 trackSeenNotificationsWhileUnlocked() 158 } 159 } 160 } 161 162 /** 163 * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually 164 * been "seen" while the device is on the keyguard. 165 */ 166 private suspend fun trackSeenNotificationsWhileLocked( 167 notificationsSeenWhileLocked: MutableSet<NotificationEntry>, 168 ) = coroutineScope { 169 // Remove removed notifications from the set 170 launch { 171 unseenEntryRemoved.collect { entry -> 172 if (notificationsSeenWhileLocked.remove(entry)) { 173 logger.logRemoveSeenOnLockscreen(entry) 174 } 175 } 176 } 177 // Use collectLatest so that the timeout delay is cancelled if the device enters doze, and 178 // is restarted when doze ends. 179 keyguardRepository.isDozing.collectLatest { isDozing -> 180 if (!isDozing) { 181 trackSeenNotificationsWhileLockedAndNotDozing(notificationsSeenWhileLocked) 182 } 183 } 184 } 185 186 /** 187 * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually 188 * been "seen" while the device is on the keyguard and not dozing. Any new and existing unseen 189 * notifications are not marked as seen until they are visible for the [SEEN_TIMEOUT] duration. 190 */ 191 private suspend fun trackSeenNotificationsWhileLockedAndNotDozing( 192 notificationsSeenWhileLocked: MutableSet<NotificationEntry> 193 ) = coroutineScope { 194 // All child tracking jobs will be cancelled automatically when this is cancelled. 195 val trackingJobsByEntry = mutableMapOf<NotificationEntry, Job>() 196 197 /** 198 * Wait for the user to spend enough time on the lock screen before removing notification 199 * from unseen set upon unlock. 200 */ 201 suspend fun trackSeenDurationThreshold(entry: NotificationEntry) { 202 if (notificationsSeenWhileLocked.remove(entry)) { 203 logger.logResetSeenOnLockscreen(entry) 204 } 205 delay(SEEN_TIMEOUT) 206 notificationsSeenWhileLocked.add(entry) 207 trackingJobsByEntry.remove(entry) 208 logger.logSeenOnLockscreen(entry) 209 } 210 211 /** Stop any unseen tracking when a notification is removed. */ 212 suspend fun stopTrackingRemovedNotifs(): Nothing = 213 unseenEntryRemoved.collect { entry -> 214 trackingJobsByEntry.remove(entry)?.let { 215 it.cancel() 216 logger.logStopTrackingLockscreenSeenDuration(entry) 217 } 218 } 219 220 /** Start tracking new notifications when they are posted. */ 221 suspend fun trackNewUnseenNotifs(): Nothing = coroutineScope { 222 unseenEntryAdded.collect { entry -> 223 logger.logTrackingLockscreenSeenDuration(entry) 224 // If this is an update, reset the tracking. 225 trackingJobsByEntry[entry]?.let { 226 it.cancel() 227 logger.logResetSeenOnLockscreen(entry) 228 } 229 trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) } 230 } 231 } 232 233 // Start tracking for all notifications that are currently unseen. 234 logger.logTrackingLockscreenSeenDuration(unseenNotifications) 235 unseenNotifications.forEach { entry -> 236 trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) } 237 } 238 239 launch { trackNewUnseenNotifs() } 240 launch { stopTrackingRemovedNotifs() } 241 } 242 243 // Track "seen" notifications, marking them as such when either shade is expanded or the 244 // notification becomes heads up. 245 private suspend fun trackSeenNotificationsWhileUnlocked() { 246 coroutineScope { 247 launch { clearUnseenNotificationsWhenShadeIsExpanded() } 248 launch { markHeadsUpNotificationsAsSeen() } 249 } 250 } 251 252 private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() { 253 statusBarStateController.expansionChanges.collectLatest { isExpanded -> 254 // Give keyguard events time to propagate, in case this expansion is part of the 255 // keyguard transition and not the user expanding the shade 256 yield() 257 if (isExpanded) { 258 logger.logShadeExpanded() 259 unseenNotifications.clear() 260 } 261 } 262 } 263 264 private suspend fun markHeadsUpNotificationsAsSeen() { 265 headsUpManager.allEntries 266 .filter { it.isRowPinned } 267 .forEach { unseenNotifications.remove(it) } 268 headsUpManager.headsUpEvents.collect { (entry, isHun) -> 269 if (isHun) { 270 logger.logUnseenHun(entry.key) 271 unseenNotifications.remove(entry) 272 } 273 } 274 } 275 276 private fun unseenFeatureEnabled(): Flow<Boolean> { 277 if ( 278 NotificationMinimalismPrototype.V1.isEnabled || 279 NotificationMinimalismPrototype.V2.isEnabled 280 ) { 281 return flowOf(true) 282 } 283 return secureSettings 284 // emit whenever the setting has changed 285 .observerFlow( 286 UserHandle.USER_ALL, 287 Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 288 ) 289 // perform a query immediately 290 .onStart { emit(Unit) } 291 // for each change, lookup the new value 292 .map { 293 secureSettings.getIntForUser( 294 name = Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 295 def = 0, 296 userHandle = UserHandle.USER_CURRENT, 297 ) == 1 298 } 299 // don't emit anything if nothing has changed 300 .distinctUntilChanged() 301 // perform lookups on the bg thread pool 302 .flowOn(bgDispatcher) 303 // only track the most recent emission, if events are happening faster than they can be 304 // consumed 305 .conflate() 306 } 307 308 private suspend fun trackUnseenFilterSettingChanges() { 309 unseenFeatureEnabled().collectLatest { setting -> 310 // update local field and invalidate if necessary 311 if (setting != unseenFilterEnabled) { 312 unseenFilterEnabled = setting 313 unseenNotifFilter.invalidateList("unseen setting changed") 314 } 315 // if the setting is enabled, then start tracking and filtering unseen notifications 316 if (setting) { 317 trackSeenNotifications() 318 } 319 } 320 } 321 322 private val collectionListener = 323 object : NotifCollectionListener { 324 override fun onEntryAdded(entry: NotificationEntry) { 325 if ( 326 keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded 327 ) { 328 logger.logUnseenAdded(entry.key) 329 unseenNotifications.add(entry) 330 unseenEntryAdded.tryEmit(entry) 331 } 332 } 333 334 override fun onEntryUpdated(entry: NotificationEntry) { 335 if ( 336 keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded 337 ) { 338 logger.logUnseenUpdated(entry.key) 339 unseenNotifications.add(entry) 340 unseenEntryAdded.tryEmit(entry) 341 } 342 } 343 344 override fun onEntryRemoved(entry: NotificationEntry, reason: Int) { 345 if (unseenNotifications.remove(entry)) { 346 logger.logUnseenRemoved(entry.key) 347 unseenEntryRemoved.tryEmit(entry) 348 } 349 } 350 } 351 352 private fun pickOutTopUnseenNotifs(list: List<ListEntry>) { 353 if (NotificationMinimalismPrototype.V2.isUnexpectedlyInLegacyMode()) return 354 // Only ever elevate a top unseen notification on keyguard, not even locked shade 355 if (statusBarStateController.state != StatusBarState.KEYGUARD) { 356 seenNotificationsInteractor.setTopOngoingNotification(null) 357 seenNotificationsInteractor.setTopUnseenNotification(null) 358 return 359 } 360 // On keyguard pick the top-ranked unseen or ongoing notification to elevate 361 val nonSummaryEntries: Sequence<NotificationEntry> = 362 list 363 .asSequence() 364 .flatMap { 365 when (it) { 366 is NotificationEntry -> listOfNotNull(it) 367 is GroupEntry -> it.children 368 else -> error("unhandled type of $it") 369 } 370 } 371 .filter { it.importance >= NotificationManager.IMPORTANCE_DEFAULT } 372 seenNotificationsInteractor.setTopOngoingNotification( 373 nonSummaryEntries 374 .filter { ColorizedFgsCoordinator.isRichOngoing(it) } 375 .minByOrNull { it.ranking.rank } 376 ) 377 seenNotificationsInteractor.setTopUnseenNotification( 378 nonSummaryEntries 379 .filter { !ColorizedFgsCoordinator.isRichOngoing(it) && it in unseenNotifications } 380 .minByOrNull { it.ranking.rank } 381 ) 382 } 383 384 @VisibleForTesting 385 internal val unseenNotifPromoter = 386 object : NotifPromoter("$TAG-unseen") { 387 override fun shouldPromoteToTopLevel(child: NotificationEntry): Boolean = 388 if (NotificationMinimalismPrototype.V2.isUnexpectedlyInLegacyMode()) false 389 else if (!NotificationMinimalismPrototype.V2.ungroupTopUnseen) false 390 else 391 seenNotificationsInteractor.isTopOngoingNotification(child) || 392 seenNotificationsInteractor.isTopUnseenNotification(child) 393 } 394 395 val topOngoingSectioner = 396 object : NotifSectioner("TopOngoing", BUCKET_TOP_ONGOING) { 397 override fun isInSection(entry: ListEntry): Boolean { 398 if (NotificationMinimalismPrototype.V2.isUnexpectedlyInLegacyMode()) return false 399 return entry.anyEntry { notificationEntry -> 400 seenNotificationsInteractor.isTopOngoingNotification(notificationEntry) 401 } 402 } 403 } 404 405 val topUnseenSectioner = 406 object : NotifSectioner("TopUnseen", BUCKET_TOP_UNSEEN) { 407 override fun isInSection(entry: ListEntry): Boolean { 408 if (NotificationMinimalismPrototype.V2.isUnexpectedlyInLegacyMode()) return false 409 return entry.anyEntry { notificationEntry -> 410 seenNotificationsInteractor.isTopUnseenNotification(notificationEntry) 411 } 412 } 413 } 414 415 private fun ListEntry.anyEntry(predicate: (NotificationEntry?) -> Boolean) = 416 when { 417 predicate(representativeEntry) -> true 418 this !is GroupEntry -> false 419 else -> children.any(predicate) 420 } 421 422 @VisibleForTesting 423 internal val unseenNotifFilter = 424 object : NotifFilter("$TAG-unseen") { 425 426 var hasFilteredAnyNotifs = false 427 428 /** 429 * Encapsulates a definition of "being on the keyguard". Note that these two definitions 430 * are wildly different: [StatusBarState.KEYGUARD] is when on the lock screen and does 431 * not include shade or occluded states, whereas [KeyguardRepository.isKeyguardShowing] 432 * is any state where the keyguard has not been dismissed, including locked shade and 433 * occluded lock screen. 434 * 435 * Returning false for locked shade and occluded states means that this filter will 436 * allow seen notifications to appear in the locked shade. 437 */ 438 private fun isOnKeyguard(): Boolean = 439 if (NotificationMinimalismPrototype.V2.isEnabled) { 440 false // disable this feature under this prototype 441 } else if ( 442 NotificationMinimalismPrototype.V1.isEnabled && 443 NotificationMinimalismPrototype.V1.showOnLockedShade 444 ) { 445 statusBarStateController.state == StatusBarState.KEYGUARD 446 } else { 447 keyguardRepository.isKeyguardShowing() 448 } 449 450 override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean = 451 when { 452 // Don't apply filter if the setting is disabled 453 !unseenFilterEnabled -> false 454 // Don't apply filter if the keyguard isn't currently showing 455 !isOnKeyguard() -> false 456 // Don't apply the filter if the notification is unseen 457 unseenNotifications.contains(entry) -> false 458 // Don't apply the filter to (non-promoted) group summaries 459 // - summary will be pruned if necessary, depending on if children are filtered 460 entry.parent?.summary == entry -> false 461 // Check that the entry satisfies certain characteristics that would bypass the 462 // filter 463 shouldIgnoreUnseenCheck(entry) -> false 464 else -> true 465 }.also { hasFiltered -> hasFilteredAnyNotifs = hasFilteredAnyNotifs || hasFiltered } 466 467 override fun onCleanup() { 468 logger.logProviderHasFilteredOutSeenNotifs(hasFilteredAnyNotifs) 469 seenNotificationsInteractor.setHasFilteredOutSeenNotifications(hasFilteredAnyNotifs) 470 hasFilteredAnyNotifs = false 471 } 472 } 473 474 private val notifFilter: NotifFilter = 475 object : NotifFilter(TAG) { 476 override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean = 477 keyguardNotificationVisibilityProvider.shouldHideNotification(entry) 478 } 479 480 private fun shouldIgnoreUnseenCheck(entry: NotificationEntry): Boolean = 481 when { 482 entry.isMediaNotification -> true 483 entry.sbn.isOngoing -> true 484 else -> false 485 } 486 487 // TODO(b/206118999): merge this class with SensitiveContentCoordinator which also depends on 488 // these same updates 489 private fun setupInvalidateNotifListCallbacks() {} 490 491 private fun invalidateListFromFilter(reason: String) { 492 updateSectionHeadersVisibility() 493 notifFilter.invalidateList(reason) 494 } 495 496 private fun updateSectionHeadersVisibility() { 497 val onKeyguard = statusBarStateController.state == StatusBarState.KEYGUARD 498 val neverShowSections = sectionHeaderVisibilityProvider.neverShowSectionHeaders 499 val showSections = !onKeyguard && !neverShowSections 500 sectionHeaderVisibilityProvider.sectionHeadersVisible = showSections 501 } 502 503 override fun dump(pw: PrintWriter, args: Array<out String>) = 504 with(pw.asIndenting()) { 505 println( 506 "notificationListInteractor.hasFilteredOutSeenNotifications.value=" + 507 seenNotificationsInteractor.hasFilteredOutSeenNotifications.value 508 ) 509 println("unseen notifications:") 510 indentIfPossible { 511 for (notification in unseenNotifications) { 512 println(notification.key) 513 } 514 } 515 } 516 517 companion object { 518 private const val TAG = "KeyguardCoordinator" 519 private val SEEN_TIMEOUT = 5.seconds 520 } 521 } 522