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.ui.viewmodel 18 19 import android.content.res.Configuration 20 import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractor 21 import com.android.systemui.common.ui.domain.interactor.ConfigurationInteractor 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.LogLevel 26 import com.android.systemui.statusbar.chips.StatusBarChipLogTags.pad 27 import com.android.systemui.statusbar.chips.StatusBarChipsLog 28 import com.android.systemui.statusbar.chips.call.ui.viewmodel.CallChipViewModel 29 import com.android.systemui.statusbar.chips.casttootherdevice.ui.viewmodel.CastToOtherDeviceChipViewModel 30 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips 31 import com.android.systemui.statusbar.chips.notification.ui.viewmodel.NotifChipsViewModel 32 import com.android.systemui.statusbar.chips.screenrecord.ui.viewmodel.ScreenRecordChipViewModel 33 import com.android.systemui.statusbar.chips.sharetoapp.ui.viewmodel.ShareToAppChipViewModel 34 import com.android.systemui.statusbar.chips.ui.model.MultipleOngoingActivityChipsModel 35 import com.android.systemui.statusbar.chips.ui.model.MultipleOngoingActivityChipsModelLegacy 36 import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel 37 import com.android.systemui.statusbar.phone.ongoingcall.StatusBarChipsModernization 38 import com.android.systemui.util.kotlin.pairwise 39 import javax.inject.Inject 40 import kotlinx.coroutines.CoroutineScope 41 import kotlinx.coroutines.flow.Flow 42 import kotlinx.coroutines.flow.MutableStateFlow 43 import kotlinx.coroutines.flow.SharingStarted 44 import kotlinx.coroutines.flow.StateFlow 45 import kotlinx.coroutines.flow.asStateFlow 46 import kotlinx.coroutines.flow.combine 47 import kotlinx.coroutines.flow.distinctUntilChanged 48 import kotlinx.coroutines.flow.map 49 import kotlinx.coroutines.flow.onEach 50 import kotlinx.coroutines.flow.stateIn 51 52 /** 53 * View model deciding which ongoing activity chip to show in the status bar. 54 * 55 * There may be multiple ongoing activities at the same time, but we can only ever show one chip at 56 * any one time (for now). This class decides which ongoing activity to show if there are multiple. 57 */ 58 @SysUISingleton 59 class OngoingActivityChipsViewModel 60 @Inject 61 constructor( 62 @Background scope: CoroutineScope, 63 screenRecordChipViewModel: ScreenRecordChipViewModel, 64 shareToAppChipViewModel: ShareToAppChipViewModel, 65 castToOtherDeviceChipViewModel: CastToOtherDeviceChipViewModel, 66 callChipViewModel: CallChipViewModel, 67 notifChipsViewModel: NotifChipsViewModel, 68 displayStateInteractor: DisplayStateInteractor, 69 configurationInteractor: ConfigurationInteractor, 70 @StatusBarChipsLog private val logger: LogBuffer, 71 ) { 72 private val isLandscape: Flow<Boolean> = 73 configurationInteractor.configurationValues 74 .map { it.isLandscape } 75 .stateIn(scope, SharingStarted.WhileSubscribed(), false) 76 77 private val isScreenReasonablyLarge: Flow<Boolean> = 78 combine(isLandscape, displayStateInteractor.isLargeScreen) { isLandscape, isLargeScreen -> 79 isLandscape || isLargeScreen 80 } 81 .distinctUntilChanged() 82 .onEach { 83 logger.log( 84 TAG, 85 LogLevel.DEBUG, 86 { bool1 = it }, 87 { "isScreenReasonablyLarge: $bool1" }, 88 ) 89 } 90 .stateIn(scope, SharingStarted.WhileSubscribed(), false) 91 92 private enum class ChipType { 93 ScreenRecord, 94 ShareToApp, 95 CastToOtherDevice, 96 Call, 97 Notification, 98 } 99 100 /** Model that helps us internally track the various chip states from each of the types. */ 101 @Deprecated("Since StatusBarChipsModernization, this isn't used anymore") 102 private sealed interface InternalChipModel { 103 /** 104 * Represents that we've internally decided to show the chip with type [type] with the given 105 * [model] information. 106 */ 107 data class Active(val type: ChipType, val model: OngoingActivityChipModel.Active) : 108 InternalChipModel 109 110 /** 111 * Represents that all chip types would like to be hidden. Each value specifies *how* that 112 * chip type should get hidden. 113 */ 114 data class Inactive( 115 val screenRecord: OngoingActivityChipModel.Inactive, 116 val shareToApp: OngoingActivityChipModel.Inactive, 117 val castToOtherDevice: OngoingActivityChipModel.Inactive, 118 val call: OngoingActivityChipModel.Inactive, 119 val notifs: OngoingActivityChipModel.Inactive, 120 ) : InternalChipModel 121 } 122 123 private data class ChipBundle( 124 val screenRecord: OngoingActivityChipModel = OngoingActivityChipModel.Inactive(), 125 val shareToApp: OngoingActivityChipModel = OngoingActivityChipModel.Inactive(), 126 val castToOtherDevice: OngoingActivityChipModel = OngoingActivityChipModel.Inactive(), 127 val call: OngoingActivityChipModel = OngoingActivityChipModel.Inactive(), 128 val notifs: List<OngoingActivityChipModel.Active> = emptyList(), 129 ) 130 131 /** Bundles all the incoming chips into one object to easily pass to various flows. */ 132 private val incomingChipBundle = 133 combine( 134 screenRecordChipViewModel.chip, 135 shareToAppChipViewModel.chip, 136 castToOtherDeviceChipViewModel.chip, 137 callChipViewModel.chip, 138 notifChipsViewModel.chips, 139 ) { screenRecord, shareToApp, castToOtherDevice, call, notifs -> 140 logger.log( 141 TAG, 142 LogLevel.INFO, 143 { 144 str1 = screenRecord.logName 145 str2 = shareToApp.logName 146 str3 = castToOtherDevice.logName 147 }, 148 { "Chips: ScreenRecord=$str1 > ShareToApp=$str2 > CastToOther=$str3..." }, 149 ) 150 logger.log( 151 TAG, 152 LogLevel.INFO, 153 { 154 str1 = call.logName 155 // TODO(b/364653005): Log other information for notification chips. 156 str2 = notifs.map { it.logName }.toString() 157 }, 158 { "... > Call=$str1 > Notifs=$str2" }, 159 ) 160 ChipBundle( 161 screenRecord = screenRecord, 162 shareToApp = shareToApp, 163 castToOtherDevice = castToOtherDevice, 164 call = call, 165 notifs = notifs, 166 ) 167 } 168 // Some of the chips could have timers in them and we don't want the start time 169 // for those timers to get reset for any reason. So, as soon as any subscriber has 170 // requested the chip information, we maintain it forever by using 171 // [SharingStarted.Lazily]. See b/347726238. 172 .stateIn(scope, SharingStarted.Lazily, ChipBundle()) 173 174 private val internalChip: Flow<InternalChipModel> = 175 incomingChipBundle.map { bundle -> pickMostImportantChip(bundle).mostImportantChip } 176 177 /** 178 * A flow modeling the primary chip that should be shown in the status bar after accounting for 179 * possibly multiple ongoing activities and animation requirements. 180 * 181 * [com.android.systemui.statusbar.phone.fragment.CollapsedStatusBarFragment] is responsible for 182 * actually displaying the chip. 183 */ 184 val primaryChip: StateFlow<OngoingActivityChipModel> = 185 internalChip 186 .pairwise(initialValue = DEFAULT_INTERNAL_INACTIVE_MODEL) 187 .map { (old, new) -> createOutputModel(old, new) } 188 .stateIn(scope, SharingStarted.Lazily, OngoingActivityChipModel.Inactive()) 189 190 /** 191 * Equivalent to [MultipleOngoingActivityChipsModelLegacy] but using the internal models to do 192 * some state tracking before we get the final output. 193 */ 194 @Deprecated("Since StatusBarChipsModernization, this isn't used anymore") 195 private data class InternalMultipleOngoingActivityChipsModel( 196 val primary: InternalChipModel, 197 val secondary: InternalChipModel, 198 ) 199 200 private val internalChips: Flow<InternalMultipleOngoingActivityChipsModel> = 201 combine(incomingChipBundle, isScreenReasonablyLarge) { bundle, isScreenReasonablyLarge -> 202 // First: Find the most important chip. 203 val primaryChipResult = pickMostImportantChip(bundle) 204 when (val primaryChip = primaryChipResult.mostImportantChip) { 205 is InternalChipModel.Inactive -> { 206 // If the primary chip is hidden, the secondary chip will also be hidden, so 207 // just pass the same Hidden model for both. 208 InternalMultipleOngoingActivityChipsModel(primaryChip, primaryChip) 209 } 210 is InternalChipModel.Active -> { 211 // Otherwise: Find the next most important chip. 212 val secondaryChip = 213 pickMostImportantChip(primaryChipResult.remainingChips).mostImportantChip 214 if ( 215 secondaryChip is InternalChipModel.Active && 216 StatusBarNotifChips.isEnabled && 217 !isScreenReasonablyLarge 218 ) { 219 // If we have two showing chips and we don't have a ton of room 220 // (!isScreenReasonablyLarge), then we want to make both of them as small as 221 // possible so that we have the highest chance of showing both chips (as 222 // opposed to showing the primary chip with a lot of text and completely 223 // hiding the secondary chip). 224 // TODO(b/392895330): If StatusBarChipsModernization is enabled, do the 225 // squishing in Compose instead, and be smart about it (e.g. if we have 226 // room for the first chip to show text and the second chip to be icon-only, 227 // do that instead of always squishing both chips.) 228 InternalMultipleOngoingActivityChipsModel( 229 primaryChip.squish(), 230 secondaryChip.squish(), 231 ) 232 } else { 233 InternalMultipleOngoingActivityChipsModel(primaryChip, secondaryChip) 234 } 235 } 236 } 237 } 238 239 /** Squishes the chip down to the smallest content possible. */ 240 private fun InternalChipModel.Active.squish(): InternalChipModel.Active { 241 return if (model.shouldSquish()) { 242 InternalChipModel.Active(this.type, this.model.toIconOnly()) 243 } else { 244 this 245 } 246 } 247 248 private fun OngoingActivityChipModel.Active.shouldSquish(): Boolean { 249 return when (this) { 250 // Icon-only is already maximum squished 251 is OngoingActivityChipModel.Active.IconOnly, 252 // Countdown shows just a single digit, so already maximum squished 253 is OngoingActivityChipModel.Active.Countdown -> false 254 // The other chips have icon+text, so we can squish them by hiding text 255 is OngoingActivityChipModel.Active.Timer, 256 is OngoingActivityChipModel.Active.ShortTimeDelta, 257 is OngoingActivityChipModel.Active.Text -> true 258 } 259 } 260 261 private fun OngoingActivityChipModel.Active.toIconOnly(): OngoingActivityChipModel.Active { 262 // If this chip doesn't have an icon, then it only has text and we should continue showing 263 // its text. (This is theoretically impossible because 264 // [OngoingActivityChipModel.Active.Countdown] is the only chip without an icon and 265 // [shouldSquish] returns false for that model, but protect against it just in case.) 266 val currentIcon = icon ?: return this 267 // TODO(b/364653005): Make sure every field is copied over. 268 return OngoingActivityChipModel.Active.IconOnly( 269 key = key, 270 isImportantForPrivacy = isImportantForPrivacy, 271 icon = currentIcon, 272 colors = colors, 273 onClickListenerLegacy = onClickListenerLegacy, 274 clickBehavior = clickBehavior, 275 instanceId = instanceId, 276 ) 277 } 278 279 /** 280 * A flow modeling the active and inactive chips as well as which should be shown in the status 281 * bar after accounting for possibly multiple ongoing activities and animation requirements. 282 */ 283 val chips: StateFlow<MultipleOngoingActivityChipsModel> = 284 if (StatusBarChipsModernization.isEnabled) { 285 combine( 286 incomingChipBundle.map { bundle -> rankChips(bundle) }, 287 isScreenReasonablyLarge, 288 ) { rankedChips, isScreenReasonablyLarge -> 289 if ( 290 StatusBarNotifChips.isEnabled && 291 !isScreenReasonablyLarge && 292 rankedChips.active.filter { !it.isHidden }.size >= 2 293 ) { 294 // If we have at least two showing chips and we don't have a ton of room 295 // (!isScreenReasonablyLarge), then we want to make both of them as small as 296 // possible so that we have the highest chance of showing both chips (as 297 // opposed to showing the first chip with a lot of text and completely 298 // hiding the other chips). 299 val squishedActiveChips = 300 rankedChips.active.map { 301 if (!it.isHidden && it.shouldSquish()) { 302 it.toIconOnly() 303 } else { 304 it 305 } 306 } 307 308 MultipleOngoingActivityChipsModel( 309 active = squishedActiveChips, 310 overflow = rankedChips.overflow, 311 inactive = rankedChips.inactive, 312 ) 313 } else { 314 rankedChips 315 } 316 } 317 .stateIn(scope, SharingStarted.Lazily, MultipleOngoingActivityChipsModel()) 318 } else { 319 MutableStateFlow(MultipleOngoingActivityChipsModel()).asStateFlow() 320 } 321 322 /** 323 * A flow modeling the primary chip that should be shown in the status bar after accounting for 324 * possibly multiple ongoing activities and animation requirements. 325 * 326 * [com.android.systemui.statusbar.phone.fragment.CollapsedStatusBarFragment] is responsible for 327 * actually displaying the chip. 328 * 329 * Deprecated: since StatusBarChipsModernization, use the new [chips] instead. 330 */ 331 val chipsLegacy: StateFlow<MultipleOngoingActivityChipsModelLegacy> = 332 if (StatusBarChipsModernization.isEnabled) { 333 MutableStateFlow(MultipleOngoingActivityChipsModelLegacy()).asStateFlow() 334 } else if (!StatusBarNotifChips.isEnabled) { 335 // Multiple chips are only allowed with notification chips. If the flag isn't on, use 336 // just the primary chip. 337 primaryChip 338 .map { 339 MultipleOngoingActivityChipsModelLegacy( 340 primary = it, 341 secondary = OngoingActivityChipModel.Inactive(), 342 ) 343 } 344 .stateIn(scope, SharingStarted.Lazily, MultipleOngoingActivityChipsModelLegacy()) 345 } else { 346 internalChips 347 .pairwise(initialValue = DEFAULT_MULTIPLE_INTERNAL_INACTIVE_MODEL) 348 .map { (old, new) -> 349 val correctPrimary = createOutputModel(old.primary, new.primary) 350 val correctSecondary = createOutputModel(old.secondary, new.secondary) 351 MultipleOngoingActivityChipsModelLegacy(correctPrimary, correctSecondary) 352 } 353 .stateIn(scope, SharingStarted.Lazily, MultipleOngoingActivityChipsModelLegacy()) 354 } 355 356 private val activeChips = 357 if (StatusBarChipsModernization.isEnabled) { 358 chips.map { it.active } 359 } else { 360 chipsLegacy.map { 361 val list = mutableListOf<OngoingActivityChipModel.Active>() 362 if (it.primary is OngoingActivityChipModel.Active) { 363 list.add(it.primary) 364 } 365 if (it.secondary is OngoingActivityChipModel.Active) { 366 list.add(it.secondary) 367 } 368 list 369 } 370 } 371 372 /** A flow modeling just the keys for the currently visible chips. */ 373 val visibleChipKeys: Flow<List<String>> = 374 activeChips.map { chips -> chips.filter { !it.isHidden }.map { it.key } } 375 376 /** 377 * Sort the given chip [bundle] in order of priority, and divide the chips between active, 378 * overflow, and inactive (see [MultipleOngoingActivityChipsModel] for a description of each). 379 */ 380 // IMPORTANT: PromotedNotificationsInteractor re-implements this same ordering scheme. Any 381 // changes here should also be made in PromotedNotificationsInteractor. 382 // TODO(b/402471288): Create a single source of truth for the ordering. 383 private fun rankChips(bundle: ChipBundle): MultipleOngoingActivityChipsModel { 384 val activeChips = mutableListOf<OngoingActivityChipModel.Active>() 385 val overflowChips = mutableListOf<OngoingActivityChipModel.Active>() 386 val inactiveChips = mutableListOf<OngoingActivityChipModel.Inactive>() 387 388 val sortedChips = 389 mutableListOf( 390 bundle.screenRecord, 391 bundle.shareToApp, 392 bundle.castToOtherDevice, 393 bundle.call, 394 ) 395 .apply { bundle.notifs.forEach { add(it) } } 396 397 var shownSlotsRemaining = MAX_VISIBLE_CHIPS 398 for (chip in sortedChips) { 399 when (chip) { 400 is OngoingActivityChipModel.Active -> { 401 // Screen recording also activates the media projection APIs, which means that 402 // whenever the screen recording chip is active, the share-to-app chip would 403 // also be active. (Screen recording is a special case of share-to-app, where 404 // the app receiving the share is specifically System UI.) 405 // We want only the screen-recording-specific chip to be shown in this case. If 406 // we did have screen recording as the primary chip, we need to suppress the 407 // share-to-app chip to make sure they don't both show. 408 // See b/296461748. 409 val suppressShareToApp = 410 chip == bundle.shareToApp && 411 bundle.screenRecord is OngoingActivityChipModel.Active 412 if (shownSlotsRemaining > 0 && !suppressShareToApp) { 413 activeChips.add(chip) 414 if (!chip.isHidden) shownSlotsRemaining-- 415 } else { 416 overflowChips.add(chip) 417 } 418 } 419 420 is OngoingActivityChipModel.Inactive -> inactiveChips.add(chip) 421 } 422 } 423 424 return MultipleOngoingActivityChipsModel(activeChips, overflowChips, inactiveChips) 425 } 426 427 /** A data class representing the return result of [pickMostImportantChip]. */ 428 private data class MostImportantChipResult( 429 val mostImportantChip: InternalChipModel, 430 val remainingChips: ChipBundle, 431 ) 432 433 /** 434 * Finds the most important chip from the given [bundle]. 435 * 436 * This function returns that most important chip, and it also returns any remaining chips that 437 * still want to be shown after filtering out the most important chip. 438 */ 439 private fun pickMostImportantChip(bundle: ChipBundle): MostImportantChipResult { 440 // This `when` statement shows the priority order of the chips. 441 return when { 442 bundle.screenRecord is OngoingActivityChipModel.Active -> 443 MostImportantChipResult( 444 mostImportantChip = 445 InternalChipModel.Active(ChipType.ScreenRecord, bundle.screenRecord), 446 remainingChips = 447 bundle.copy( 448 screenRecord = OngoingActivityChipModel.Inactive(), 449 // Screen recording also activates the media projection APIs, which 450 // means that whenever the screen recording chip is active, the 451 // share-to-app chip would also be active. (Screen recording is a 452 // special case of share-to-app, where the app receiving the share is 453 // specifically System UI.) 454 // We want only the screen-recording-specific chip to be shown in this 455 // case. If we did have screen recording as the primary chip, we need to 456 // suppress the share-to-app chip to make sure they don't both show. 457 // See b/296461748. 458 shareToApp = OngoingActivityChipModel.Inactive(), 459 ), 460 ) 461 bundle.shareToApp is OngoingActivityChipModel.Active -> 462 MostImportantChipResult( 463 mostImportantChip = 464 InternalChipModel.Active(ChipType.ShareToApp, bundle.shareToApp), 465 remainingChips = bundle.copy(shareToApp = OngoingActivityChipModel.Inactive()), 466 ) 467 bundle.castToOtherDevice is OngoingActivityChipModel.Active -> 468 MostImportantChipResult( 469 mostImportantChip = 470 InternalChipModel.Active( 471 ChipType.CastToOtherDevice, 472 bundle.castToOtherDevice, 473 ), 474 remainingChips = 475 bundle.copy(castToOtherDevice = OngoingActivityChipModel.Inactive()), 476 ) 477 bundle.call is OngoingActivityChipModel.Active -> 478 MostImportantChipResult( 479 mostImportantChip = InternalChipModel.Active(ChipType.Call, bundle.call), 480 remainingChips = bundle.copy(call = OngoingActivityChipModel.Inactive()), 481 ) 482 bundle.notifs.isNotEmpty() -> 483 MostImportantChipResult( 484 mostImportantChip = 485 InternalChipModel.Active(ChipType.Notification, bundle.notifs.first()), 486 remainingChips = 487 bundle.copy(notifs = bundle.notifs.subList(1, bundle.notifs.size)), 488 ) 489 else -> { 490 // We should only get here if all chip types are hidden 491 check(bundle.screenRecord is OngoingActivityChipModel.Inactive) 492 check(bundle.shareToApp is OngoingActivityChipModel.Inactive) 493 check(bundle.castToOtherDevice is OngoingActivityChipModel.Inactive) 494 check(bundle.call is OngoingActivityChipModel.Inactive) 495 check(bundle.notifs.isEmpty()) 496 MostImportantChipResult( 497 mostImportantChip = 498 InternalChipModel.Inactive( 499 screenRecord = bundle.screenRecord, 500 shareToApp = bundle.shareToApp, 501 castToOtherDevice = bundle.castToOtherDevice, 502 call = bundle.call, 503 notifs = OngoingActivityChipModel.Inactive(), 504 ), 505 // All the chips are already hidden, so no need to filter anything out of the 506 // bundle. 507 remainingChips = bundle, 508 ) 509 } 510 } 511 } 512 513 private fun createOutputModel( 514 old: InternalChipModel, 515 new: InternalChipModel, 516 ): OngoingActivityChipModel { 517 return if (old is InternalChipModel.Active && new is InternalChipModel.Inactive) { 518 // If we're transitioning from showing the chip to hiding the chip, different 519 // chips require different animation behaviors. For example, the screen share 520 // chips shouldn't animate if the user stopped the screen share from the dialog 521 // (see b/353249803#comment4), but the call chip should always animate. 522 // 523 // This `when` block makes sure that when we're transitioning from Active to 524 // Inactive, we check what chip type was previously showing and we use that chip 525 // type's hide animation behavior. 526 return when (old.type) { 527 ChipType.ScreenRecord -> new.screenRecord 528 ChipType.ShareToApp -> new.shareToApp 529 ChipType.CastToOtherDevice -> new.castToOtherDevice 530 ChipType.Call -> new.call 531 ChipType.Notification -> new.notifs 532 } 533 } else if (new is InternalChipModel.Active) { 534 // If we have a chip to show, always show it. 535 new.model 536 } else { 537 // In the Hidden -> Hidden transition, it shouldn't matter which hidden model we 538 // choose because no animation should happen regardless. 539 OngoingActivityChipModel.Inactive() 540 } 541 } 542 543 private val Configuration.isLandscape: Boolean 544 get() = orientation == Configuration.ORIENTATION_LANDSCAPE 545 546 companion object { 547 private val TAG = "ChipsViewModel".pad() 548 549 private val DEFAULT_INTERNAL_INACTIVE_MODEL = 550 InternalChipModel.Inactive( 551 screenRecord = OngoingActivityChipModel.Inactive(), 552 shareToApp = OngoingActivityChipModel.Inactive(), 553 castToOtherDevice = OngoingActivityChipModel.Inactive(), 554 call = OngoingActivityChipModel.Inactive(), 555 notifs = OngoingActivityChipModel.Inactive(), 556 ) 557 558 private val DEFAULT_MULTIPLE_INTERNAL_INACTIVE_MODEL = 559 InternalMultipleOngoingActivityChipsModel( 560 primary = DEFAULT_INTERNAL_INACTIVE_MODEL, 561 secondary = DEFAULT_INTERNAL_INACTIVE_MODEL, 562 ) 563 564 private const val MAX_VISIBLE_CHIPS = 3 565 } 566 } 567