• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2025 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.promoted.domain.interactor
18 
19 import com.android.systemui.dagger.SysUISingleton
20 import com.android.systemui.dagger.qualifiers.Background
21 import com.android.systemui.statusbar.chips.call.domain.interactor.CallChipInteractor
22 import com.android.systemui.statusbar.chips.mediaprojection.domain.interactor.MediaProjectionChipInteractor
23 import com.android.systemui.statusbar.chips.mediaprojection.domain.model.ProjectionChipModel
24 import com.android.systemui.statusbar.chips.notification.domain.interactor.StatusBarNotificationChipsInteractor
25 import com.android.systemui.statusbar.chips.screenrecord.domain.interactor.ScreenRecordChipInteractor
26 import com.android.systemui.statusbar.chips.screenrecord.domain.model.ScreenRecordChipModel
27 import com.android.systemui.statusbar.notification.domain.interactor.ActiveNotificationsInteractor
28 import com.android.systemui.statusbar.notification.promoted.shared.model.PromotedNotificationContentModel.Style.Ineligible
29 import com.android.systemui.statusbar.notification.promoted.shared.model.PromotedNotificationContentModels
30 import com.android.systemui.statusbar.notification.shared.ActiveNotificationModel
31 import com.android.systemui.statusbar.phone.ongoingcall.shared.model.OngoingCallModel
32 import javax.inject.Inject
33 import kotlinx.coroutines.CoroutineDispatcher
34 import kotlinx.coroutines.ExperimentalCoroutinesApi
35 import kotlinx.coroutines.flow.Flow
36 import kotlinx.coroutines.flow.combine
37 import kotlinx.coroutines.flow.distinctUntilChanged
38 import kotlinx.coroutines.flow.flatMapLatest
39 import kotlinx.coroutines.flow.flowOf
40 import kotlinx.coroutines.flow.flowOn
41 import kotlinx.coroutines.flow.map
42 
43 /**
44  * An interactor that provides details about promoted notification precedence, based on the
45  * presented order of current notification status bar chips.
46  */
47 @SysUISingleton
48 @OptIn(ExperimentalCoroutinesApi::class)
49 class PromotedNotificationsInteractor
50 @Inject
51 constructor(
52     private val activeNotificationsInteractor: ActiveNotificationsInteractor,
53     screenRecordChipInteractor: ScreenRecordChipInteractor,
54     mediaProjectionChipInteractor: MediaProjectionChipInteractor,
55     callChipInteractor: CallChipInteractor,
56     notifChipsInteractor: StatusBarNotificationChipsInteractor,
57     @Background backgroundDispatcher: CoroutineDispatcher,
58 ) {
59     private val screenRecordChipNotification: Flow<NotifAndPromotedContent?> =
60         screenRecordChipInteractor.screenRecordState.flatMapLatest { screenRecordModel ->
61             when (screenRecordModel) {
62                 is ScreenRecordChipModel.DoingNothing -> flowOf(null)
63                 is ScreenRecordChipModel.Starting -> flowOf(null)
64                 is ScreenRecordChipModel.Recording ->
65                     createRecordingNotificationFlow(hostPackage = screenRecordModel.hostPackage)
66             }
67         }
68 
69     private val mediaProjectionChipNotification: Flow<NotifAndPromotedContent?> =
70         mediaProjectionChipInteractor.projection.flatMapLatest { projectionModel ->
71             when (projectionModel) {
72                 is ProjectionChipModel.NotProjecting -> flowOf(null)
73                 is ProjectionChipModel.Projecting ->
74                     createRecordingNotificationFlow(
75                         hostPackage = projectionModel.projectionState.hostPackage
76                     )
77             }
78         }
79 
80     /**
81      * Creates a flow emitting the screen-recording-related notification corresponding to the given
82      * package name (if we can find it).
83      *
84      * @param hostPackage the package name of the app that is receiving the content of the media
85      *   projection (aka which app the phone screen contents are being sent to).
86      */
87     private fun createRecordingNotificationFlow(
88         hostPackage: String?
89     ): Flow<NotifAndPromotedContent?> =
90         if (hostPackage == null) {
91             flowOf(null)
92         } else {
93             activeNotificationsInteractor.allRepresentativeNotifications
94                 .map { allNotifs ->
95                     findBestMatchingMediaProjectionNotif(allNotifs.values, hostPackage)
96                 }
97                 .distinctUntilChanged()
98         }
99 
100     /**
101      * Finds the best notification that matches the given [hostPackage] that looks like a recording
102      * notification, or null if we couldn't find a uniquely good match.
103      */
104     private fun findBestMatchingMediaProjectionNotif(
105         allNotifs: Collection<ActiveNotificationModel>,
106         hostPackage: String,
107     ): NotifAndPromotedContent? {
108         val candidates = allNotifs.filter { it.packageName == hostPackage }
109         if (candidates.isEmpty()) {
110             return null
111         }
112 
113         candidates
114             .findOnlyOrNull { it.isForegroundService }
115             ?.let {
116                 return it.toNotifAndPromotedContent()
117             }
118         candidates
119             .findOnlyOrNull { it.isOngoingEvent }
120             ?.let {
121                 return it.toNotifAndPromotedContent()
122             }
123         candidates
124             .findOnlyOrNull { it.isForegroundService && it.isOngoingEvent }
125             ?.let {
126                 return it.toNotifAndPromotedContent()
127             }
128         // We weren't able to find exactly 1 match for the given [hostPackage], so just don't match
129         // at all.
130         return null
131     }
132 
133     /**
134      * Returns the single notification matching the given [predicate] if there's only 1 match, or
135      * null if there's 0 or 2+ matches.
136      */
137     private fun List<ActiveNotificationModel>.findOnlyOrNull(
138         predicate: (ActiveNotificationModel) -> Boolean
139     ): ActiveNotificationModel? {
140         val list = this.filter(predicate)
141         return if (list.size == 1) {
142             list.first()
143         } else {
144             null
145         }
146     }
147 
148     private fun ActiveNotificationModel.toNotifAndPromotedContent(): NotifAndPromotedContent {
149         return NotifAndPromotedContent(this.key, this.promotedContent)
150     }
151 
152     private val callNotification: Flow<NotifAndPromotedContent?> =
153         callChipInteractor.ongoingCallState
154             .map {
155                 when (it) {
156                     is OngoingCallModel.InCall ->
157                         NotifAndPromotedContent(it.notificationKey, it.promotedContent)
158                     is OngoingCallModel.NoCall -> null
159                 }
160             }
161             .distinctUntilChanged()
162 
163     private val promotedChipNotifications: Flow<List<NotifAndPromotedContent>> =
164         notifChipsInteractor.allNotificationChips
165             .map { chips -> chips.map { NotifAndPromotedContent(it.key, it.promotedContent) } }
166             .distinctUntilChanged()
167 
168     /**
169      * This is the ordered list of notifications (and the promoted content) represented as chips in
170      * the status bar.
171      */
172     private val orderedChipNotifications: Flow<List<NotifAndPromotedContent>> =
173         combine(
174             screenRecordChipNotification,
175             mediaProjectionChipNotification,
176             callNotification,
177             promotedChipNotifications,
178         ) { screenRecordNotif, mediaProjectionNotif, callNotif, promotedNotifs ->
179             val chipNotifications = mutableListOf<NotifAndPromotedContent>()
180             val usedKeys = mutableListOf<String>()
181 
182             fun addToList(item: NotifAndPromotedContent?) {
183                 if (item != null && !usedKeys.contains(item.key)) {
184                     chipNotifications.add(item)
185                     usedKeys.add(item.key)
186                 }
187             }
188 
189             // IMPORTANT: This ordering is prescribed by OngoingActivityChipsViewModel. Be sure to
190             // always keep this ordering in sync with that view model.
191             // TODO(b/402471288): Create a single source of truth for the ordering.
192             addToList(screenRecordNotif)
193             addToList(mediaProjectionNotif)
194             addToList(callNotif)
195             promotedNotifs.forEach { addToList(it) }
196 
197             chipNotifications
198         }
199 
200     /**
201      * The top promoted notification represented by a chip, with the order determined by the order
202      * of the chips, not the notifications.
203      */
204     private val topPromotedChipNotification: Flow<PromotedNotificationContentModels?> =
205         orderedChipNotifications
206             .map { list -> list.firstNotNullOfOrNull { it.promotedContent } }
207             .distinctUntilNewInstance()
208 
209     /** This is the AOD promoted notification, which should avoid regular changing. */
210     val aodPromotedNotification: Flow<PromotedNotificationContentModels?> =
211         combine(
212                 topPromotedChipNotification,
213                 activeNotificationsInteractor.topLevelRepresentativeNotifications,
214             ) { topChipNotif, topLevelNotifs ->
215                 topChipNotif?.takeIfAodEligible() ?: topLevelNotifs.firstAodEligibleOrNull()
216             }
217             // #equals() can be a bit expensive on this object, but this flow will regularly try to
218             // emit the same immutable instance over and over, so just prevent that.
219             .distinctUntilNewInstance()
220 
221     /**
222      * This is the ordered list of notifications (and the promoted content) represented as chips in
223      * the status bar. Flows on the background context.
224      */
225     val orderedChipNotificationKeys: Flow<List<String>> =
226         orderedChipNotifications
227             .map { list -> list.map { it.key } }
228             .distinctUntilChanged()
229             .flowOn(backgroundDispatcher)
230 
231     private fun List<ActiveNotificationModel>.firstAodEligibleOrNull():
232         PromotedNotificationContentModels? {
233         return this.firstNotNullOfOrNull { it.promotedContent?.takeIfAodEligible() }
234     }
235 
236     private fun PromotedNotificationContentModels.takeIfAodEligible():
237         PromotedNotificationContentModels? {
238         return this.takeUnless { it.privateVersion.style == Ineligible }
239     }
240 
241     /**
242      * Returns flow where all subsequent repetitions of the same object instance are filtered out.
243      */
244     private fun <T> Flow<T>.distinctUntilNewInstance() = distinctUntilChanged { a, b -> a === b }
245 
246     /**
247      * A custom pair, but providing clearer semantic names, and implementing equality as being the
248      * same instance of the promoted content model, which allows us to use distinctUntilChanged() on
249      * flows containing this without doing pixel comparisons on the Bitmaps inside Icon objects
250      * provided by the Notification.
251      */
252     private data class NotifAndPromotedContent(
253         val key: String,
254         val promotedContent: PromotedNotificationContentModels?,
255     ) {
256         /**
257          * Define the equals of this object to only check the reference equality of the promoted
258          * content so that we can mark.
259          */
260         override fun equals(other: Any?): Boolean {
261             return when {
262                 other == null -> false
263                 other === this -> true
264                 other !is NotifAndPromotedContent -> return false
265                 else -> key == other.key && promotedContent === other.promotedContent
266             }
267         }
268 
269         /** Define the hashCode to be very quick, even if it increases collisions. */
270         override fun hashCode(): Int {
271             var result = key.hashCode()
272             result = 31 * result + (promotedContent?.key?.hashCode() ?: 0)
273             return result
274         }
275     }
276 }
277