• 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.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