Add bottomsheet predictive back animation for SysUI

Bug: 340724858
Flag: com.android.systemui.predictiveBackAnimateDialogs
Test: atest BackAnimationSpecTest
Test: atest SystemUIDialogTest
Test: atest BackTransformationTest
Test: Manual, i.e. testing predictive back animation for volume panel bottomsheet on device
Change-Id: Ica27bb102e747dd83503eae70a93cfaad1906050
diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackAnimationSpecForSysUi.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackAnimationSpecForSysUi.kt
index b057296..536f297 100644
--- a/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackAnimationSpecForSysUi.kt
+++ b/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackAnimationSpecForSysUi.kt
@@ -73,3 +73,11 @@
         maxMarginYdp = 8f,
         minScale = 0.9f,
     )
+
+/**
+ * SysUI transitions - Bottomsheet (AT3)
+ * https://carbon.googleplex.com/predictive-back-for-apps/pages/at-3-bottom-sheets
+ */
+fun BackAnimationSpec.Companion.bottomSheetForSysUi(
+    displayMetricsProvider: () -> DisplayMetrics,
+): BackAnimationSpec = BackAnimationSpec.createBottomsheetAnimationSpec(displayMetricsProvider)
diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackTransformation.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackTransformation.kt
index 49d1fb4..029f62c 100644
--- a/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackTransformation.kt
+++ b/packages/SystemUI/animation/src/com/android/systemui/animation/back/BackTransformation.kt
@@ -26,12 +26,36 @@
     var translateX: Float = Float.NaN,
     var translateY: Float = Float.NaN,
     var scale: Float = Float.NaN,
+    var scalePivotPosition: ScalePivotPosition? = null,
 )
 
+/** Enum that describes the location of the scale pivot position */
+enum class ScalePivotPosition {
+    // more options may be added in the future
+    CENTER,
+    BOTTOM_CENTER;
+
+    fun applyTo(view: View) {
+        val pivotX =
+            when (this) {
+                CENTER -> view.width / 2f
+                BOTTOM_CENTER -> view.width / 2f
+            }
+        val pivotY =
+            when (this) {
+                CENTER -> view.height / 2f
+                BOTTOM_CENTER -> view.height.toFloat()
+            }
+        view.pivotX = pivotX
+        view.pivotY = pivotY
+    }
+}
+
 /** Apply the transformation to the [targetView] */
 fun BackTransformation.applyTo(targetView: View) {
     if (translateX.isFinite()) targetView.translationX = translateX
     if (translateY.isFinite()) targetView.translationY = translateY
+    scalePivotPosition?.applyTo(targetView)
     if (scale.isFinite()) {
         targetView.scaleX = scale
         targetView.scaleY = scale
diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/back/BottomsheetBackAnimationSpec.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/back/BottomsheetBackAnimationSpec.kt
new file mode 100644
index 0000000..b1945a1
--- /dev/null
+++ b/packages/SystemUI/animation/src/com/android/systemui/animation/back/BottomsheetBackAnimationSpec.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.animation.back
+
+import android.util.DisplayMetrics
+import android.view.animation.Interpolator
+import com.android.app.animation.Interpolators
+import com.android.systemui.util.dpToPx
+
+private const val MAX_SCALE_DELTA_DP = 48
+
+/** Create a [BackAnimationSpec] from [displayMetrics] and design specs. */
+fun BackAnimationSpec.Companion.createBottomsheetAnimationSpec(
+    displayMetricsProvider: () -> DisplayMetrics,
+    scaleEasing: Interpolator = Interpolators.BACK_GESTURE,
+): BackAnimationSpec {
+    return BackAnimationSpec { backEvent, _, result ->
+        val displayMetrics = displayMetricsProvider()
+        val screenWidthPx = displayMetrics.widthPixels
+        val minScale = 1 - MAX_SCALE_DELTA_DP.dpToPx(displayMetrics) / screenWidthPx
+        val progressX = backEvent.progress
+        val ratioScale = scaleEasing.getInterpolation(progressX)
+        result.apply {
+            scale = 1f - ratioScale * (1f - minScale)
+            scalePivotPosition = ScalePivotPosition.BOTTOM_CENTER
+        }
+    }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/phone/EdgeToEdgeDialogDelegate.kt b/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/phone/EdgeToEdgeDialogDelegate.kt
index 55dfed4..5fc78c0 100644
--- a/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/phone/EdgeToEdgeDialogDelegate.kt
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/statusbar/phone/EdgeToEdgeDialogDelegate.kt
@@ -17,8 +17,11 @@
 package com.android.systemui.statusbar.phone
 
 import android.os.Bundle
+import android.util.DisplayMetrics
 import android.view.Gravity
 import android.view.WindowManager
+import com.android.systemui.animation.back.BackAnimationSpec
+import com.android.systemui.animation.back.bottomSheetForSysUi
 
 /** [DialogDelegate] that configures a dialog to be an edge-to-edge one. */
 class EdgeToEdgeDialogDelegate : DialogDelegate<SystemUIDialog> {
@@ -40,4 +43,10 @@
     override fun getWidth(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.MATCH_PARENT
 
     override fun getHeight(dialog: SystemUIDialog): Int = WindowManager.LayoutParams.MATCH_PARENT
+
+    override fun getBackAnimationSpec(
+        displayMetricsProvider: () -> DisplayMetrics
+    ): BackAnimationSpec {
+        return BackAnimationSpec.bottomSheetForSysUi(displayMetricsProvider)
+    }
 }
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/phone/SystemUIDialogTest.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/phone/SystemUIDialogTest.java
index 1cdf8dc..79b5cc3 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/phone/SystemUIDialogTest.java
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/statusbar/phone/SystemUIDialogTest.java
@@ -21,8 +21,10 @@
 import static junit.framework.Assert.assertFalse;
 import static junit.framework.Assert.assertTrue;
 
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import android.content.BroadcastReceiver;
 import android.content.Intent;
@@ -41,6 +43,7 @@
 import com.android.systemui.Flags;
 import com.android.systemui.SysuiTestCase;
 import com.android.systemui.animation.DialogTransitionAnimator;
+import com.android.systemui.animation.back.BackAnimationSpec;
 import com.android.systemui.broadcast.BroadcastDispatcher;
 import com.android.systemui.model.SysUiState;
 
@@ -78,6 +81,8 @@
         MockitoAnnotations.initMocks(this);
 
         mDependency.injectTestDependency(BroadcastDispatcher.class, mBroadcastDispatcher);
+        when(mDelegate.getBackAnimationSpec(ArgumentMatchers.any()))
+                .thenReturn(mock(BackAnimationSpec.class));
     }
 
     @Test
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/DialogDelegate.kt b/packages/SystemUI/src/com/android/systemui/statusbar/phone/DialogDelegate.kt
index 25d1f05..2beb66b 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/DialogDelegate.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/DialogDelegate.kt
@@ -19,7 +19,10 @@
 import android.app.Dialog
 import android.content.res.Configuration
 import android.os.Bundle
+import android.util.DisplayMetrics
 import android.view.ViewRootImpl
+import com.android.systemui.animation.back.BackAnimationSpec
+import com.android.systemui.animation.back.floatingSystemSurfacesForSysUi
 
 /**
  * A delegate class that should be implemented in place of subclassing [Dialog].
@@ -49,4 +52,7 @@
     fun getWidth(dialog: T): Int = SystemUIDialog.getDefaultDialogWidth(dialog)
 
     fun getHeight(dialog: T): Int = SystemUIDialog.getDefaultDialogHeight()
+
+    fun getBackAnimationSpec(displayMetricsProvider: () -> DisplayMetrics): BackAnimationSpec =
+        BackAnimationSpec.floatingSystemSurfacesForSysUi(displayMetricsProvider)
 }
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java
index c74dde5..e01556f 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java
@@ -269,9 +269,12 @@
             mOnCreateRunnables.get(i).run();
         }
         if (predictiveBackAnimateDialogs()) {
+            View targetView = getWindow().getDecorView();
             DialogKt.registerAnimationOnBackInvoked(
                     /* dialog = */ this,
-                    /* targetView = */ getWindow().getDecorView()
+                    /* targetView = */ targetView,
+                    /* backAnimationSpec= */mDelegate.getBackAnimationSpec(
+                            () -> targetView.getResources().getDisplayMetrics())
             );
         }
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackAnimationSpecTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackAnimationSpecTest.kt
index 190babd..0ed84ea 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackAnimationSpecTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackAnimationSpecTest.kt
@@ -4,7 +4,9 @@
 import android.window.BackEvent
 import androidx.test.filters.SmallTest
 import com.android.systemui.SysuiTestCase
+import com.android.systemui.util.dpToPx
 import com.google.common.truth.Truth.assertThat
+import junit.framework.TestCase.assertEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
@@ -60,6 +62,58 @@
             expected = BackTransformation(translateX = 0f, translateY = maxY, scale = 1f),
         )
     }
+
+    @Test
+    fun sysUi_bottomsheet_animationValues() {
+        val minScale = 1 - 48.dpToPx(displayMetrics) / displayMetrics.widthPixels
+
+        val backAnimationSpec = BackAnimationSpec.bottomSheetForSysUi { displayMetrics }
+
+        assertBackTransformation(
+            backAnimationSpec = backAnimationSpec,
+            backInput = BackInput(progressX = 0f, progressY = 0f, edge = BackEvent.EDGE_LEFT),
+            expected =
+                BackTransformation(
+                    translateX = Float.NaN,
+                    translateY = Float.NaN,
+                    scale = 1f,
+                    scalePivotPosition = ScalePivotPosition.BOTTOM_CENTER
+                ),
+        )
+        assertBackTransformation(
+            backAnimationSpec = backAnimationSpec,
+            backInput = BackInput(progressX = 1f, progressY = 0f, edge = BackEvent.EDGE_LEFT),
+            expected =
+                BackTransformation(
+                    translateX = Float.NaN,
+                    translateY = Float.NaN,
+                    scale = minScale,
+                    scalePivotPosition = ScalePivotPosition.BOTTOM_CENTER
+                ),
+        )
+        assertBackTransformation(
+            backAnimationSpec = backAnimationSpec,
+            backInput = BackInput(progressX = 1f, progressY = 0f, edge = BackEvent.EDGE_RIGHT),
+            expected =
+                BackTransformation(
+                    translateX = Float.NaN,
+                    translateY = Float.NaN,
+                    scale = minScale,
+                    scalePivotPosition = ScalePivotPosition.BOTTOM_CENTER
+                ),
+        )
+        assertBackTransformation(
+            backAnimationSpec = backAnimationSpec,
+            backInput = BackInput(progressX = 1f, progressY = 1f, edge = BackEvent.EDGE_LEFT),
+            expected =
+                BackTransformation(
+                    translateX = Float.NaN,
+                    translateY = Float.NaN,
+                    scale = minScale,
+                    scalePivotPosition = ScalePivotPosition.BOTTOM_CENTER
+                ),
+        )
+    }
 }
 
 private fun assertBackTransformation(
@@ -81,7 +135,16 @@
     )
 
     val tolerance = 0f
-    assertThat(actual.translateX).isWithin(tolerance).of(expected.translateX)
-    assertThat(actual.translateY).isWithin(tolerance).of(expected.translateY)
+    if (expected.translateX.isNaN()) {
+        assertEquals(expected.translateX, actual.translateX)
+    } else {
+        assertThat(actual.translateX).isWithin(tolerance).of(expected.translateX)
+    }
+    if (expected.translateY.isNaN()) {
+        assertEquals(expected.translateY, actual.translateY)
+    } else {
+        assertThat(actual.translateY).isWithin(tolerance).of(expected.translateY)
+    }
     assertThat(actual.scale).isWithin(tolerance).of(expected.scale)
+    assertEquals(expected.scalePivotPosition, actual.scalePivotPosition)
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackTransformationTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackTransformationTest.kt
index 190b3d2..44a5467 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackTransformationTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/animation/back/BackTransformationTest.kt
@@ -5,17 +5,25 @@
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.util.mockito.mock
 import com.google.common.truth.Truth.assertThat
+import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
 import org.mockito.Mockito.verify
 import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.kotlin.whenever
 
 @SmallTest
 @RunWith(JUnit4::class)
 class BackTransformationTest : SysuiTestCase() {
     private val targetView: View = mock()
 
+    @Before
+    fun setup() {
+        whenever(targetView.width).thenReturn(TARGET_VIEW_WIDTH)
+        whenever(targetView.height).thenReturn(TARGET_VIEW_HEIGHT)
+    }
+
     @Test
     fun defaultValue_noTransformation() {
         val transformation = BackTransformation()
@@ -70,6 +78,16 @@
     }
 
     @Test
+    fun applyTo_targetView_scale_pivot() {
+        val transformation = BackTransformation(scalePivotPosition = ScalePivotPosition.CENTER)
+
+        transformation.applyTo(targetView = targetView)
+
+        verify(targetView).pivotX = TARGET_VIEW_WIDTH / 2f
+        verify(targetView).pivotY = TARGET_VIEW_HEIGHT / 2f
+    }
+
+    @Test
     fun applyTo_targetView_noTransformation() {
         val transformation = BackTransformation()
 
@@ -77,4 +95,9 @@
 
         verifyNoMoreInteractions(targetView)
     }
+
+    companion object {
+        private const val TARGET_VIEW_WIDTH = 100
+        private const val TARGET_VIEW_HEIGHT = 50
+    }
 }