Merge changes from topics "expandable-border", "expandable-interactive-size" into tm-qpr-dev

* changes:
  Introduce the FadingBackground modifier
  Make clickable Expandables have a minimum (interactive) size (1/2)
  Add support for borders in Expandable (1/2)
  Reconcile Expandable.kt on master/tm-qpr-dev-plus-aosp and tm-qpr-dev
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
index 8f9a4da..18534f4 100644
--- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
@@ -20,13 +20,19 @@
 import android.view.View
 import android.view.ViewGroup
 import android.view.ViewGroupOverlay
+import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.requiredSize
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
 import androidx.compose.material3.contentColorFor
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
@@ -34,46 +40,70 @@
 import androidx.compose.runtime.State
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCompositionContext
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.drawWithContent
 import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.RoundRect
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathOperation
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.graphics.drawscope.scale
 import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.layout
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.platform.ComposeView
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalViewConfiguration
 import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
 import androidx.lifecycle.ViewTreeLifecycleOwner
 import androidx.lifecycle.ViewTreeViewModelStoreOwner
-import androidx.savedstate.ViewTreeSavedStateRegistryOwner
+import com.android.systemui.animation.Expandable
 import com.android.systemui.animation.LaunchAnimator
+import kotlin.math.max
 import kotlin.math.min
+import kotlin.math.roundToInt
 
 /**
  * Create an expandable shape that can launch into an Activity or a Dialog.
  *
+ * If this expandable should be expanded when it is clicked directly, then you should specify a
+ * [onClick] handler, which will ensure that this expandable interactive size and background size
+ * are consistent with the M3 components (48dp and 40dp respectively).
+ *
+ * If this expandable should be expanded when a children component is clicked, like a button inside
+ * the expandable, then you can use the Expandable parameter passed to the [content] lambda.
+ *
  * Example:
  * ```
  *    Expandable(
  *      color = MaterialTheme.colorScheme.primary,
  *      shape = RoundedCornerShape(16.dp),
- *    ) { controller ->
- *      Row(
- *        Modifier
- *          // For activities:
- *          .clickable { activityStarter.startActivity(intent, controller.forActivity()) }
  *
- *          // For dialogs:
- *          .clickable { dialogLaunchAnimator.show(dialog, controller.forDialog()) }
- *      ) { ... }
+ *      // For activities:
+ *      onClick = { expandable ->
+ *          activityStarter.startActivity(intent, expandable.activityLaunchController())
+ *      },
+ *
+ *      // For dialogs:
+ *      onClick = { expandable ->
+ *          dialogLaunchAnimator.show(dialog, controller.dialogLaunchController())
+ *      },
+ *    ) {
+ *      ...
  *    }
  * ```
  *
@@ -86,11 +116,14 @@
     shape: Shape,
     modifier: Modifier = Modifier,
     contentColor: Color = contentColorFor(color),
-    content: @Composable (ExpandableController) -> Unit,
+    borderStroke: BorderStroke? = null,
+    onClick: ((Expandable) -> Unit)? = null,
+    content: @Composable (Expandable) -> Unit,
 ) {
     Expandable(
-        rememberExpandableController(color, shape, contentColor),
+        rememberExpandableController(color, shape, contentColor, borderStroke),
         modifier,
+        onClick,
         content,
     )
 }
@@ -119,11 +152,13 @@
  * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
  * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
  */
+@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun Expandable(
     controller: ExpandableController,
     modifier: Modifier = Modifier,
-    content: @Composable (ExpandableController) -> Unit,
+    onClick: ((Expandable) -> Unit)? = null,
+    content: @Composable (Expandable) -> Unit,
 ) {
     val controller = controller as ExpandableControllerImpl
     val color = controller.color
@@ -137,13 +172,23 @@
             CompositionLocalProvider(
                 LocalContentColor provides contentColor,
             ) {
-                content(controller)
+                // We make sure that the content itself (wrapped by the background) is at least
+                // 40.dp, which is the same as the M3 buttons. This applies even if onClick is
+                // null, to make it easier to write expandables that are sometimes clickable and
+                // sometimes not. There shouldn't be any Expandable smaller than 40dp because if
+                // the expandable is not clickable directly, then something in its content should
+                // be (and with a size >= 40dp).
+                val minSize = 40.dp
+                Box(
+                    Modifier.defaultMinSize(minWidth = minSize, minHeight = minSize),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    content(controller.expandable)
+                }
             }
         }
 
-    val thisExpandableSize by remember {
-        derivedStateOf { controller.boundsInComposeViewRoot.value.size }
-    }
+    var thisExpandableSize by remember { mutableStateOf(Size.Zero) }
 
     // Make sure we don't read animatorState directly here to avoid recomposition every time the
     // state changes (i.e. every frame of the animation).
@@ -153,22 +198,42 @@
         }
     }
 
+    // If this expandable is expanded when it's being directly clicked on, let's ensure that it has
+    // the minimum interactive size followed by all M3 components (48.dp).
+    val minInteractiveSizeModifier =
+        if (onClick != null && LocalMinimumTouchTargetEnforcement.current) {
+            // TODO(b/242040009): Replace this by Modifier.minimumInteractiveComponentSize() once
+            // http://aosp/2305511 is available.
+            val minTouchSize = LocalViewConfiguration.current.minimumTouchTargetSize
+            Modifier.layout { measurable, constraints ->
+                // Copied from androidx.compose.material3.InteractiveComponentSize.kt
+                val placeable = measurable.measure(constraints)
+                val width = maxOf(placeable.width, minTouchSize.width.roundToPx())
+                val height = maxOf(placeable.height, minTouchSize.height.roundToPx())
+                layout(width, height) {
+                    val centerX = ((width - placeable.width) / 2f).roundToInt()
+                    val centerY = ((height - placeable.height) / 2f).roundToInt()
+                    placeable.place(centerX, centerY)
+                }
+            }
+        } else {
+            Modifier
+        }
+
     when {
         isAnimating -> {
             // Don't compose the movable content during the animation, as it should be composed only
             // once at all times. We make this spacer exactly the same size as this Expandable when
             // it is visible.
             Spacer(
-                modifier
-                    .clip(shape)
-                    .requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
+                modifier.requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
             )
 
             // The content and its animated background in the overlay. We draw it only when we are
             // animating.
             AnimatedContentInOverlay(
                 color,
-                thisExpandableSize,
+                controller.boundsInComposeViewRoot.value.size,
                 controller.animatorState,
                 controller.overlay.value
                     ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
@@ -182,6 +247,8 @@
         controller.isDialogShowing.value -> {
             Box(
                 modifier
+                    .onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
+                    .then(minInteractiveSizeModifier)
                     .drawWithContent { /* Don't draw anything when the dialog is shown. */}
                     .onGloballyPositioned {
                         controller.boundsInComposeViewRoot.value = it.boundsInRoot()
@@ -189,11 +256,29 @@
             ) { wrappedContent(controller) }
         }
         else -> {
-            Box(
-                modifier.clip(shape).background(color, shape).onGloballyPositioned {
-                    controller.boundsInComposeViewRoot.value = it.boundsInRoot()
+            val clickModifier =
+                if (onClick != null) {
+                    Modifier.clickable { onClick(controller.expandable) }
+                } else {
+                    Modifier
                 }
-            ) { wrappedContent(controller) }
+
+            Box(
+                modifier
+                    .onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
+                    .then(minInteractiveSizeModifier)
+                    // Note that clip() *must* be above the clickModifier to properly clip the
+                    // ripple.
+                    .clip(shape)
+                    .then(clickModifier)
+                    .background(color, shape)
+                    .border(controller)
+                    .onGloballyPositioned {
+                        controller.boundsInComposeViewRoot.value = it.boundsInRoot()
+                    },
+            ) {
+                wrappedContent(controller)
+            }
         }
     }
 }
@@ -205,7 +290,7 @@
     sizeInOriginalLayout: Size,
     animatorState: State<LaunchAnimator.State?>,
     overlay: ViewGroupOverlay,
-    controller: ExpandableController,
+    controller: ExpandableControllerImpl,
     content: @Composable (ExpandableController) -> Unit,
     composeViewRoot: View,
     onOverlayComposeViewChanged: (View?) -> Unit,
@@ -255,24 +340,7 @@
                                     return@drawWithContent
                                 }
 
-                                val topRadius = animatorState.topCornerRadius
-                                val bottomRadius = animatorState.bottomCornerRadius
-                                if (topRadius == bottomRadius) {
-                                    // Shortcut to avoid Outline calculation and allocation.
-                                    val cornerRadius = CornerRadius(topRadius)
-                                    drawRoundRect(color, cornerRadius = cornerRadius)
-                                } else {
-                                    val shape =
-                                        RoundedCornerShape(
-                                            topStart = topRadius,
-                                            topEnd = topRadius,
-                                            bottomStart = bottomRadius,
-                                            bottomEnd = bottomRadius,
-                                        )
-                                    val outline = shape.createOutline(size, layoutDirection, this)
-                                    drawOutline(outline, color = color)
-                                }
-
+                                drawBackground(animatorState, color, controller.borderStroke)
                                 drawContent()
                             },
                             // We center the content in the expanding container.
@@ -361,3 +429,107 @@
     overlay.remove(view)
     return current as ViewGroup
 }
+
+private fun Modifier.border(controller: ExpandableControllerImpl): Modifier {
+    return if (controller.borderStroke != null) {
+        this.border(controller.borderStroke, controller.shape)
+    } else {
+        this
+    }
+}
+
+private fun ContentDrawScope.drawBackground(
+    animatorState: LaunchAnimator.State,
+    color: Color,
+    border: BorderStroke?,
+) {
+    val topRadius = animatorState.topCornerRadius
+    val bottomRadius = animatorState.bottomCornerRadius
+    if (topRadius == bottomRadius) {
+        // Shortcut to avoid Outline calculation and allocation.
+        val cornerRadius = CornerRadius(topRadius)
+
+        // Draw the background.
+        drawRoundRect(color, cornerRadius = cornerRadius)
+
+        // Draw the border.
+        if (border != null) {
+            // Copied from androidx.compose.foundation.Border.kt
+            val strokeWidth = border.width.toPx()
+            val halfStroke = strokeWidth / 2
+            val borderStroke = Stroke(strokeWidth)
+
+            drawRoundRect(
+                brush = border.brush,
+                topLeft = Offset(halfStroke, halfStroke),
+                size = Size(size.width - strokeWidth, size.height - strokeWidth),
+                cornerRadius = cornerRadius.shrink(halfStroke),
+                style = borderStroke
+            )
+        }
+    } else {
+        val shape =
+            RoundedCornerShape(
+                topStart = topRadius,
+                topEnd = topRadius,
+                bottomStart = bottomRadius,
+                bottomEnd = bottomRadius,
+            )
+        val outline = shape.createOutline(size, layoutDirection, this)
+
+        // Draw the background.
+        drawOutline(outline, color = color)
+
+        // Draw the border.
+        if (border != null) {
+            // Copied from androidx.compose.foundation.Border.kt.
+            val strokeWidth = border.width.toPx()
+            val path =
+                createRoundRectPath(
+                    (outline as Outline.Rounded).roundRect,
+                    strokeWidth,
+                )
+
+            drawPath(path, border.brush)
+        }
+    }
+}
+
+/**
+ * Helper method that creates a round rect with the inner region removed by the given stroke width.
+ *
+ * Copied from androidx.compose.foundation.Border.kt.
+ */
+private fun createRoundRectPath(
+    roundedRect: RoundRect,
+    strokeWidth: Float,
+): Path {
+    return Path().apply {
+        addRoundRect(roundedRect)
+        val insetPath =
+            Path().apply { addRoundRect(createInsetRoundedRect(strokeWidth, roundedRect)) }
+        op(this, insetPath, PathOperation.Difference)
+    }
+}
+
+/* Copied from androidx.compose.foundation.Border.kt. */
+private fun createInsetRoundedRect(widthPx: Float, roundedRect: RoundRect) =
+    RoundRect(
+        left = widthPx,
+        top = widthPx,
+        right = roundedRect.width - widthPx,
+        bottom = roundedRect.height - widthPx,
+        topLeftCornerRadius = roundedRect.topLeftCornerRadius.shrink(widthPx),
+        topRightCornerRadius = roundedRect.topRightCornerRadius.shrink(widthPx),
+        bottomLeftCornerRadius = roundedRect.bottomLeftCornerRadius.shrink(widthPx),
+        bottomRightCornerRadius = roundedRect.bottomRightCornerRadius.shrink(widthPx)
+    )
+
+/**
+ * Helper method to shrink the corner radius by the given value, clamping to 0 if the resultant
+ * corner radius would be negative.
+ *
+ * Copied from androidx.compose.foundation.Border.kt.
+ */
+private fun CornerRadius.shrink(value: Float): CornerRadius =
+    CornerRadius(max(0f, this.x - value), max(0f, this.y - value))
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt
index d6db574..f75b3a8 100644
--- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt
@@ -20,6 +20,7 @@
 import android.view.ViewGroup
 import android.view.ViewGroupOverlay
 import android.view.ViewRootImpl
+import androidx.compose.foundation.BorderStroke
 import androidx.compose.material3.contentColorFor
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
@@ -62,6 +63,7 @@
     color: Color,
     shape: Shape,
     contentColor: Color = contentColorFor(color),
+    borderStroke: BorderStroke? = null,
 ): ExpandableController {
     val composeViewRoot = LocalView.current
     val density = LocalDensity.current
@@ -87,11 +89,20 @@
     val isComposed = remember { mutableStateOf(true) }
     DisposableEffect(Unit) { onDispose { isComposed.value = false } }
 
-    return remember(color, contentColor, shape, composeViewRoot, density, layoutDirection) {
+    return remember(
+        color,
+        contentColor,
+        shape,
+        borderStroke,
+        composeViewRoot,
+        density,
+        layoutDirection,
+    ) {
         ExpandableControllerImpl(
             color,
             contentColor,
             shape,
+            borderStroke,
             composeViewRoot,
             density,
             animatorState,
@@ -109,6 +120,7 @@
     internal val color: Color,
     internal val contentColor: Color,
     internal val shape: Shape,
+    internal val borderStroke: BorderStroke?,
     internal val composeViewRoot: View,
     internal val density: Density,
     internal val animatorState: MutableState<LaunchAnimator.State?>,
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ViewTreeSavedStateRegistryOwner.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ViewTreeSavedStateRegistryOwner.kt
new file mode 100644
index 0000000..79f1cad1
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ViewTreeSavedStateRegistryOwner.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2022 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.compose.animation
+
+import android.view.View
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.ViewTreeSavedStateRegistryOwner as AndroidXViewTreeSavedStateRegistryOwner
+
+// TODO(b/262222023): Remove this workaround and import the new savedstate libraries in tm-qpr-dev
+// instead.
+object ViewTreeSavedStateRegistryOwner {
+    fun set(view: View, owner: SavedStateRegistryOwner?) {
+        AndroidXViewTreeSavedStateRegistryOwner.set(view, owner)
+    }
+
+    fun get(view: View): SavedStateRegistryOwner? {
+        return AndroidXViewTreeSavedStateRegistryOwner.get(view)
+    }
+}
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt
new file mode 100644
index 0000000..121bf2c
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2022 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.compose.modifiers
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.DrawModifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.drawOutline
+import androidx.compose.ui.graphics.drawscope.ContentDrawScope
+import androidx.compose.ui.platform.InspectorInfo
+import androidx.compose.ui.platform.InspectorValueInfo
+import androidx.compose.ui.platform.debugInspectorInfo
+import androidx.compose.ui.unit.LayoutDirection
+
+/**
+ * Draws a fading [shape] with a solid [color] and [alpha] behind the content.
+ *
+ * @param color color to paint background with
+ * @param alpha alpha of the background
+ * @param shape desired shape of the background
+ */
+fun Modifier.background(
+    color: Color,
+    alpha: () -> Float,
+    shape: Shape = RectangleShape,
+) =
+    this.then(
+        FadingBackground(
+            brush = SolidColor(color),
+            alpha = alpha,
+            shape = shape,
+            inspectorInfo =
+                debugInspectorInfo {
+                    name = "background"
+                    value = color
+                    properties["color"] = color
+                    properties["alpha"] = alpha
+                    properties["shape"] = shape
+                }
+        )
+    )
+
+private class FadingBackground
+constructor(
+    private val brush: Brush,
+    private val shape: Shape,
+    private val alpha: () -> Float,
+    inspectorInfo: InspectorInfo.() -> Unit
+) : DrawModifier, InspectorValueInfo(inspectorInfo) {
+    // naive cache outline calculation if size is the same
+    private var lastSize: Size? = null
+    private var lastLayoutDirection: LayoutDirection? = null
+    private var lastOutline: Outline? = null
+
+    override fun ContentDrawScope.draw() {
+        if (shape === RectangleShape) {
+            // shortcut to avoid Outline calculation and allocation
+            drawRect()
+        } else {
+            drawOutline()
+        }
+        drawContent()
+    }
+
+    private fun ContentDrawScope.drawRect() {
+        drawRect(brush, alpha = alpha())
+    }
+
+    private fun ContentDrawScope.drawOutline() {
+        val outline =
+            if (size == lastSize && layoutDirection == lastLayoutDirection) {
+                lastOutline!!
+            } else {
+                shape.createOutline(size, layoutDirection, this)
+            }
+        drawOutline(outline, brush = brush, alpha = alpha())
+        lastOutline = outline
+        lastSize = size
+        lastLayoutDirection = layoutDirection
+    }
+
+    override fun hashCode(): Int {
+        var result = brush.hashCode()
+        result = 31 * result + alpha.hashCode()
+        result = 31 * result + shape.hashCode()
+        return result
+    }
+
+    override fun equals(other: Any?): Boolean {
+        val otherModifier = other as? FadingBackground ?: return false
+        return brush == otherModifier.brush &&
+            alpha == otherModifier.alpha &&
+            shape == otherModifier.shape
+    }
+
+    override fun toString(): String = "FadingBackground(brush=$brush, alpha = $alpha, shape=$shape)"
+}