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