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.chips.notification.domain.interactor 18 19 import android.annotation.SuppressLint 20 import com.android.app.tracing.coroutines.launchTraced as launch 21 import com.android.systemui.CoreStartable 22 import com.android.systemui.dagger.SysUISingleton 23 import com.android.systemui.dagger.qualifiers.Background 24 import com.android.systemui.log.LogBuffer 25 import com.android.systemui.log.core.Logger 26 import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad 27 import com.android.systemui.statusbar.chips.StatusBarChipsLog 28 import com.android.systemui.statusbar.chips.notification.domain.model.NotificationChipModel 29 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips 30 import com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsInteractor 31 import com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsInteractor.Companion.isOngoingCallNotification 32 import com.android.systemui.util.kotlin.pairwise 33 import com.android.systemui.util.time.SystemClock 34 import javax.inject.Inject 35 import kotlin.math.max 36 import kotlinx.coroutines.CoroutineScope 37 import kotlinx.coroutines.flow.Flow 38 import kotlinx.coroutines.flow.MutableSharedFlow 39 import kotlinx.coroutines.flow.MutableStateFlow 40 import kotlinx.coroutines.flow.SharedFlow 41 import kotlinx.coroutines.flow.SharingStarted 42 import kotlinx.coroutines.flow.asSharedFlow 43 import kotlinx.coroutines.flow.combine 44 import kotlinx.coroutines.flow.distinctUntilChanged 45 import kotlinx.coroutines.flow.flatMapLatest 46 import kotlinx.coroutines.flow.flowOf 47 import kotlinx.coroutines.flow.map 48 import kotlinx.coroutines.flow.onEach 49 import kotlinx.coroutines.flow.stateIn 50 51 /** An interactor for the notification chips shown in the status bar. */ 52 @SysUISingleton 53 class StatusBarNotificationChipsInteractor 54 @Inject 55 constructor( 56 @Background private val backgroundScope: CoroutineScope, 57 private val systemClock: SystemClock, 58 private val activeNotificationsInteractor: ActiveNotificationsInteractor, 59 private val singleNotificationChipInteractorFactory: SingleNotificationChipInteractor.Factory, 60 @StatusBarChipsLog private val logBuffer: LogBuffer, 61 ) : CoreStartable { 62 private val logger = Logger(logBuffer, "AllNotifs".pad()) 63 64 // Each chip tap is an individual event, *not* a state, which is why we're using SharedFlow not 65 // StateFlow. There shouldn't be multiple updates per frame, which should avoid performance 66 // problems. 67 @SuppressLint("SharedFlowCreation") 68 private val _promotedNotificationChipTapEvent = MutableSharedFlow<String>() 69 70 /** 71 * SharedFlow that emits each time a promoted notification's status bar chip is tapped. The 72 * emitted value is the promoted notification's key. 73 */ 74 val promotedNotificationChipTapEvent: SharedFlow<String> = 75 _promotedNotificationChipTapEvent.asSharedFlow() 76 77 suspend fun onPromotedNotificationChipTapped(key: String) { 78 StatusBarNotifChips.unsafeAssertInNewMode() 79 _promotedNotificationChipTapEvent.emit(key) 80 } 81 82 /** 83 * A cache of interactors. Each currently-promoted notification should have a corresponding 84 * interactor in this map. 85 */ 86 private val promotedNotificationInteractorMap = 87 mutableMapOf<String, SingleNotificationChipInteractor>() 88 89 /** 90 * A list of interactors. Each currently-promoted notification should have a corresponding 91 * interactor in this list. 92 */ 93 private val promotedNotificationInteractors = 94 MutableStateFlow<List<SingleNotificationChipInteractor>>(emptyList()) 95 96 /** 97 * The notifications that are promoted and ongoing. 98 * 99 * Explicitly does *not* include any ongoing call notifications, even if the call notifications 100 * meet the promotion criteria. Those call notifications will be handled by 101 * [com.android.systemui.statusbar.chips.call.domain.CallChipInteractor] instead. See 102 * b/388521980. 103 */ 104 private val promotedOngoingNotifications = 105 activeNotificationsInteractor.promotedOngoingNotifications.map { notifs -> 106 notifs.filterNot { it.isOngoingCallNotification() } 107 } 108 109 override fun start() { 110 if (!StatusBarNotifChips.isEnabled) { 111 return 112 } 113 114 backgroundScope.launch("StatusBarNotificationChipsInteractor") { 115 promotedOngoingNotifications.pairwise(initialValue = emptyList()).collect { 116 (oldNotifs, currentNotifs) -> 117 val removedNotifKeys = 118 oldNotifs.map { it.key }.minus(currentNotifs.map { it.key }.toSet()) 119 removedNotifKeys.forEach { removedNotifKey -> 120 val wasRemoved = promotedNotificationInteractorMap.remove(removedNotifKey) 121 if (wasRemoved == null) { 122 logger.w({ 123 "Attempted to remove $str1 from interactor map but it wasn't present" 124 }) { 125 str1 = removedNotifKey 126 } 127 } 128 } 129 130 currentNotifs.forEach { notif -> 131 val interactor = 132 promotedNotificationInteractorMap.computeIfAbsent(notif.key) { 133 singleNotificationChipInteractorFactory.create( 134 notif, 135 creationTime = systemClock.currentTimeMillis(), 136 ) 137 } 138 interactor.setNotification(notif) 139 } 140 promotedNotificationInteractors.value = 141 promotedNotificationInteractorMap.values.toList() 142 } 143 } 144 } 145 146 /** 147 * Emits all notifications that are eligible to show as chips in the status bar. This is 148 * different from which chips will *actually* show, because 149 * [com.android.systemui.statusbar.chips.notification.ui.viewmodel.NotifChipsViewModel] will 150 * hide chips that have [NotificationChipModel.isAppVisible] as true. 151 */ 152 val allNotificationChips: Flow<List<NotificationChipModel>> = 153 if (StatusBarNotifChips.isEnabled) { 154 // For all our current interactors... 155 promotedNotificationInteractors.flatMapLatest { interactors -> 156 if (interactors.isNotEmpty()) { 157 // Combine each interactor's [notificationChip] flow... 158 val allNotificationChips: List<Flow<NotificationChipModel?>> = 159 interactors.map { interactor -> interactor.notificationChip } 160 combine(allNotificationChips) { 161 // ... and emit just the non-null & sorted chips 162 it.filterNotNull().sortedWith(chipComparator) 163 } 164 } else { 165 flowOf(emptyList()) 166 } 167 } 168 } else { 169 flowOf(emptyList()) 170 } 171 .distinctUntilChanged() 172 .logSort() 173 .stateIn( 174 backgroundScope, 175 SharingStarted.WhileSubscribed( 176 // When a promoted notification is added or removed, the `.flatMapLatest` above 177 // will stop collection and then re-start collection on each individual 178 // interactor's flow. (It will happen even for a chip that didn't change.) We 179 // don't want the individual interactor flows to stop then re-start because they 180 // may be maintaining values that would get thrown away when collection stops 181 // (like an app's last visible time). 182 // stopTimeoutMillis ensures we maintain those values even during the brief 183 // moment (1-2ms) when `.flatMapLatest` causes collect to stop then immediately 184 // restart. 185 // Note: Channels could also work to solve this. 186 stopTimeoutMillis = 1000 187 ), 188 initialValue = emptyList(), 189 ) 190 191 /* 192 Stable sort the promoted notifications by two criteria: 193 Criteria #1: Whichever app was most recently visible has higher ranking. 194 - Reasoning: If a user opened the app to see additional information, that's 195 likely the most important ongoing notification. 196 Criteria #2: Whichever notification first appeared more recently has higher ranking. 197 - Reasoning: Older chips get hidden if there's not enough room for all chips. 198 This semi-stable ordering ensures: 199 1) The chips don't switch places if the older chip gets a notification update. 200 2) The chips don't switch places when the second chip is tapped. (Whichever 201 notification is showing heads-up is considered to be the top notification, which 202 means tapping the second chip would move it to be the first chip if we didn't 203 sort by appearance time here.) 204 */ 205 private val chipComparator = 206 compareByDescending<NotificationChipModel> { 207 max(it.creationTime, it.lastAppVisibleTime ?: Long.MIN_VALUE) 208 } 209 210 private fun Flow<List<NotificationChipModel>>.logSort(): Flow<List<NotificationChipModel>> { 211 return this.onEach { chips -> 212 val logString = 213 chips.joinToString { 214 "{key=${it.key}. " + 215 "lastVisibleAppTime=${it.lastAppVisibleTime}. " + 216 "creationTime=${it.creationTime}}" 217 } 218 logger.d({ "Sorted notif chips: $str1" }) { str1 = logString } 219 } 220 } 221 } 222