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