• 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 android.app.NotificationManager
21 import androidx.annotation.VisibleForTesting
22 import com.android.app.tracing.coroutines.launchTraced as launch
23 import com.android.systemui.Dumpable
24 import com.android.systemui.dagger.qualifiers.Application
25 import com.android.systemui.dump.DumpManager
26 import com.android.systemui.plugins.statusbar.StatusBarStateController
27 import com.android.systemui.shade.domain.interactor.ShadeInteractor
28 import com.android.systemui.statusbar.StatusBarState
29 import com.android.systemui.statusbar.notification.collection.BundleEntry
30 import com.android.systemui.statusbar.notification.collection.GroupEntry
31 import com.android.systemui.statusbar.notification.collection.PipelineEntry
32 import com.android.systemui.statusbar.notification.collection.NotifPipeline
33 import com.android.systemui.statusbar.notification.collection.NotificationEntry
34 import com.android.systemui.statusbar.notification.collection.coordinator.dagger.CoordinatorScope
35 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifPromoter
36 import com.android.systemui.statusbar.notification.collection.listbuilder.pluggable.NotifSectioner
37 import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
38 import com.android.systemui.statusbar.notification.domain.interactor.HeadsUpNotificationInteractor
39 import com.android.systemui.statusbar.notification.domain.interactor.SeenNotificationsInteractor
40 import com.android.systemui.statusbar.notification.shared.NotificationMinimalism
41 import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_ONGOING
42 import com.android.systemui.statusbar.notification.stack.BUCKET_TOP_UNSEEN
43 import com.android.systemui.util.asIndenting
44 import com.android.systemui.util.printCollection
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.coroutineScope
50 import kotlinx.coroutines.delay
51 import kotlinx.coroutines.flow.Flow
52 import kotlinx.coroutines.flow.collectLatest
53 import kotlinx.coroutines.flow.flowOf
54 import kotlinx.coroutines.flow.map
55 
56 /**
57  * If the setting is enabled, this will track seen notifications and ensure that they only show in
58  * the shelf on the lockscreen.
59  *
60  * This class is a replacement of the [OriginalUnseenKeyguardCoordinator].
61  */
62 @CoordinatorScope
63 @SuppressLint("SharedFlowCreation")
64 class LockScreenMinimalismCoordinator
65 @Inject
66 constructor(
67     private val dumpManager: DumpManager,
68     private val headsUpInteractor: HeadsUpNotificationInteractor,
69     private val logger: LockScreenMinimalismCoordinatorLogger,
70     @Application private val scope: CoroutineScope,
71     private val seenNotificationsInteractor: SeenNotificationsInteractor,
72     private val statusBarStateController: StatusBarStateController,
73     private val shadeInteractor: ShadeInteractor,
74 ) : Coordinator, Dumpable {
75 
76     private val unseenNotifications = mutableSetOf<NotificationEntry>()
77     private var isShadeVisible = false
78     private var minimalismEnabled = false
79 
80     override fun attach(pipeline: NotifPipeline) {
81         if (NotificationMinimalism.isUnexpectedlyInLegacyMode()) {
82             return
83         }
84         pipeline.addPromoter(unseenNotifPromoter)
85         pipeline.addOnBeforeTransformGroupsListener(::pickOutTopUnseenNotifs)
86         pipeline.addCollectionListener(collectionListener)
87         scope.launch { trackLockScreenNotificationMinimalismSettingChanges() }
88         dumpManager.registerDumpable(this)
89     }
90 
91     private suspend fun trackSeenNotifications() {
92         coroutineScope {
93             launch { clearUnseenNotificationsWhenShadeIsExpanded() }
94             launch { markHeadsUpNotificationsAsSeen() }
95         }
96     }
97 
98     private suspend fun clearUnseenNotificationsWhenShadeIsExpanded() {
99         shadeInteractor.isShadeFullyExpanded.collectLatest { isExpanded ->
100             // Give keyguard events time to propagate, in case this expansion is part of the
101             // keyguard transition and not the user expanding the shade
102             delay(SHADE_VISIBLE_SEEN_TIMEOUT)
103             isShadeVisible = isExpanded
104             if (isExpanded) {
105                 logger.logShadeVisible(unseenNotifications.size)
106                 unseenNotifications.clear()
107                 // no need to invalidateList; filtering is inactive while shade is open
108             } else {
109                 logger.logShadeHidden()
110             }
111         }
112     }
113 
114     private suspend fun markHeadsUpNotificationsAsSeen() {
115         headsUpInteractor.topHeadsUpRowIfPinned
116             .map { it?.let { headsUpInteractor.notificationKey(it) } }
117             .collectLatest { key ->
118                 if (key == null) {
119                     logger.logTopHeadsUpRow(key = null, wasUnseenWhenPinned = false)
120                 } else {
121                     val wasUnseenWhenPinned = unseenNotifications.any { it.key == key }
122                     logger.logTopHeadsUpRow(key, wasUnseenWhenPinned)
123                     if (wasUnseenWhenPinned) {
124                         delay(HEADS_UP_SEEN_TIMEOUT)
125                         val wasUnseenAfterDelay = unseenNotifications.removeIf { it.key == key }
126                         logger.logHunHasBeenSeen(key, wasUnseenAfterDelay)
127                         // no need to invalidateList; nothing should change until after heads up
128                     }
129                 }
130             }
131     }
132 
133     private fun minimalismFeatureSettingEnabled(): Flow<Boolean> {
134         if (!NotificationMinimalism.isEnabled) {
135             return flowOf(false)
136         }
137         return seenNotificationsInteractor.isLockScreenNotificationMinimalismEnabled()
138     }
139 
140     private suspend fun trackLockScreenNotificationMinimalismSettingChanges() {
141         // Only filter the seen notifs when the lock screen minimalism feature settings is on.
142         minimalismFeatureSettingEnabled().collectLatest { isMinimalismSettingEnabled ->
143             // update local field and invalidate if necessary
144             if (isMinimalismSettingEnabled != minimalismEnabled) {
145                 minimalismEnabled = isMinimalismSettingEnabled
146                 unseenNotifications.clear()
147                 unseenNotifPromoter.invalidateList("unseen setting changed")
148             }
149             // if the setting is enabled, then start tracking and filtering unseen notifications
150             logger.logTrackingUnseen(isMinimalismSettingEnabled)
151             if (isMinimalismSettingEnabled) {
152                 trackSeenNotifications()
153             }
154         }
155     }
156 
157     private val collectionListener =
158         object : NotifCollectionListener {
159             override fun onEntryAdded(entry: NotificationEntry) {
160                 if (minimalismEnabled && !isShadeVisible) {
161                     logger.logUnseenAdded(entry.key)
162                     unseenNotifications.add(entry)
163                 }
164             }
165 
166             override fun onEntryUpdated(entry: NotificationEntry) {
167                 if (minimalismEnabled && !isShadeVisible) {
168                     logger.logUnseenUpdated(entry.key)
169                     unseenNotifications.add(entry)
170                 }
171             }
172 
173             override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
174                 if (minimalismEnabled && unseenNotifications.remove(entry)) {
175                     logger.logUnseenRemoved(entry.key)
176                 }
177             }
178         }
179 
180     private fun pickOutTopUnseenNotifs(list: List<PipelineEntry>) {
181         if (NotificationMinimalism.isUnexpectedlyInLegacyMode()) return
182         if (!minimalismEnabled) return
183         // Only ever elevate a top unseen notification on keyguard, not even locked shade
184         if (statusBarStateController.state != StatusBarState.KEYGUARD) {
185             seenNotificationsInteractor.setTopOngoingNotification(null)
186             seenNotificationsInteractor.setTopUnseenNotification(null)
187             return
188         }
189         // On keyguard pick the top-ranked unseen or ongoing notification to elevate
190         val nonSummaryEntries: Sequence<NotificationEntry> =
191             list
192                 .asSequence()
193                 .flatMap {
194                     when (it) {
195                         is NotificationEntry -> listOfNotNull(it)
196                         is GroupEntry -> it.children
197                         is BundleEntry -> emptyList()
198                         else -> error("unhandled type of $it")
199                     }
200                 }
201                 .filter { it.importance >= NotificationManager.IMPORTANCE_DEFAULT }
202         seenNotificationsInteractor.setTopOngoingNotification(
203             nonSummaryEntries
204                 .filter { ColorizedFgsCoordinator.isRichOngoing(it) }
205                 .minByOrNull { it.ranking.rank }
206         )
207         seenNotificationsInteractor.setTopUnseenNotification(
208             nonSummaryEntries
209                 .filter { !ColorizedFgsCoordinator.isRichOngoing(it) && it in unseenNotifications }
210                 .minByOrNull { it.ranking.rank }
211         )
212     }
213 
214     @VisibleForTesting
215     val unseenNotifPromoter =
216         object : NotifPromoter(TAG) {
217             override fun shouldPromoteToTopLevel(child: NotificationEntry): Boolean =
218                 when {
219                     NotificationMinimalism.isUnexpectedlyInLegacyMode() -> false
220                     !minimalismEnabled -> false
221                     seenNotificationsInteractor.isTopOngoingNotification(child) -> true
222                     !NotificationMinimalism.ungroupTopUnseen -> false
223                     else -> seenNotificationsInteractor.isTopUnseenNotification(child)
224                 }
225         }
226 
227     val topOngoingSectioner =
228         object : NotifSectioner("TopOngoing", BUCKET_TOP_ONGOING) {
229             override fun isInSection(entry: PipelineEntry): Boolean {
230                 if (NotificationMinimalism.isUnexpectedlyInLegacyMode()) return false
231                 if (!minimalismEnabled) return false
232                 if (BundleUtil.isClassified(entry)) return false
233                 return entry.anyEntry { notificationEntry ->
234                     seenNotificationsInteractor.isTopOngoingNotification(notificationEntry)
235                 }
236             }
237         }
238 
239     val topUnseenSectioner =
240         object : NotifSectioner("TopUnseen", BUCKET_TOP_UNSEEN) {
241             override fun isInSection(entry: PipelineEntry): Boolean {
242                 if (NotificationMinimalism.isUnexpectedlyInLegacyMode()) return false
243                 if (!minimalismEnabled) return false
244                 if (BundleUtil.isClassified(entry)) return false
245                 return entry.anyEntry { notificationEntry ->
246                     seenNotificationsInteractor.isTopUnseenNotification(notificationEntry)
247                 }
248             }
249         }
250 
251     private fun PipelineEntry.anyEntry(predicate: (NotificationEntry?) -> Boolean) =
252         when {
253             predicate(representativeEntry) -> true
254             this !is GroupEntry -> false
255             else -> children.any(predicate)
256         }
257 
258     override fun dump(pw: PrintWriter, args: Array<out String>) =
259         with(pw.asIndenting()) {
260             seenNotificationsInteractor.dump(this)
261             printCollection("unseen notifications", unseenNotifications) { println(it.key) }
262         }
263 
264     companion object {
265         private const val TAG = "LockScreenMinimalismCoordinator"
266         private val SHADE_VISIBLE_SEEN_TIMEOUT = 0.25.seconds
267         private val HEADS_UP_SEEN_TIMEOUT = 0.75.seconds
268     }
269 }
270