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