• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.statusbar.notification.collection.coordinator
18 
19 import android.annotation.SuppressLint
20 import androidx.annotation.VisibleForTesting
21 import com.android.app.tracing.coroutines.launchTraced as launch
22 import com.android.systemui.Dumpable
23 import com.android.systemui.dagger.qualifiers.Application
24 import com.android.systemui.dump.DumpManager
25 import com.android.systemui.keyguard.data.repository.KeyguardRepository
26 import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
27 import com.android.systemui.keyguard.shared.model.KeyguardState
28 import com.android.systemui.plugins.statusbar.StatusBarStateController
29 import com.android.systemui.scene.domain.interactor.SceneInteractor
30 import com.android.systemui.scene.shared.flag.SceneContainerFlag
31 import com.android.systemui.scene.shared.model.Scenes
32 import com.android.systemui.statusbar.expansionChanges
33 import com.android.systemui.statusbar.notification.collection.GroupEntry
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.notifcollection.UpdateSource
40 import com.android.systemui.statusbar.notification.domain.interactor.SeenNotificationsInteractor
41 import com.android.systemui.statusbar.notification.headsup.HeadsUpManager
42 import com.android.systemui.statusbar.notification.headsup.headsUpEvents
43 import com.android.systemui.util.asIndenting
44 import com.android.systemui.util.indentIfPossible
45 import java.io.PrintWriter
46 import javax.inject.Inject
47 import kotlin.time.Duration.Companion.seconds
48 import kotlinx.coroutines.CoroutineScope
49 import kotlinx.coroutines.Job
50 import kotlinx.coroutines.coroutineScope
51 import kotlinx.coroutines.delay
52 import kotlinx.coroutines.flow.Flow
53 import kotlinx.coroutines.flow.MutableSharedFlow
54 import kotlinx.coroutines.flow.collectLatest
55 import kotlinx.coroutines.flow.distinctUntilChanged
56 import kotlinx.coroutines.flow.map
57 import kotlinx.coroutines.flow.onEach
58 import kotlinx.coroutines.yield
59 
60 /**
61  * If the setting is enabled, this will track and hide seen notifications on the lockscreen.
62  *
63  * This is the "original" unseen keyguard coordinator because this is the logic originally developed
64  * for large screen devices where showing "seen" notifications on the lock screen was distracting.
65  * Moreover, this file was created during a project that will replace this logic, so the
66  * [LockScreenMinimalismCoordinator] is the expected replacement of this file.
67  */
68 @CoordinatorScope
69 @SuppressLint("SharedFlowCreation")
70 class OriginalUnseenKeyguardCoordinator
71 @Inject
72 constructor(
73     private val dumpManager: DumpManager,
74     private val headsUpManager: HeadsUpManager,
75     private val keyguardRepository: KeyguardRepository,
76     private val keyguardTransitionInteractor: KeyguardTransitionInteractor,
77     private val logger: KeyguardCoordinatorLogger,
78     @Application private val scope: CoroutineScope,
79     private val seenNotificationsInteractor: SeenNotificationsInteractor,
80     private val statusBarStateController: StatusBarStateController,
81     private val sceneInteractor: SceneInteractor,
82 ) : Coordinator, Dumpable {
83 
84     private val unseenNotifications = mutableSetOf<NotificationEntry>()
85     private val unseenEntryAdded = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1)
86     private val unseenEntryRemoved = MutableSharedFlow<NotificationEntry>(extraBufferCapacity = 1)
87     private var unseenFilterEnabled = false
88 
89     override fun attach(pipeline: NotifPipeline) {
90         pipeline.addFinalizeFilter(unseenNotifFilter)
91         pipeline.addCollectionListener(collectionListener)
92         scope.launch { trackUnseenFilterSettingChanges() }
93         dumpManager.registerDumpable(this)
94     }
95 
96     private suspend fun trackSeenNotifications() {
97         // Whether or not keyguard is visible (or occluded).
98         @Suppress("DEPRECATION")
99         val isKeyguardPresentFlow: Flow<Boolean> =
100             if (SceneContainerFlag.isEnabled) {
101                     sceneInteractor.transitionState.map {
102                         !it.isTransitioning(to = Scenes.Gone) && !it.isIdle(Scenes.Gone)
103                     }
104                 } else {
105                     keyguardTransitionInteractor.transitions.map { step ->
106                         step.to != KeyguardState.GONE
107                     }
108                 }
109                 .distinctUntilChanged()
110                 .onEach { trackingUnseen -> logger.logTrackingUnseen(trackingUnseen) }
111 
112         // Separately track seen notifications while the device is locked, applying once the device
113         // is unlocked.
114         val notificationsSeenWhileLocked = mutableSetOf<NotificationEntry>()
115 
116         // Use [collectLatest] to cancel any running jobs when [trackingUnseen] changes.
117         isKeyguardPresentFlow.collectLatest { isKeyguardPresent: Boolean ->
118             if (isKeyguardPresent) {
119                 // Keyguard is not gone, notifications need to be visible for a certain threshold
120                 // before being marked as seen
121                 trackSeenNotificationsWhileLocked(notificationsSeenWhileLocked)
122             } else {
123                 // Mark all seen-while-locked notifications as seen for real.
124                 if (notificationsSeenWhileLocked.isNotEmpty()) {
125                     unseenNotifications.removeAll(notificationsSeenWhileLocked)
126                     logger.logAllMarkedSeenOnUnlock(
127                         seenCount = notificationsSeenWhileLocked.size,
128                         remainingUnseenCount = unseenNotifications.size,
129                     )
130                     notificationsSeenWhileLocked.clear()
131                 }
132                 unseenNotifFilter.invalidateList("keyguard no longer showing")
133                 // Keyguard is gone, notifications can be immediately marked as seen when they
134                 // become visible.
135                 trackSeenNotificationsWhileUnlocked()
136             }
137         }
138     }
139 
140     /**
141      * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually
142      * been "seen" while the device is on the keyguard.
143      */
144     private suspend fun trackSeenNotificationsWhileLocked(
145         notificationsSeenWhileLocked: MutableSet<NotificationEntry>
146     ) = coroutineScope {
147         // Remove removed notifications from the set
148         launch {
149             unseenEntryRemoved.collect { entry ->
150                 if (notificationsSeenWhileLocked.remove(entry)) {
151                     logger.logRemoveSeenOnLockscreen(entry)
152                 }
153             }
154         }
155         // Use collectLatest so that the timeout delay is cancelled if the device enters doze, and
156         // is restarted when doze ends.
157         keyguardRepository.isDozing.collectLatest { isDozing ->
158             if (!isDozing) {
159                 trackSeenNotificationsWhileLockedAndNotDozing(notificationsSeenWhileLocked)
160             }
161         }
162     }
163 
164     /**
165      * Keep [notificationsSeenWhileLocked] updated to represent which notifications have actually
166      * been "seen" while the device is on the keyguard and not dozing. Any new and existing unseen
167      * notifications are not marked as seen until they are visible for the [SEEN_TIMEOUT] duration.
168      */
169     private suspend fun trackSeenNotificationsWhileLockedAndNotDozing(
170         notificationsSeenWhileLocked: MutableSet<NotificationEntry>
171     ) = coroutineScope {
172         // All child tracking jobs will be cancelled automatically when this is cancelled.
173         val trackingJobsByEntry = mutableMapOf<NotificationEntry, Job>()
174 
175         /**
176          * Wait for the user to spend enough time on the lock screen before removing notification
177          * from unseen set upon unlock.
178          */
179         suspend fun trackSeenDurationThreshold(entry: NotificationEntry) {
180             if (notificationsSeenWhileLocked.remove(entry)) {
181                 logger.logResetSeenOnLockscreen(entry)
182             }
183             delay(SEEN_TIMEOUT)
184             notificationsSeenWhileLocked.add(entry)
185             trackingJobsByEntry.remove(entry)
186             logger.logSeenOnLockscreen(entry)
187         }
188 
189         /** Stop any unseen tracking when a notification is removed. */
190         suspend fun stopTrackingRemovedNotifs(): Nothing =
191             unseenEntryRemoved.collect { entry ->
192                 trackingJobsByEntry.remove(entry)?.let {
193                     it.cancel()
194                     logger.logStopTrackingLockscreenSeenDuration(entry)
195                 }
196             }
197 
198         /** Start tracking new notifications when they are posted. */
199         suspend fun trackNewUnseenNotifs(): Nothing = coroutineScope {
200             unseenEntryAdded.collect { entry ->
201                 logger.logTrackingLockscreenSeenDuration(entry)
202                 // If this is an update, reset the tracking.
203                 trackingJobsByEntry[entry]?.let {
204                     it.cancel()
205                     logger.logResetSeenOnLockscreen(entry)
206                 }
207                 trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) }
208             }
209         }
210 
211         // Start tracking for all notifications that are currently unseen.
212         logger.logTrackingLockscreenSeenDuration(unseenNotifications)
213         unseenNotifications.forEach { entry ->
214             trackingJobsByEntry[entry] = launch { trackSeenDurationThreshold(entry) }
215         }
216 
217         launch { trackNewUnseenNotifs() }
218         launch { stopTrackingRemovedNotifs() }
219     }
220 
221     // Track "seen" notifications, marking them as such when either shade is expanded or the
222     // notification becomes heads up.
223     private suspend fun trackSeenNotificationsWhileUnlocked() {
224         coroutineScope {
225             launch { clearUnseenNotificationsWhenShadeIsExpanded() }
226             launch { markHeadsUpNotificationsAsSeen() }
227         }
228     }
229 
230     private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() {
231         statusBarStateController.expansionChanges.collectLatest { isExpanded ->
232             // Give keyguard events time to propagate, in case this expansion is part of the
233             // keyguard transition and not the user expanding the shade
234             yield()
235             if (isExpanded) {
236                 logger.logShadeExpanded()
237                 unseenNotifications.clear()
238             }
239         }
240     }
241 
242     private suspend fun markHeadsUpNotificationsAsSeen() {
243         headsUpManager.allEntries
244             .filter { it.isRowPinned }
245             .forEach { unseenNotifications.remove(it) }
246         headsUpManager.headsUpEvents.collect { (entry, isHun) ->
247             if (isHun) {
248                 logger.logUnseenHun(entry.key)
249                 unseenNotifications.remove(entry)
250             }
251         }
252     }
253 
254     private fun unseenFeatureEnabled(): Flow<Boolean> {
255         return seenNotificationsInteractor.isLockScreenShowOnlyUnseenNotificationsEnabled()
256     }
257 
258     private suspend fun trackUnseenFilterSettingChanges() {
259         unseenFeatureEnabled().collectLatest { setting ->
260             // update local field and invalidate if necessary
261             if (setting != unseenFilterEnabled) {
262                 unseenFilterEnabled = setting
263                 unseenNotifFilter.invalidateList("unseen setting changed")
264             }
265             // if the setting is enabled, then start tracking and filtering unseen notifications
266             if (setting) {
267                 trackSeenNotifications()
268             }
269         }
270     }
271 
272     private val collectionListener =
273         object : NotifCollectionListener {
274             override fun onEntryAdded(entry: NotificationEntry) {
275                 if (
276                     keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded
277                 ) {
278                     logger.logUnseenAdded(entry.key, entry.sbn.postTime)
279                     unseenNotifications.add(entry)
280                     unseenEntryAdded.tryEmit(entry)
281                 }
282             }
283 
284             override fun onEntryUpdated(entry: NotificationEntry, source: UpdateSource) {
285                 if (
286                     keyguardRepository.isKeyguardShowing() || !statusBarStateController.isExpanded
287                 ) {
288                     logger.logUnseenUpdated(entry.key, source, entry.sbn.postTime)
289                     // We are not marking a notif as unseen if it's updated by the SystemServer
290                     // (for example, auto-grouping), or the SystemUi, not the App.
291                     if (source != UpdateSource.App) {
292                         return
293                     }
294                     unseenNotifications.add(entry)
295                     unseenEntryAdded.tryEmit(entry)
296                 }
297             }
298 
299             override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
300                 if (unseenNotifications.remove(entry)) {
301                     logger.logUnseenRemoved(entry.key)
302                     unseenEntryRemoved.tryEmit(entry)
303                 }
304             }
305         }
306 
307     @VisibleForTesting
308     val unseenNotifFilter =
309         object : NotifFilter(TAG) {
310 
311             var hasFilteredAnyNotifs = false
312 
313             override fun shouldFilterOut(entry: NotificationEntry, now: Long): Boolean =
314                 when {
315                     // Don't apply filter if the setting is disabled
316                     !unseenFilterEnabled -> false
317                     // Don't apply filter if the keyguard isn't currently showing
318                     !keyguardRepository.isKeyguardShowing() -> false
319                     // Don't apply the filter if the notification is unseen
320                     unseenNotifications.contains(entry) -> false
321                     // Don't apply the filter to (non-promoted) group summaries
322                     //  - summary will be pruned if necessary, depending on if children are filtered
323                     (entry.parent as? GroupEntry)?.summary == entry -> false
324                     // Check that the entry satisfies certain characteristics that would bypass the
325                     // filter
326                     shouldIgnoreUnseenCheck(entry) -> false
327                     else -> true
328                 }.also { hasFiltered -> hasFilteredAnyNotifs = hasFilteredAnyNotifs || hasFiltered }
329 
330             override fun onCleanup() {
331                 logger.logProviderHasFilteredOutSeenNotifs(hasFilteredAnyNotifs)
332                 seenNotificationsInteractor.setHasFilteredOutSeenNotifications(hasFilteredAnyNotifs)
333                 hasFilteredAnyNotifs = false
334             }
335         }
336 
337     private fun shouldIgnoreUnseenCheck(entry: NotificationEntry): Boolean =
338         when {
339             entry.isMediaNotification -> true
340             entry.sbn.isOngoing -> true
341             else -> false
342         }
343 
344     override fun dump(pw: PrintWriter, args: Array<out String>) =
345         with(pw.asIndenting()) {
346             println(
347                 "notificationListInteractor.hasFilteredOutSeenNotifications.value=" +
348                     seenNotificationsInteractor.hasFilteredOutSeenNotifications.value
349             )
350             println("unseen notifications:")
351             indentIfPossible {
352                 for (notification in unseenNotifications) {
353                     println(notification.key)
354                 }
355             }
356         }
357 
358     companion object {
359         private const val TAG = "OriginalUnseenKeyguardCoordinator"
360         private val SEEN_TIMEOUT = 5.seconds
361     }
362 }
363