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