Add show-hide animations for the new Volume Dialog

Flag: com.android.systemui.volume_redesign
Test: passes presubmits
Bug: 369994090
Change-Id: Icba6f4fba4c0ebf3135e187d3ab3fd454ebaa726
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorTest.kt
index 7ce421a..06a3e8b 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorTest.kt
@@ -27,7 +27,7 @@
 import com.android.systemui.plugins.fakeVolumeDialogController
 import com.android.systemui.testKosmos
 import com.android.systemui.volume.Events
-import com.android.systemui.volume.dialog.domain.model.VolumeDialogVisibilityModel
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
 import com.google.common.truth.Truth.assertThat
 import kotlin.time.Duration.Companion.days
 import kotlin.time.Duration.Companion.seconds
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt
index 3fdf86a..cd8cdc8 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/dagger/module/VolumeDialogPluginModule.kt
@@ -17,6 +17,13 @@
 package com.android.systemui.volume.dialog.dagger.module
 
 import com.android.systemui.volume.dialog.dagger.VolumeDialogComponent
+import com.android.systemui.volume.dialog.utils.VolumeTracer
+import com.android.systemui.volume.dialog.utils.VolumeTracerImpl
+import dagger.Binds
 import dagger.Module
 
-@Module(subcomponents = [VolumeDialogComponent::class]) interface VolumeDialogPluginModule
+@Module(subcomponents = [VolumeDialogComponent::class])
+interface VolumeDialogPluginModule {
+
+    @Binds fun bindVolumeTracer(volumeTracer: VolumeTracerImpl): VolumeTracer
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/data/VolumeDialogVisibilityRepository.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/data/VolumeDialogVisibilityRepository.kt
new file mode 100644
index 0000000..2aeaa5c
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/data/VolumeDialogVisibilityRepository.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.data
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
+import javax.inject.Inject
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+@SysUISingleton
+class VolumeDialogVisibilityRepository @Inject constructor() {
+
+    private val mutableDialogVisibility =
+        MutableStateFlow<VolumeDialogVisibilityModel>(VolumeDialogVisibilityModel.Invisible)
+    val dialogVisibility: Flow<VolumeDialogVisibilityModel> = mutableDialogVisibility.asStateFlow()
+
+    fun updateVisibility(
+        update: (current: VolumeDialogVisibilityModel) -> VolumeDialogVisibilityModel
+    ) {
+        mutableDialogVisibility.update(update)
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractor.kt
index f7d6d90..2668589b 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractor.kt
@@ -20,8 +20,12 @@
 import com.android.systemui.volume.Events
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPlugin
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPluginScope
+import com.android.systemui.volume.dialog.data.VolumeDialogVisibilityRepository
 import com.android.systemui.volume.dialog.domain.model.VolumeDialogEventModel
-import com.android.systemui.volume.dialog.domain.model.VolumeDialogVisibilityModel
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel.Dismissed
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel.Visible
+import com.android.systemui.volume.dialog.utils.VolumeTracer
 import javax.inject.Inject
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.seconds
@@ -30,13 +34,11 @@
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.update
 
 private val MAX_DIALOG_SHOW_TIME: Duration = 3.seconds
 
@@ -53,14 +55,13 @@
 constructor(
     @VolumeDialogPlugin coroutineScope: CoroutineScope,
     callbacksInteractor: VolumeDialogCallbacksInteractor,
+    private val tracer: VolumeTracer,
+    private val repository: VolumeDialogVisibilityRepository,
 ) {
 
     @SuppressLint("SharedFlowCreation")
     private val mutableDismissDialogEvents = MutableSharedFlow<Unit>()
-    private val mutableDialogVisibility =
-        MutableStateFlow<VolumeDialogVisibilityModel>(VolumeDialogVisibilityModel.Invisible)
-
-    val dialogVisibility: Flow<VolumeDialogVisibilityModel> = mutableDialogVisibility.asStateFlow()
+    val dialogVisibility: Flow<VolumeDialogVisibilityModel> = repository.dialogVisibility
 
     init {
         merge(
@@ -70,12 +71,11 @@
                 },
                 callbacksInteractor.event,
             )
-            .onEach { event ->
-                VolumeDialogVisibilityModel.fromEvent(event)?.let { model ->
-                    mutableDialogVisibility.value = model
-                    if (model is VolumeDialogVisibilityModel.Visible) {
-                        resetDismissTimeout()
-                    }
+            .mapNotNull { it.toVisibilityModel() }
+            .onEach { model ->
+                updateVisibility { model }
+                if (model is VolumeDialogVisibilityModel.Visible) {
+                    resetDismissTimeout()
                 }
             }
             .launchIn(coroutineScope)
@@ -86,9 +86,9 @@
      * [dialogVisibility].
      */
     fun dismissDialog(reason: Int) {
-        mutableDialogVisibility.update {
-            if (it is VolumeDialogVisibilityModel.Dismissed) {
-                it
+        updateVisibility { visibilityModel ->
+            if (visibilityModel is VolumeDialogVisibilityModel.Dismissed) {
+                visibilityModel
             } else {
                 VolumeDialogVisibilityModel.Dismissed(reason)
             }
@@ -99,4 +99,19 @@
     suspend fun resetDismissTimeout() {
         mutableDismissDialogEvents.emit(Unit)
     }
+
+    private fun updateVisibility(
+        update: (VolumeDialogVisibilityModel) -> VolumeDialogVisibilityModel
+    ) {
+        repository.updateVisibility { update(it).also(tracer::traceVisibilityStart) }
+    }
+
+    private fun VolumeDialogEventModel.toVisibilityModel(): VolumeDialogVisibilityModel? {
+        return when (this) {
+            is VolumeDialogEventModel.DismissRequested -> Dismissed(reason)
+            is VolumeDialogEventModel.ShowRequested ->
+                Visible(reason, keyguardLocked, lockTaskModeState)
+            else -> null
+        }
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/settings/domain/VolumeDialogSettingsButtonInteractor.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/settings/domain/VolumeDialogSettingsButtonInteractor.kt
index db19634..2dd0bda 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/settings/domain/VolumeDialogSettingsButtonInteractor.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/settings/domain/VolumeDialogSettingsButtonInteractor.kt
@@ -23,7 +23,7 @@
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialog
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
 import com.android.systemui.volume.dialog.domain.interactor.VolumeDialogVisibilityInteractor
-import com.android.systemui.volume.dialog.domain.model.VolumeDialogVisibilityModel
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
 import com.android.systemui.volume.panel.domain.interactor.VolumePanelGlobalStateInteractor
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogVisibilityModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/shared/model/VolumeDialogVisibilityModel.kt
similarity index 65%
rename from packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogVisibilityModel.kt
rename to packages/SystemUI/src/com/android/systemui/volume/dialog/shared/model/VolumeDialogVisibilityModel.kt
index 646445d..56a707d 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/domain/model/VolumeDialogVisibilityModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/shared/model/VolumeDialogVisibilityModel.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package com.android.systemui.volume.dialog.domain.model
+package com.android.systemui.volume.dialog.shared.model
 
 /** Models current Volume Dialog visibility state. */
 sealed interface VolumeDialogVisibilityModel {
@@ -30,19 +30,4 @@
 
     /** Dialog has been shown and then dismissed. */
     data class Dismissed(val reason: Int) : Invisible
-
-    companion object {
-
-        /**
-         * Creates [VolumeDialogVisibilityModel] from appropriate events and returns null otherwise.
-         */
-        fun fromEvent(event: VolumeDialogEventModel): VolumeDialogVisibilityModel? {
-            return when (event) {
-                is VolumeDialogEventModel.DismissRequested -> Dismissed(event.reason)
-                is VolumeDialogEventModel.ShowRequested ->
-                    Visible(event.reason, event.keyguardLocked, event.lockTaskModeState)
-                else -> null
-            }
-        }
-    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogBinder.kt
index 9c88303..9452d8c 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogBinder.kt
@@ -33,6 +33,7 @@
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 
+/** Binds the Volume Dialog itself. */
 @VolumeDialogScope
 class VolumeDialogBinder
 @Inject
@@ -47,9 +48,13 @@
         with(dialog) {
             setupWindow(window!!)
             dialog.setContentView(R.layout.volume_dialog)
+            dialog.setCanceledOnTouchOutside(true)
 
             settingsButtonViewBinder.bind(dialog.requireViewById(R.id.settings_container))
-            volumeDialogViewBinder.bind(dialog.requireViewById(R.id.volume_dialog_container))
+            volumeDialogViewBinder.bind(
+                dialog,
+                dialog.requireViewById(R.id.volume_dialog_container),
+            )
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt
index 600d176..23e6eac 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/binder/VolumeDialogViewBinder.kt
@@ -16,32 +16,144 @@
 
 package com.android.systemui.volume.dialog.ui.binder
 
+import android.app.Dialog
+import android.view.Gravity
 import android.view.View
+import com.android.internal.view.RotationPolicy
 import com.android.systemui.lifecycle.WindowLifecycleState
 import com.android.systemui.lifecycle.repeatWhenAttached
-import com.android.systemui.lifecycle.setSnapshotBinding
 import com.android.systemui.lifecycle.viewModel
+import com.android.systemui.volume.SystemUIInterpolators
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
+import com.android.systemui.volume.dialog.ui.utils.JankListenerFactory
+import com.android.systemui.volume.dialog.ui.utils.suspendAnimate
+import com.android.systemui.volume.dialog.ui.viewmodel.VolumeDialogGravityViewModel
+import com.android.systemui.volume.dialog.ui.viewmodel.VolumeDialogResourcesViewModel
 import com.android.systemui.volume.dialog.ui.viewmodel.VolumeDialogViewModel
+import com.android.systemui.volume.dialog.utils.VolumeTracer
 import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.mapLatest
 
+/** Binds the root view of the Volume Dialog. */
+@OptIn(ExperimentalCoroutinesApi::class)
 @VolumeDialogScope
 class VolumeDialogViewBinder
 @Inject
-constructor(private val volumeDialogViewModelFactory: VolumeDialogViewModel.Factory) {
+constructor(
+    private val volumeResources: VolumeDialogResourcesViewModel,
+    private val gravityViewModel: VolumeDialogGravityViewModel,
+    private val viewModelFactory: VolumeDialogViewModel.Factory,
+    private val jankListenerFactory: JankListenerFactory,
+    private val tracer: VolumeTracer,
+) {
 
-    fun bind(view: View) {
+    fun bind(dialog: Dialog, view: View) {
+        view.alpha = 0f
         view.repeatWhenAttached {
             view.viewModel(
                 traceName = "VolumeDialogViewBinder",
                 minWindowLifecycleState = WindowLifecycleState.ATTACHED,
-                factory = { volumeDialogViewModelFactory.create() },
+                factory = { viewModelFactory.create() },
             ) { viewModel ->
-                view.setSnapshotBinding {}
+                animateVisibility(view, dialog, viewModel.dialogVisibilityModel)
 
                 awaitCancellation()
             }
         }
     }
+
+    private fun CoroutineScope.animateVisibility(
+        view: View,
+        dialog: Dialog,
+        visibilityModel: Flow<VolumeDialogVisibilityModel>,
+    ) {
+        visibilityModel
+            .mapLatest {
+                when (it) {
+                    is VolumeDialogVisibilityModel.Visible -> {
+                        tracer.traceVisibilityEnd(it)
+                        calculateTranslationX(view)?.let(view::setTranslationX)
+                        view.animateShow(volumeResources.dialogShowDurationMillis.first())
+                    }
+                    is VolumeDialogVisibilityModel.Dismissed -> {
+                        tracer.traceVisibilityEnd(it)
+                        view.animateHide(
+                            duration = volumeResources.dialogHideDurationMillis.first(),
+                            translationX = calculateTranslationX(view),
+                        )
+                        dialog.dismiss()
+                    }
+                    is VolumeDialogVisibilityModel.Invisible -> {
+                        // do nothing
+                    }
+                }
+            }
+            .launchIn(this)
+    }
+
+    private suspend fun calculateTranslationX(view: View): Float? {
+        return if (view.display.rotation == RotationPolicy.NATURAL_ROTATION) {
+            val dialogGravity = gravityViewModel.dialogGravity.first()
+            val isGravityLeft = (dialogGravity and Gravity.LEFT) == Gravity.LEFT
+            if (isGravityLeft) {
+                -1
+            } else {
+                1
+            } * view.width / 2.0f
+        } else {
+            null
+        }
+    }
+
+    private suspend fun View.animateShow(duration: Long) {
+        animate()
+            .alpha(1f)
+            .translationX(0f)
+            .setDuration(duration)
+            .setInterpolator(SystemUIInterpolators.LogDecelerateInterpolator())
+            .suspendAnimate(jankListenerFactory.show(this, duration))
+        /* TODO(b/369993851)
+        .withEndAction(Runnable {
+            if (!Prefs.getBoolean(mContext, Prefs.Key.TOUCHED_RINGER_TOGGLE, false)) {
+                if (mRingerIcon != null) {
+                    mRingerIcon.postOnAnimationDelayed(
+                        getSinglePressFor(mRingerIcon), 1500
+                    )
+                }
+            }
+        })
+         */
+    }
+
+    private suspend fun View.animateHide(duration: Long, translationX: Float?) {
+        val animator =
+            animate()
+                .alpha(0f)
+                .setDuration(duration)
+                .setInterpolator(SystemUIInterpolators.LogAccelerateInterpolator())
+        /*  TODO(b/369993851)
+        .withEndAction(
+            Runnable {
+                mHandler.postDelayed(
+                    Runnable {
+                        hideRingerDrawer()
+
+                    },
+                    50
+                )
+            }
+        )
+         */
+        if (translationX != null) {
+            animator.translationX(translationX)
+        }
+        animator.suspendAnimate(jankListenerFactory.dismiss(this, duration))
+    }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/JankListenerFactory.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/JankListenerFactory.kt
new file mode 100644
index 0000000..9fcd777
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/JankListenerFactory.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.ui.utils
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.view.View
+import com.android.internal.jank.Cuj
+import com.android.internal.jank.InteractionJankMonitor
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPluginScope
+import javax.inject.Inject
+
+/** Provides [Animator.AnimatorListener] to measure Volume CUJ Jank */
+@VolumeDialogPluginScope
+class JankListenerFactory
+@Inject
+constructor(private val interactionJankMonitor: InteractionJankMonitor) {
+
+    fun show(view: View, timeout: Long) = getJunkListener(view, "show", timeout)
+
+    fun update(view: View, timeout: Long) = getJunkListener(view, "update", timeout)
+
+    fun dismiss(view: View, timeout: Long) = getJunkListener(view, "dismiss", timeout)
+
+    private fun getJunkListener(
+        view: View,
+        type: String,
+        timeout: Long,
+    ): Animator.AnimatorListener {
+        return object : AnimatorListenerAdapter() {
+            override fun onAnimationStart(animation: Animator) {
+                interactionJankMonitor.begin(
+                    InteractionJankMonitor.Configuration.Builder.withView(
+                            Cuj.CUJ_VOLUME_CONTROL,
+                            view,
+                        )
+                        .setTag(type)
+                        .setTimeout(timeout)
+                )
+            }
+
+            override fun onAnimationEnd(animation: Animator) {
+                interactionJankMonitor.end(Cuj.CUJ_VOLUME_CONTROL)
+            }
+
+            override fun onAnimationCancel(animation: Animator) {
+                interactionJankMonitor.cancel(Cuj.CUJ_VOLUME_CONTROL)
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
new file mode 100644
index 0000000..4eae3b9a
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/utils/SuspendAnimators.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.ui.utils
+
+import android.animation.Animator
+import android.view.ViewPropertyAnimator
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+/**
+ * Starts animation and suspends until it's finished. Cancels the animation if the running coroutine
+ * is cancelled.
+ *
+ * Careful! This method overrides [ViewPropertyAnimator.setListener]. Use [animationListener]
+ * instead.
+ */
+suspend fun ViewPropertyAnimator.suspendAnimate(
+    animationListener: Animator.AnimatorListener? = null
+) = suspendCancellableCoroutine { continuation ->
+    start()
+    setListener(
+        object : Animator.AnimatorListener {
+            override fun onAnimationStart(animation: Animator) {
+                animationListener?.onAnimationStart(animation)
+            }
+
+            override fun onAnimationEnd(animation: Animator) {
+                continuation.resume(Unit)
+                animationListener?.onAnimationEnd(animation)
+            }
+
+            override fun onAnimationCancel(animation: Animator) {
+                animationListener?.onAnimationCancel(animation)
+            }
+
+            override fun onAnimationRepeat(animation: Animator) {
+                animationListener?.onAnimationRepeat(animation)
+            }
+        }
+    )
+    continuation.invokeOnCancellation { this.cancel() }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogGravityViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogGravityViewModel.kt
index df6523c..112afb1 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogGravityViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogGravityViewModel.kt
@@ -39,6 +39,7 @@
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.withContext
 
+/** Exposes dialog [GravityInt] for use in the UI layer. */
 @VolumeDialogScope
 class VolumeDialogGravityViewModel
 @Inject
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogPluginViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogPluginViewModel.kt
index 8aa0d09..f336d46 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogPluginViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogPluginViewModel.kt
@@ -16,15 +16,14 @@
 
 package com.android.systemui.volume.dialog.ui.viewmodel
 
-import android.app.Dialog
 import com.android.systemui.lifecycle.ExclusiveActivatable
 import com.android.systemui.plugins.VolumeDialogController
 import com.android.systemui.volume.Events
 import com.android.systemui.volume.dialog.dagger.VolumeDialogComponent
 import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPluginScope
 import com.android.systemui.volume.dialog.domain.interactor.VolumeDialogVisibilityInteractor
-import com.android.systemui.volume.dialog.domain.model.VolumeDialogVisibilityModel
 import com.android.systemui.volume.dialog.shared.VolumeDialogLogger
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
 import javax.inject.Inject
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.awaitCancellation
@@ -32,8 +31,7 @@
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.flow.onEach
 
 @OptIn(ExperimentalCoroutinesApi::class)
 @VolumeDialogPluginScope
@@ -49,6 +47,7 @@
     override suspend fun onActivated(): Nothing {
         coroutineScope {
             dialogVisibilityInteractor.dialogVisibility
+                .onEach { controller.notifyVisible(it is VolumeDialogVisibilityModel.Visible) }
                 .mapLatest { visibilityModel ->
                     with(visibilityModel) {
                         if (this is VolumeDialogVisibilityModel.Visible) {
@@ -78,15 +77,8 @@
                     dialogVisibilityInteractor.dismissDialog(Events.DISMISS_REASON_UNKNOWN)
                 }
             }
-        launch { dialog.awaitShow() }
+        dialog.show()
 
         Events.writeEvent(Events.EVENT_SHOW_DIALOG, reason, keyguardLocked)
     }
 }
-
-/** Shows [Dialog] until suspend function is cancelled. */
-private suspend fun Dialog.awaitShow() =
-    suspendCancellableCoroutine<Unit> {
-        show()
-        it.invokeOnCancellation { dismiss() }
-    }
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogResourcesViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogResourcesViewModel.kt
new file mode 100644
index 0000000..da9be98
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogResourcesViewModel.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.ui.viewmodel
+
+import android.content.Context
+import android.content.res.Resources
+import com.android.systemui.dagger.qualifiers.UiBackground
+import com.android.systemui.res.R
+import com.android.systemui.statusbar.policy.ConfigurationController
+import com.android.systemui.statusbar.policy.onConfigChanged
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialog
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogScope
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
+
+/**
+ * Provides cached resources [Flow]s that update when the configuration changes.
+ *
+ * Consume or use [kotlinx.coroutines.flow.first] to get the value.
+ */
+@VolumeDialogScope
+class VolumeDialogResourcesViewModel
+@Inject
+constructor(
+    @VolumeDialog private val coroutineScope: CoroutineScope,
+    @UiBackground private val uiBackgroundContext: CoroutineContext,
+    private val context: Context,
+    private val configurationController: ConfigurationController,
+) {
+
+    val dialogShowDurationMillis: Flow<Long> = configurationResource {
+        getInteger(R.integer.config_dialogShowAnimationDurationMs).toLong()
+    }
+
+    val dialogHideDurationMillis: Flow<Long> = configurationResource {
+        getInteger(R.integer.config_dialogHideAnimationDurationMs).toLong()
+    }
+
+    private fun <T> configurationResource(get: Resources.() -> T): Flow<T> =
+        configurationController.onConfigChanged
+            .map { context.resources.get() }
+            .onStart { emit(context.resources.get()) }
+            .flowOn(uiBackgroundContext)
+            .stateIn(coroutineScope, SharingStarted.Eagerly, null)
+            .filterNotNull()
+}
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt
index 30c8c15..84c837c 100644
--- a/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/ui/viewmodel/VolumeDialogViewModel.kt
@@ -17,11 +17,20 @@
 package com.android.systemui.volume.dialog.ui.viewmodel
 
 import com.android.systemui.lifecycle.ExclusiveActivatable
+import com.android.systemui.volume.dialog.domain.interactor.VolumeDialogVisibilityInteractor
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.Flow
 
-class VolumeDialogViewModel @AssistedInject constructor() : ExclusiveActivatable() {
+/** Provides a state for the Volume Dialog. */
+class VolumeDialogViewModel
+@AssistedInject
+constructor(dialogVisibilityInteractor: VolumeDialogVisibilityInteractor) : ExclusiveActivatable() {
+
+    val dialogVisibilityModel: Flow<VolumeDialogVisibilityModel> =
+        dialogVisibilityInteractor.dialogVisibility
 
     override suspend fun onActivated(): Nothing {
         awaitCancellation()
diff --git a/packages/SystemUI/src/com/android/systemui/volume/dialog/utils/VolumeTracer.kt b/packages/SystemUI/src/com/android/systemui/volume/dialog/utils/VolumeTracer.kt
new file mode 100644
index 0000000..db35ca7
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/volume/dialog/utils/VolumeTracer.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.utils
+
+import android.os.Trace
+import com.android.systemui.volume.dialog.dagger.scope.VolumeDialogPluginScope
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
+import javax.inject.Inject
+
+/** Traces the async sections for the Volume Dialog. */
+interface VolumeTracer {
+
+    fun traceVisibilityStart(model: VolumeDialogVisibilityModel)
+
+    fun traceVisibilityEnd(model: VolumeDialogVisibilityModel)
+}
+
+@VolumeDialogPluginScope
+class VolumeTracerImpl @Inject constructor() : VolumeTracer {
+
+    override fun traceVisibilityStart(model: VolumeDialogVisibilityModel) =
+        with(model) { Trace.beginAsyncSection(methodName, tracingCookie) }
+
+    override fun traceVisibilityEnd(model: VolumeDialogVisibilityModel) =
+        with(model) { Trace.endAsyncSection(methodName, tracingCookie) }
+
+    private val VolumeDialogVisibilityModel.tracingCookie
+        get() = this.hashCode()
+
+    private val VolumeDialogVisibilityModel.methodName
+        get() =
+            when (this) {
+                is VolumeDialogVisibilityModel.Visible -> "VolumeDialog#show"
+                is VolumeDialogVisibilityModel.Dismissed -> "VolumeDialog#dismiss"
+                is VolumeDialogVisibilityModel.Invisible -> error("Invisible is unsupported")
+            }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/data/repository/VolumeDialogVisibilityRepositoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/data/repository/VolumeDialogVisibilityRepositoryKosmos.kt
new file mode 100644
index 0000000..291dfc0
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/data/repository/VolumeDialogVisibilityRepositoryKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.data.repository
+
+import com.android.systemui.kosmos.Kosmos
+import com.android.systemui.volume.dialog.data.VolumeDialogVisibilityRepository
+
+val Kosmos.volumeDialogVisibilityRepository by Kosmos.Fixture { VolumeDialogVisibilityRepository() }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorKosmos.kt
index e73539e..7376c7f 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/domain/interactor/VolumeDialogVisibilityInteractorKosmos.kt
@@ -18,8 +18,15 @@
 
 import com.android.systemui.kosmos.Kosmos
 import com.android.systemui.kosmos.applicationCoroutineScope
+import com.android.systemui.volume.dialog.data.repository.volumeDialogVisibilityRepository
+import com.android.systemui.volume.dialog.utils.volumeTracer
 
 val Kosmos.volumeDialogVisibilityInteractor by
     Kosmos.Fixture {
-        VolumeDialogVisibilityInteractor(applicationCoroutineScope, volumeDialogCallbacksInteractor)
+        VolumeDialogVisibilityInteractor(
+            applicationCoroutineScope,
+            volumeDialogCallbacksInteractor,
+            volumeTracer,
+            volumeDialogVisibilityRepository,
+        )
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/utils/FakeVolumeTracer.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/utils/FakeVolumeTracer.kt
new file mode 100644
index 0000000..a5074eb
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/utils/FakeVolumeTracer.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.utils
+
+import com.android.systemui.volume.dialog.shared.model.VolumeDialogVisibilityModel
+
+class FakeVolumeTracer : VolumeTracer {
+
+    override fun traceVisibilityStart(model: VolumeDialogVisibilityModel) {}
+
+    override fun traceVisibilityEnd(model: VolumeDialogVisibilityModel) {}
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/utils/VolumeTracerKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/utils/VolumeTracerKosmos.kt
new file mode 100644
index 0000000..1382563
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/volume/dialog/utils/VolumeTracerKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.volume.dialog.utils
+
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.fakeVolumeTracer: FakeVolumeTracer by Kosmos.Fixture { FakeVolumeTracer() }
+var Kosmos.volumeTracer: VolumeTracer by Kosmos.Fixture { fakeVolumeTracer }