1 /* 2 * 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.os.UserHandle 22 import android.provider.Settings 23 import androidx.annotation.VisibleForTesting 24 import com.android.systemui.Dumpable 25 import com.android.systemui.dagger.qualifiers.Application 26 import com.android.systemui.dagger.qualifiers.Background 27 import com.android.systemui.dump.DumpManager 28 import com.android.systemui.keyguard.data.repository.KeyguardRepository 29 import com.android.systemui.keyguard.data.repository.KeyguardTransitionRepository 30 import com.android.systemui.keyguard.shared.model.KeyguardState 31 import com.android.systemui.plugins.statusbar.StatusBarStateController 32 import com.android.systemui.statusbar.StatusBarState 33 import com.android.systemui.statusbar.expansionChanges 34 import com.android.systemui.statusbar.notification.collection.NotifPipeline 35 import com.android.systemui.statusbar.notification.collection.NotificationEntry 36 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope 37 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifFilter 38 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener 39 import com.android.systemui.statusbar.notification.collection.provider.SectionHeaderVisibilityProvider 40 import com.android.systemui.statusbar.notification.collection.provider.SeenNotificationsProviderImpl 41 import com.android.systemui.statusbar.notification.interruption.KeyguardNotificationVisibilityProvider 42 import com.android.systemui.statusbar.policy.HeadsUpManager 43 import com.android.systemui.statusbar.policy.headsUpEvents 44 import com.android.systemui.util.asIndenting 45 import com.android.systemui.util.indentIfPossible 46 import com.android.systemui.util.settings.SecureSettings 47 import com.android.systemui.util.settings.SettingsProxyExt.observerFlow 48 import java.io.PrintWriter 49 import javax.inject.Inject 50 import kotlin.time.Duration.Companion.seconds 51 import kotlinx.coroutines.CoroutineDispatcher 52 import kotlinx.coroutines.CoroutineScope 53 import kotlinx.coroutines.ExperimentalCoroutinesApi 54 import kotlinx.coroutines.Job 55 import kotlinx.coroutines.coroutineScope 56 import kotlinx.coroutines.delay 57 import kotlinx.coroutines.flow.Flow 58 import kotlinx.coroutines.flow.MutableSharedFlow 59 import kotlinx.coroutines.flow.collectLatest 60 import kotlinx.coroutines.flow.conflate 61 import kotlinx.coroutines.flow.distinctUntilChanged 62 import kotlinx.coroutines.flow.flowOn 63 import kotlinx.coroutines.flow.map 64 import kotlinx.coroutines.flow.onEach 65 import kotlinx.coroutines.flow.onStart 66 import kotlinx.coroutines.launch 67 import kotlinx.coroutines.yield 68 69 /** 70 * Filters low priority and privacy-sensitive notifications from the lockscreen, and hides section 71 * headers on the lockscreen. If enabled, it will also track and hide seen notifications on the 72 * lockscreen. 73 */ 74 @CoordinatorScope 75 class KeyguardCoordinator 76 @Inject 77 constructor( 78 @Background private val bgDispatcher: CoroutineDispatcher, 79 private val dumpManager: DumpManager, 80 private val headsUpManager: HeadsUpManager, 81 private val keyguardNotificationVisibilityProvider: KeyguardNotificationVisibilityProvider, 82 private val keyguardRepository: KeyguardRepository, 83 private val keyguardTransitionRepository: KeyguardTransitionRepository, 84 private val logger: KeyguardCoordinatorLogger, 85 @Application private val scope: CoroutineScope, 86 private val sectionHeaderVisibilityProvider: SectionHeaderVisibilityProvider, 87 private val secureSettings: SecureSettings, 88 private val seenNotifsProvider: SeenNotificationsProviderImpl, 89 private val statusBarStateController: StatusBarStateController, 90 ) : Coordinator, Dumpable { 91 92 private val unseenNotifications = mutableSetOf<NotificationEntry>() 93 private val unseenEntryAdded = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) 94 private val unseenEntryRemoved = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1) 95 private var unseenFilterEnabled = false 96 97 override fun attach(pipeline: NotifPipeline) { 98 setupInvalidateNotifListCallbacks() 99 // Filter at the "finalize" stage so that views remain bound by PreparationCoordinator 100 pipeline.addFinalizeFilter(notifFilter) 101 keyguardNotificationVisibilityProvider.addOnStateChangedListener(::invalidateListFromFilter) 102 updateSectionHeadersVisibility() 103 attachUnseenFilter(pipeline) 104 } 105 106 private fun attachUnseenFilter(pipeline: NotifPipeline) { 107 pipeline.addFinalizeFilter(unseenNotifFilter) 108 pipeline.addCollectionListener(collectionListener) 109 scope.launch { trackUnseenFilterSettingChanges() } 110 dumpManager.registerDumpable(this) 111 } 112 113 private suspend fun trackSeenNotifications() { 114 // Whether or not keyguard is visible (or occluded). 115 val isKeyguardPresent: Flow<Boolean> = 116 keyguardTransitionRepository.transitions 117 .map { step -> step.to != KeyguardState.GONE } 118 .distinctUntilChanged() 119 .onEach { trackingUnseen -> logger.logTrackingUnseen(trackingUnseen) } 120 121 // Separately track seen notifications while the device is locked, applying once the device 122 // is unlocked. 123 val notificationsSeenWhileLocked = mutableSetOf<NotificationEntry>() 124 125 // Use [collectLatest] to cancel any running jobs when [trackingUnseen] changes. 126 isKeyguardPresent.collectLatest { isKeyguardPresent: Boolean -> 127 if (isKeyguardPresent) { 128 // Keyguard is not gone, notifications need to be visible for a certain threshold 129 // before being marked as seen 130 trackSeenNotificationsWhileLocked(notificationsSeenWhileLocked) 131 } else { 132 // Mark all seen-while-locked notifications as seen for real. 133 if (notificationsSeenWhileLocked.isNotEmpty()) { 134 unseenNotifications.removeAll(notificationsSeenWhileLocked) 135 logger.logAllMarkedSeenOnUnlock( 136 seenCount = notificationsSeenWhileLocked.size, 137 remainingUnseenCount = unseenNotifications.size 138 ) 139 notificationsSeenWhileLocked.clear() 140 } 141 unseenNotifFilter.invalidateList("keyguard no longer showing") 142 // Keyguard is gone, notifications can be immediately marked as seen when they 143 // become visible. 144 trackSeenNotificationsWhileUnlocked() 145 } 146 } 147 } 148 149 /** 150 * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually 151 * been "seen" while the device is on the keyguard. 152 */ 153 private suspend fun trackSeenNotificationsWhileLocked( 154 notificationsSeenWhileLocked: MutableSet<NotificationEntry>, 155 ) = coroutineScope { 156 // Remove removed notifications from the set 157 launch { 158 unseenEntryRemoved.collect { entry -> 159 if (notificationsSeenWhileLocked.remove(entry)) { 160 logger.logRemoveSeenOnLockscreen(entry) 161 } 162 } 163 } 164 // Use collectLatest so that the timeout delay is cancelled if the device enters doze, and 165 // is restarted when doze ends. 166 keyguardRepository.isDozing.collectLatest { isDozing -> 167 if (!isDozing) { 168 trackSeenNotificationsWhileLockedAndNotDozing(notificationsSeenWhileLocked) 169 } 170 } 171 } 172 173 /** 174 * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually 175 * been "seen" while the device is on the keyguard and not dozing. Any new and existing unseen 176 * notifications are not marked as seen until they are visible for the [SEEN_TIMEOUT] duration. 177 */ 178 private suspend fun trackSeenNotificationsWhileLockedAndNotDozing( 179 notificationsSeenWhileLocked: MutableSet<NotificationEntry> 180 ) = coroutineScope { 181 // All child tracking jobs will be cancelled automatically when this is cancelled. 182 val trackingJobsByEntry = mutableMapOf<NotificationEntry, Job>() 183 184 /** 185 * Wait for the user to spend enough time on the lock screen before removing notification 186 * from unseen set upon unlock. 187 */ 188 suspend fun trackSeenDurationThreshold(entry: NotificationEntry) { 189 if (notificationsSeenWhileLocked.remove(entry)) { 190 logger.logResetSeenOnLockscreen(entry) 191 } 192 delay(SEEN_TIMEOUT) 193 notificationsSeenWhileLocked.add(entry) 194 trackingJobsByEntry.remove(entry) 195 logger.logSeenOnLockscreen(entry) 196 } 197 198 /** Stop any unseen tracking when a notification is removed. */ 199 suspend fun stopTrackingRemovedNotifs(): Nothing = 200 unseenEntryRemoved.collect { entry -> 201 trackingJobsByEntry.remove(entry)?.let { 202 it.cancel() 203 logger.logStopTrackingLockscreenSeenDuration(entry) 204 } 205 } 206 207 /** Start tracking new notifications when they are posted. */ 208 suspend fun trackNewUnseenNotifs(): Nothing = coroutineScope { 209 unseenEntryAdded.collect { entry -> 210 logger.logTrackingLockscreenSeenDuration(entry) 211 // If this is an update, reset the tracking. 212 trackingJobsByEntry[entry]?.let { 213 it.cancel() 214 logger.logResetSeenOnLockscreen(entry) 215 } 216 trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) } 217 } 218 } 219 220 // Start tracking for all notifications that are currently unseen. 221 logger.logTrackingLockscreenSeenDuration(unseenNotifications) 222 unseenNotifications.forEach { entry -> 223 trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) } 224 } 225 226 launch { trackNewUnseenNotifs() } 227 launch { stopTrackingRemovedNotifs() } 228 } 229 230 // Track "seen" notifications, marking them as such when either shade is expanded or the 231 // notification becomes heads up. 232 private suspend fun trackSeenNotificationsWhileUnlocked() { 233 coroutineScope { 234 launch { clearUnseenNotificationsWhenShadeIsExpanded() } 235 launch { markHeadsUpNotificationsAsSeen() } 236 } 237 } 238 239 private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() { 240 statusBarStateController.expansionChanges.collectLatest { isExpanded -> 241 // Give keyguard events time to propagate, in case this expansion is part of the 242 // keyguard transition and not the user expanding the shade 243 yield() 244 if (isExpanded) { 245 logger.logShadeExpanded() 246 unseenNotifications.clear() 247 } 248 } 249 } 250 251 private suspend fun markHeadsUpNotificationsAsSeen() { 252 headsUpManager.allEntries 253 .filter { it.isRowPinned } 254 .forEach { unseenNotifications.remove(it) } 255 headsUpManager.headsUpEvents.collect { (entry, isHun) -> 256 if (isHun) { 257 logger.logUnseenHun(entry.key) 258 unseenNotifications.remove(entry) 259 } 260 } 261 } 262 263 private suspend fun trackUnseenFilterSettingChanges() { 264 secureSettings 265 // emit whenever the setting has changed 266 .observerFlow( 267 UserHandle.USER_ALL, 268 Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 269 ) 270 // perform a query immediately 271 .onStart { emit(Unit) } 272 // for each change, lookup the new value 273 .map { 274 secureSettings.getIntForUser( 275 Settings.Secure.LOCK_SCREEN_SHOW_ONLY_UNSEEN_NOTIFICATIONS, 276 UserHandle.USER_CURRENT, 277 ) == 1 278 } 279 // don't emit anything if nothing has changed 280 .distinctUntilChanged() 281 // perform lookups on the bg thread pool 282 .flowOn(bgDispatcher) 283 // only track the most recent emission, if events are happening faster than they can be 284 // consumed 285 .conflate() 286 .collectLatest { setting -> 287 // update local field and invalidate if necessary 288 if (setting != unseenFilterEnabled) { 289 unseenFilterEnabled = setting 290 unseenNotifFilter.invalidateList("unseen setting changed") 291 } 292 // if the setting is enabled, then start tracking and filtering unseen notifications 293 if (setting) { 294 trackSeenNotifications() 295 } 296 } 297 } 298 299 private val collectionListener = 300 object : NotifCollectionListener { 301 override fun onEntryAdded(entry: NotificationEntry) { 302 if ( 303 keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded 304 ) { 305 logger.logUnseenAdded(entry.key) 306 unseenNotifications.add(entry) 307 unseenEntryAdded.tryEmit(entry) 308 } 309 } 310 311 override fun onEntryUpdated(entry: NotificationEntry) { 312 if ( 313 keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded 314 ) { 315 logger.logUnseenUpdated(entry.key) 316 unseenNotifications.add(entry) 317 unseenEntryAdded.tryEmit(entry) 318 } 319 } 320 321 override fun onEntryRemoved(entry: NotificationEntry, reason: Int) { 322 if (unseenNotifications.remove(entry)) { 323 logger.logUnseenRemoved(entry.key) 324 unseenEntryRemoved.tryEmit(entry) 325 } 326 } 327 } 328 329 @VisibleForTesting 330 internal val unseenNotifFilter = 331 object : NotifFilter("$TAG-unseen") { 332 333 var hasFilteredAnyNotifs = false 334 335 override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean = 336 when { 337 // Don't apply filter if the setting is disabled 338 !unseenFilterEnabled -> false 339 // Don't apply filter if the keyguard isn't currently showing 340 !keyguardRepository.isKeyguardShowing() -> false 341 // Don't apply the filter if the notification is unseen 342 unseenNotifications.contains(entry) -> false 343 // Don't apply the filter to (non-promoted) group summaries 344 // - summary will be pruned if necessary, depending on if children are filtered 345 entry.parent?.summary == entry -> false 346 // Check that the entry satisfies certain characteristics that would bypass the 347 // filter 348 shouldIgnoreUnseenCheck(entry) -> false 349 else -> true 350 }.also { hasFiltered -> hasFilteredAnyNotifs = hasFilteredAnyNotifs || hasFiltered } 351 352 override fun onCleanup() { 353 logger.logProviderHasFilteredOutSeenNotifs(hasFilteredAnyNotifs) 354 seenNotifsProvider.hasFilteredOutSeenNotifications = hasFilteredAnyNotifs 355 hasFilteredAnyNotifs = false 356 } 357 } 358 359 private val notifFilter: NotifFilter = 360 object : NotifFilter(TAG) { 361 override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean = 362 keyguardNotificationVisibilityProvider.shouldHideNotification(entry) 363 } 364 365 private fun shouldIgnoreUnseenCheck(entry: NotificationEntry): Boolean = 366 when { 367 entry.isMediaNotification -> true 368 entry.sbn.isOngoing -> true 369 else -> false 370 } 371 372 // TODO(b/206118999): merge this class with SensitiveContentCoordinator which also depends on 373 // these same updates 374 private fun setupInvalidateNotifListCallbacks() {} 375 376 private fun invalidateListFromFilter(reason: String) { 377 updateSectionHeadersVisibility() 378 notifFilter.invalidateList(reason) 379 } 380 381 private fun updateSectionHeadersVisibility() { 382 val onKeyguard = statusBarStateController.state == StatusBarState.KEYGUARD 383 val neverShowSections = sectionHeaderVisibilityProvider.neverShowSectionHeaders 384 val showSections = !onKeyguard && !neverShowSections 385 sectionHeaderVisibilityProvider.sectionHeadersVisible = showSections 386 } 387 388 override fun dump(pw: PrintWriter, args: Array<out String>) = 389 with(pw.asIndenting()) { 390 println( 391 "seenNotifsProvider.hasFilteredOutSeenNotifications=" + 392 seenNotifsProvider.hasFilteredOutSeenNotifications 393 ) 394 println("unseen notifications:") 395 indentIfPossible { 396 for (notification in unseenNotifications) { 397 println(notification.key) 398 } 399 } 400 } 401 402 companion object { 403 private const val TAG = "KeyguardCoordinator" 404 private val SEEN_TIMEOUT = 5.seconds 405 } 406 } 407