summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModelTest.kt296
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt241
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChip.kt3
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChips.kt14
-rw-r--r--packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt33
5 files changed, 578 insertions, 9 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModelTest.kt
index 485b9febc284..2fa9a02b9e87 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModelTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModelTest.kt
@@ -17,12 +17,17 @@
package com.android.systemui.statusbar.chips.call.ui.viewmodel
import android.app.PendingIntent
+import android.content.ComponentName
+import android.content.Intent
import android.platform.test.annotations.DisableFlags
import android.platform.test.annotations.EnableFlags
import android.platform.test.flag.junit.FlagsParameterization
import android.view.View
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
+import com.android.systemui.activity.data.repository.activityManagerRepository
+import com.android.systemui.activity.data.repository.fake
+import com.android.systemui.animation.ActivityTransitionAnimator
import com.android.systemui.animation.Expandable
import com.android.systemui.common.shared.model.ContentDescription.Companion.loadContentDescription
import com.android.systemui.common.shared.model.Icon
@@ -51,6 +56,7 @@ import com.google.common.truth.Truth.assertThat
import kotlin.test.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
@@ -481,6 +487,294 @@ class CallChipViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
verify(kosmos.activityStarter).postStartActivityDismissingKeyguard(pendingIntent, null)
}
+ @Test
+ @EnableFlags(StatusBarChipsReturnAnimations.FLAG_NAME)
+ @EnableChipsModernization
+ fun chipWithReturnAnimation_updatesCorrectly_withStateAndTransitionState() =
+ kosmos.runTest {
+ val pendingIntent = mock<PendingIntent>()
+ val intent = mock<Intent>()
+ whenever(pendingIntent.intent).thenReturn(intent)
+ val component = mock<ComponentName>()
+ whenever(intent.component).thenReturn(component)
+
+ val expandable = mock<Expandable>()
+ val activityController = mock<ActivityTransitionAnimator.Controller>()
+ whenever(
+ expandable.activityTransitionController(
+ anyOrNull(),
+ anyOrNull(),
+ any(),
+ anyOrNull(),
+ any(),
+ )
+ )
+ .thenReturn(activityController)
+
+ val latest by collectLastValue(underTest.chip)
+
+ // Start off with no call.
+ removeOngoingCallState(key = NOTIFICATION_KEY)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Inactive::class.java)
+ assertThat(latest!!.transitionManager!!.controllerFactory).isNull()
+
+ // Call starts [NoCall -> InCall(isAppVisible=true), NoTransition].
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isTrue()
+ val factory = latest!!.transitionManager!!.controllerFactory
+ assertThat(factory!!.component).isEqualTo(component)
+
+ // Request a return transition [InCall(isAppVisible=true), NoTransition ->
+ // ReturnRequested].
+ factory.onCompose(expandable)
+ var controller = factory.createController(forLaunch = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // Start the return transition [InCall(isAppVisible=true), ReturnRequested ->
+ // Returning].
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // End the return transition [InCall(isAppVisible=true), Returning -> NoTransition].
+ controller.onTransitionAnimationEnd(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // Settle the return transition [InCall(isAppVisible=true) ->
+ // InCall(isAppVisible=false), NoTransition].
+ kosmos.activityManagerRepository.fake.setIsAppVisible(NOTIFICATION_UID, false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // Trigger a launch transition [InCall(isAppVisible=false) -> InCall(isAppVisible=true),
+ // NoTransition].
+ kosmos.activityManagerRepository.fake.setIsAppVisible(NOTIFICATION_UID, true)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // Request the return transition [InCall(isAppVisible=true), NoTransition ->
+ // LaunchRequested].
+ controller = factory.createController(forLaunch = true)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // Start the return transition [InCall(isAppVisible=true), LaunchRequested ->
+ // Launching].
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // End the return transition [InCall(isAppVisible=true), Launching -> NoTransition].
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ assertThat(latest!!.transitionManager!!.controllerFactory).isEqualTo(factory)
+
+ // End the call with the app visible [InCall(isAppVisible=true) -> NoCall,
+ // NoTransition].
+ removeOngoingCallState(key = NOTIFICATION_KEY)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Inactive::class.java)
+ assertThat(latest!!.transitionManager!!.controllerFactory).isNull()
+
+ // End the call with the app hidden [InCall(isAppVisible=false) -> NoCall,
+ // NoTransition].
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ contentIntent = pendingIntent,
+ isAppVisible = false,
+ )
+ removeOngoingCallState(key = NOTIFICATION_KEY)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Inactive::class.java)
+ assertThat(latest!!.transitionManager!!.controllerFactory).isNull()
+ }
+
+ @Test
+ @DisableFlags(StatusBarChipsReturnAnimations.FLAG_NAME)
+ fun chipLegacy_hasNoTransitionAnimationInformation() =
+ kosmos.runTest {
+ val latest by collectLastValue(underTest.chip)
+
+ // NoCall
+ removeOngoingCallState(key = NOTIFICATION_KEY)
+ assertThat(latest!!.transitionManager).isNull()
+
+ // InCall with visible app
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ assertThat(latest!!.transitionManager).isNull()
+
+ // InCall with hidden app
+ kosmos.activityManagerRepository.fake.setIsAppVisible(NOTIFICATION_UID, false)
+ assertThat(latest!!.transitionManager).isNull()
+ }
+
+ @Test
+ @EnableFlags(StatusBarChipsReturnAnimations.FLAG_NAME)
+ @EnableChipsModernization
+ fun chipWithReturnAnimation_chipDataChangesMidTransition() =
+ kosmos.runTest {
+ val pendingIntent = mock<PendingIntent>()
+ val intent = mock<Intent>()
+ whenever(pendingIntent.intent).thenReturn(intent)
+ val component = mock<ComponentName>()
+ whenever(intent.component).thenReturn(component)
+
+ val expandable = mock<Expandable>()
+ val activityController = mock<ActivityTransitionAnimator.Controller>()
+ whenever(
+ expandable.activityTransitionController(
+ anyOrNull(),
+ anyOrNull(),
+ any(),
+ anyOrNull(),
+ any(),
+ )
+ )
+ .thenReturn(activityController)
+
+ val latest by collectLastValue(underTest.chip)
+
+ // Start with the app visible and trigger a return animation.
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ var factory = latest!!.transitionManager!!.controllerFactory!!
+ factory.onCompose(expandable)
+ var controller = factory.createController(forLaunch = false)
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+
+ // The chip changes state.
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 0,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active.IconOnly::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+
+ // Reset the state and trigger a launch animation.
+ controller.onTransitionAnimationEnd(isExpandingFullyAbove = false)
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ factory = latest!!.transitionManager!!.controllerFactory!!
+ factory.onCompose(expandable)
+ controller = factory.createController(forLaunch = true)
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+
+ // The chip changes state.
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = -2,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active.IconOnly::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+ }
+
+ @Test
+ @EnableFlags(StatusBarChipsReturnAnimations.FLAG_NAME)
+ @EnableChipsModernization
+ fun chipWithReturnAnimation_chipDisappearsMidTransition() =
+ kosmos.runTest {
+ val pendingIntent = mock<PendingIntent>()
+ val intent = mock<Intent>()
+ whenever(pendingIntent.intent).thenReturn(intent)
+ val component = mock<ComponentName>()
+ whenever(intent.component).thenReturn(component)
+
+ val expandable = mock<Expandable>()
+ val activityController = mock<ActivityTransitionAnimator.Controller>()
+ whenever(
+ expandable.activityTransitionController(
+ anyOrNull(),
+ anyOrNull(),
+ any(),
+ anyOrNull(),
+ any(),
+ )
+ )
+ .thenReturn(activityController)
+
+ val latest by collectLastValue(underTest.chip)
+
+ // Start with the app visible and trigger a return animation.
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ var factory = latest!!.transitionManager!!.controllerFactory!!
+ factory.onCompose(expandable)
+ var controller = factory.createController(forLaunch = false)
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+
+ // The chip disappears.
+ removeOngoingCallState(key = NOTIFICATION_KEY)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Inactive::class.java)
+
+ // Reset the state and trigger a launch animation.
+ controller.onTransitionAnimationEnd(isExpandingFullyAbove = false)
+ addOngoingCallState(
+ key = NOTIFICATION_KEY,
+ startTimeMs = 345,
+ contentIntent = pendingIntent,
+ uid = NOTIFICATION_UID,
+ isAppVisible = true,
+ )
+ factory = latest!!.transitionManager!!.controllerFactory!!
+ factory.onCompose(expandable)
+ controller = factory.createController(forLaunch = true)
+ controller.onTransitionAnimationStart(isExpandingFullyAbove = false)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Active.Timer::class.java)
+ assertThat((latest as OngoingActivityChipModel.Active).isHidden).isFalse()
+
+ // The chip disappears.
+ removeOngoingCallState(key = NOTIFICATION_KEY)
+ assertThat(latest).isInstanceOf(OngoingActivityChipModel.Inactive::class.java)
+ }
+
companion object {
fun createStatusBarIconViewOrNull(): StatusBarIconView? =
if (StatusBarConnectedDisplays.isEnabled) {
@@ -500,6 +794,8 @@ class CallChipViewModelTest(flags: FlagsParameterization) : SysuiTestCase() {
}
.build()
+ private const val NOTIFICATION_KEY = "testKey"
+ private const val NOTIFICATION_UID = 12345
private const val PROMOTED_BACKGROUND_COLOR = 65
private const val PROMOTED_PRIMARY_TEXT_COLOR = 98
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt
index 7e7031200988..03108deb0ecc 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/call/ui/viewmodel/CallChipViewModel.kt
@@ -17,10 +17,13 @@
package com.android.systemui.statusbar.chips.call.ui.viewmodel
import android.app.PendingIntent
+import android.content.ComponentName
import android.content.Context
import android.view.View
import com.android.internal.jank.Cuj
import com.android.systemui.animation.ActivityTransitionAnimator
+import com.android.systemui.animation.ComposableControllerFactory
+import com.android.systemui.animation.DelegateTransitionAnimatorController
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.dagger.SysUISingleton
@@ -48,7 +51,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
/** View model for the ongoing phone call chip shown in the status bar. */
@@ -63,14 +69,62 @@ constructor(
private val activityStarter: ActivityStarter,
@StatusBarChipsLog private val logger: LogBuffer,
) : OngoingActivityChipViewModel {
+ /** The transition cookie used to register and unregister launch and return animations. */
+ private val cookie =
+ ActivityTransitionAnimator.TransitionCookie("${CallChipViewModel::class.java}")
+
+ /**
+ * Used internally to determine when a launch or return animation is in progress, as these
+ * require special handling.
+ */
+ private val transitionState: MutableStateFlow<TransitionState> =
+ MutableStateFlow(TransitionState.NoTransition)
+
+ // Since we're combining the chip state and the transition state flows, getting the old value by
+ // using [pairwise()] would confuse things. This is because if the calculation is triggered by
+ // a change in transition state, the chip state will still show the previous and current values,
+ // making it difficult to figure out what actually changed. Instead we cache the old value here,
+ // so that at each update we can keep track of what actually changed.
+ private var latestState: OngoingCallModel = OngoingCallModel.NoCall
+ private var latestTransitionState: TransitionState = TransitionState.NoTransition
+
private val chipWithReturnAnimation: StateFlow<OngoingActivityChipModel> =
if (StatusBarChipsReturnAnimations.isEnabled) {
- interactor.ongoingCallState
- .map { state ->
- when (state) {
- is OngoingCallModel.NoCall -> OngoingActivityChipModel.Inactive()
+ combine(interactor.ongoingCallState, transitionState) { newState, newTransitionState ->
+ val oldState = latestState
+ latestState = newState
+ val oldTransitionState = latestTransitionState
+ latestTransitionState = newTransitionState
+
+ logger.log(
+ TAG,
+ LogLevel.DEBUG,
+ {},
+ {
+ "Call chip state updated: oldState=$oldState newState=$newState " +
+ "oldTransitionState=$oldTransitionState " +
+ "newTransitionState=$newTransitionState"
+ },
+ )
+
+ when (newState) {
+ is OngoingCallModel.NoCall ->
+ OngoingActivityChipModel.Inactive(
+ transitionManager = getTransitionManager(newState)
+ )
+
is OngoingCallModel.InCall ->
- prepareChip(state, systemClock, isHidden = state.isAppVisible)
+ prepareChip(
+ newState,
+ systemClock,
+ isHidden =
+ shouldChipBeHidden(
+ oldState = oldState,
+ newState = newState,
+ oldTransitionState = oldTransitionState,
+ newTransitionState = newTransitionState,
+ ),
+ )
}
}
.stateIn(
@@ -112,6 +166,12 @@ constructor(
chipLegacy
}
+ /**
+ * The controller factory that the call chip uses to register and unregister its transition
+ * animations.
+ */
+ private var transitionControllerFactory: ComposableControllerFactory? = null
+
/** Builds an [OngoingActivityChipModel.Active] from all the relevant information. */
private fun prepareChip(
state: OngoingCallModel.InCall,
@@ -149,6 +209,7 @@ constructor(
onClickListenerLegacy = getOnClickListener(state.intent),
clickBehavior = getClickBehavior(state.intent),
isHidden = isHidden,
+ transitionManager = getTransitionManager(state),
)
} else {
val startTimeInElapsedRealtime =
@@ -161,6 +222,7 @@ constructor(
onClickListenerLegacy = getOnClickListener(state.intent),
clickBehavior = getClickBehavior(state.intent),
isHidden = isHidden,
+ transitionManager = getTransitionManager(state),
)
}
}
@@ -191,9 +253,21 @@ constructor(
onClick = { expandable ->
StatusBarChipsModernization.unsafeAssertInNewMode()
val animationController =
- expandable.activityTransitionController(
- Cuj.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP
- )
+ if (
+ !StatusBarChipsReturnAnimations.isEnabled ||
+ transitionControllerFactory == null
+ ) {
+ expandable.activityTransitionController(
+ Cuj.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP
+ )
+ } else {
+ // When return animations are enabled, we use a long-lived registration
+ // with controllers created on-demand by the animation library instead
+ // of explicitly creating one at the time of the click. By not passing
+ // a controller here, we let the framework do its work. Otherwise, the
+ // explicit controller would take precedence and override the other one.
+ null
+ }
activityStarter.postStartActivityDismissingKeyguard(intent, animationController)
}
)
@@ -210,6 +284,120 @@ constructor(
)
}
+ private fun getTransitionManager(
+ state: OngoingCallModel
+ ): OngoingActivityChipModel.TransitionManager? {
+ if (!StatusBarChipsReturnAnimations.isEnabled) return null
+ return if (state is OngoingCallModel.NoCall) {
+ OngoingActivityChipModel.TransitionManager(
+ unregisterTransition = { activityStarter.unregisterTransition(cookie) }
+ )
+ } else {
+ val component = (state as OngoingCallModel.InCall).intent?.intent?.component
+ if (component != null) {
+ val factory = getTransitionControllerFactory(component)
+ OngoingActivityChipModel.TransitionManager(
+ factory,
+ registerTransition = {
+ activityStarter.registerTransition(cookie, factory, scope)
+ },
+ )
+ } else {
+ // Without a component we can't instantiate a controller factory, and without a
+ // factory registering an animation is impossible. In this case, the transition
+ // manager is empty and inert.
+ OngoingActivityChipModel.TransitionManager()
+ }
+ }
+ }
+
+ private fun getTransitionControllerFactory(
+ component: ComponentName
+ ): ComposableControllerFactory {
+ var factory = transitionControllerFactory
+ if (factory?.component == component) return factory
+
+ factory =
+ object :
+ ComposableControllerFactory(
+ cookie,
+ component,
+ launchCujType = Cuj.CUJ_STATUS_BAR_APP_LAUNCH_FROM_CALL_CHIP,
+ ) {
+ override suspend fun createController(
+ forLaunch: Boolean
+ ): ActivityTransitionAnimator.Controller {
+ transitionState.value =
+ if (forLaunch) {
+ TransitionState.LaunchRequested
+ } else {
+ TransitionState.ReturnRequested
+ }
+
+ val controller =
+ expandable
+ .mapNotNull {
+ it?.activityTransitionController(
+ launchCujType,
+ cookie,
+ component,
+ returnCujType,
+ isEphemeral = false,
+ )
+ }
+ .first()
+
+ return object : DelegateTransitionAnimatorController(controller) {
+ override val isLaunching: Boolean
+ get() = forLaunch
+
+ override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) {
+ delegate.onTransitionAnimationStart(isExpandingFullyAbove)
+ transitionState.value =
+ if (isLaunching) {
+ TransitionState.Launching
+ } else {
+ TransitionState.Returning
+ }
+ }
+
+ override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) {
+ delegate.onTransitionAnimationEnd(isExpandingFullyAbove)
+ transitionState.value = TransitionState.NoTransition
+ }
+
+ override fun onTransitionAnimationCancelled(
+ newKeyguardOccludedState: Boolean?
+ ) {
+ delegate.onTransitionAnimationCancelled(newKeyguardOccludedState)
+ transitionState.value = TransitionState.NoTransition
+ }
+ }
+ }
+ }
+
+ transitionControllerFactory = factory
+ return factory
+ }
+
+ /** Define the current state of this chip's transition animation. */
+ private sealed interface TransitionState {
+ /** Idle. */
+ data object NoTransition : TransitionState
+
+ /** Launch animation has been requested but hasn't started yet. */
+ data object LaunchRequested : TransitionState
+
+ /** Launch animation in progress. */
+ data object Launching : TransitionState
+
+ /** Return animation has been requested but hasn't started yet. */
+ data object ReturnRequested : TransitionState
+
+ /** Return animation in progress. */
+ data object Returning : TransitionState
+ }
+
companion object {
private val phoneIcon =
Icon.Resource(
@@ -217,5 +405,42 @@ constructor(
ContentDescription.Resource(R.string.ongoing_call_content_description),
)
private val TAG = "CallVM".pad()
+
+ /** Determines whether or not an active call chip should be hidden. */
+ private fun shouldChipBeHidden(
+ oldState: OngoingCallModel,
+ newState: OngoingCallModel.InCall,
+ oldTransitionState: TransitionState,
+ newTransitionState: TransitionState,
+ ): Boolean {
+ // The app is in the background and no transitions are ongoing (during transitions,
+ // [isAppVisible] must always be true). Show the chip.
+ if (!newState.isAppVisible) return false
+
+ // The call has just started and is visible. Hide the chip.
+ if (oldState is OngoingCallModel.NoCall) return true
+
+ // The state went from the app not being visible to visible. This happens when the chip
+ // is tapped and a launch animation is about to start. Keep the chip showing.
+ if (!(oldState as OngoingCallModel.InCall).isAppVisible) return false
+
+ // The app was and remains visible, but the transition state has changed. A launch or
+ // return animation has been requested or is ongoing. Keep the chip showing.
+ if (
+ newTransitionState is TransitionState.LaunchRequested ||
+ newTransitionState is TransitionState.Launching ||
+ newTransitionState is TransitionState.ReturnRequested ||
+ newTransitionState is TransitionState.Returning
+ ) {
+ return false
+ }
+
+ // The app was and remains visible, so we generally want to hide the chip. The only
+ // exception is if a return transition has just ended. In this case, the transition
+ // state changes shortly before the app visibility does. If we hide the chip between
+ // these two updates, this results in a flicker. We bridge the gap by keeping the chip
+ // showing.
+ return oldTransitionState != TransitionState.Returning
+ }
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChip.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChip.kt
index 4edb23dc9f0e..58d38903f7cf 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChip.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChip.kt
@@ -42,6 +42,7 @@ import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.common.ui.compose.load
import com.android.systemui.res.R
import com.android.systemui.statusbar.StatusBarIconView
+import com.android.systemui.statusbar.chips.StatusBarChipsReturnAnimations
import com.android.systemui.statusbar.chips.ui.model.ColorsModel
import com.android.systemui.statusbar.chips.ui.model.OngoingActivityChipModel
import com.android.systemui.statusbar.core.StatusBarConnectedDisplays
@@ -90,6 +91,8 @@ fun OngoingActivityChip(
},
borderStroke = borderStroke,
onClick = onClick,
+ useModifierBasedImplementation = StatusBarChipsReturnAnimations.isEnabled,
+ transitionControllerFactory = model.transitionManager?.controllerFactory,
) {
ChipBody(model, iconViewStore, isClickable = onClick != null)
}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChips.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChips.kt
index 407849b9fae0..700e6d93c628 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChips.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/compose/OngoingActivityChips.kt
@@ -21,12 +21,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import com.android.systemui.compose.modifiers.sysuiResTag
import com.android.systemui.res.R
+import com.android.systemui.statusbar.chips.StatusBarChipsReturnAnimations
import com.android.systemui.statusbar.chips.ui.model.MultipleOngoingActivityChipsModel
import com.android.systemui.statusbar.notification.icon.ui.viewbinder.NotificationIconContainerViewBinder
@@ -36,6 +38,18 @@ fun OngoingActivityChips(
iconViewStore: NotificationIconContainerViewBinder.IconViewStore?,
modifier: Modifier = Modifier,
) {
+ if (StatusBarChipsReturnAnimations.isEnabled) {
+ SideEffect {
+ // Active chips must always be capable of animating to/from activities, even when they
+ // are hidden. Therefore we always register their transitions.
+ for (chip in chips.active) chip.transitionManager?.registerTransition?.invoke()
+ // Inactive chips and chips in the overflow are never shown, so they must not have any
+ // registered transition.
+ for (chip in chips.overflow) chip.transitionManager?.unregisterTransition?.invoke()
+ for (chip in chips.inactive) chip.transitionManager?.unregisterTransition?.invoke()
+ }
+ }
+
val shownChips = chips.active.filter { !it.isHidden }
if (shownChips.isNotEmpty()) {
Row(
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt
index d37a46e58882..3876d9fa77a3 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/chips/ui/model/OngoingActivityChipModel.kt
@@ -20,6 +20,7 @@ import android.annotation.CurrentTimeMillisLong
import android.annotation.ElapsedRealtimeLong
import android.os.SystemClock
import android.view.View
+import com.android.systemui.animation.ComposableControllerFactory
import com.android.systemui.animation.Expandable
import com.android.systemui.common.shared.model.ContentDescription
import com.android.systemui.common.shared.model.Icon
@@ -33,6 +34,9 @@ sealed class OngoingActivityChipModel {
/** Condensed name representing the model, used for logs. */
abstract val logName: String
+ /** Object used to manage the behavior of this chip during activity launch and returns. */
+ abstract val transitionManager: TransitionManager?
+
/**
* This chip shouldn't be shown.
*
@@ -40,7 +44,10 @@ sealed class OngoingActivityChipModel {
* animated, and false if that transition should *not* be animated (i.e. the chip view should
* immediately disappear).
*/
- data class Inactive(val shouldAnimate: Boolean = true) : OngoingActivityChipModel() {
+ data class Inactive(
+ val shouldAnimate: Boolean = true,
+ override val transitionManager: TransitionManager? = null,
+ ) : OngoingActivityChipModel() {
override val logName = "Inactive(anim=$shouldAnimate)"
}
@@ -61,6 +68,7 @@ sealed class OngoingActivityChipModel {
open val onClickListenerLegacy: View.OnClickListener?,
/** Data class that determines how clicks on the chip should be handled. */
open val clickBehavior: ClickBehavior,
+ override val transitionManager: TransitionManager?,
/**
* Whether this chip should be hidden. This can be the case depending on system states (like
* which apps are in the foreground and whether there is an ongoing transition.
@@ -77,6 +85,7 @@ sealed class OngoingActivityChipModel {
override val colors: ColorsModel,
override val onClickListenerLegacy: View.OnClickListener?,
override val clickBehavior: ClickBehavior,
+ override val transitionManager: TransitionManager? = null,
override val isHidden: Boolean = false,
override val shouldAnimate: Boolean = true,
) :
@@ -86,6 +95,7 @@ sealed class OngoingActivityChipModel {
colors,
onClickListenerLegacy,
clickBehavior,
+ transitionManager,
isHidden,
shouldAnimate,
) {
@@ -122,6 +132,7 @@ sealed class OngoingActivityChipModel {
val isEventInFuture: Boolean = false,
override val onClickListenerLegacy: View.OnClickListener?,
override val clickBehavior: ClickBehavior,
+ override val transitionManager: TransitionManager? = null,
override val isHidden: Boolean = false,
override val shouldAnimate: Boolean = true,
) :
@@ -131,6 +142,7 @@ sealed class OngoingActivityChipModel {
colors,
onClickListenerLegacy,
clickBehavior,
+ transitionManager,
isHidden,
shouldAnimate,
) {
@@ -157,6 +169,7 @@ sealed class OngoingActivityChipModel {
@CurrentTimeMillisLong val time: Long,
override val onClickListenerLegacy: View.OnClickListener?,
override val clickBehavior: ClickBehavior,
+ override val transitionManager: TransitionManager? = null,
override val isHidden: Boolean = false,
override val shouldAnimate: Boolean = true,
) :
@@ -166,6 +179,7 @@ sealed class OngoingActivityChipModel {
colors,
onClickListenerLegacy,
clickBehavior,
+ transitionManager,
isHidden,
shouldAnimate,
) {
@@ -185,6 +199,7 @@ sealed class OngoingActivityChipModel {
override val colors: ColorsModel,
/** The number of seconds until an event is started. */
val secondsUntilStarted: Long,
+ override val transitionManager: TransitionManager? = null,
override val isHidden: Boolean = false,
override val shouldAnimate: Boolean = true,
) :
@@ -194,6 +209,7 @@ sealed class OngoingActivityChipModel {
colors,
onClickListenerLegacy = null,
clickBehavior = ClickBehavior.None,
+ transitionManager,
isHidden,
shouldAnimate,
) {
@@ -209,6 +225,7 @@ sealed class OngoingActivityChipModel {
val text: String,
override val onClickListenerLegacy: View.OnClickListener? = null,
override val clickBehavior: ClickBehavior,
+ override val transitionManager: TransitionManager? = null,
override val isHidden: Boolean = false,
override val shouldAnimate: Boolean = true,
) :
@@ -218,6 +235,7 @@ sealed class OngoingActivityChipModel {
colors,
onClickListenerLegacy,
clickBehavior,
+ transitionManager,
isHidden,
shouldAnimate,
) {
@@ -271,4 +289,17 @@ sealed class OngoingActivityChipModel {
/** Clicking the chip will show the heads up notification associated with the chip. */
data class ShowHeadsUpNotification(val onClick: () -> Unit) : ClickBehavior
}
+
+ /** Defines the behavior of the chip with respect to activity launch and return transitions. */
+ data class TransitionManager(
+ /** The factory used to create the controllers that animate the chip. */
+ val controllerFactory: ComposableControllerFactory? = null,
+ /**
+ * Used to create a registration for this chip using [controllerFactory]. Must be
+ * idempotent.
+ */
+ val registerTransition: () -> Unit = {},
+ /** Used to remove the existing registration for this chip, if any. */
+ val unregisterTransition: () -> Unit = {},
+ )
}