Merge "Stop trying to draw ripples while unattached" into androidx-main
diff --git a/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleHostViewTest.kt b/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleHostViewTest.kt
new file mode 100644
index 0000000..828090b
--- /dev/null
+++ b/compose/material/material-ripple/src/androidInstrumentedTest/kotlin/androidx/compose/material/ripple/RippleHostViewTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2025 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 androidx.compose.material.ripple
+
+import android.graphics.RenderNode
+import android.os.Build
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.interaction.PressInteraction
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Test for [RippleHostView] */
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class RippleHostViewTest {
+
+    @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
+
+    /**
+     * Test for b/377222399
+     *
+     * Note, without the corresponding fix this test would only fail on Samsung devices, unless
+     * manually changing RippleDrawable.mRippleStyle.mRippleStyle to STYLE_SOLID through reflection.
+     */
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q)
+    @Test
+    fun doesNotDrawWhileUnattached() {
+        rule.runOnUiThread {
+            val activity = rule.activity
+
+            // View is explicitly not attached
+            val rippleHostView = RippleHostView(activity)
+
+            // Add a ripple while unattached
+            rippleHostView.addRipple(
+                PressInteraction.Press(Offset.Zero),
+                true,
+                Size(100f, 100f),
+                radius = 10,
+                color = Color.Red,
+                alpha = 0.4f,
+                onInvalidateRipple = {}
+            )
+
+            // Create a hardware backed canvas
+            val canvas = RenderNode("RippleHostViewTest").beginRecording()
+
+            // Should not crash
+            rippleHostView.draw(canvas)
+        }
+    }
+}
diff --git a/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt b/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt
index d1f3bf4..ef949d5 100644
--- a/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt
+++ b/compose/material/material-ripple/src/androidMain/kotlin/androidx/compose/material/ripple/RippleHostView.android.kt
@@ -18,6 +18,7 @@
 
 import android.content.Context
 import android.content.res.ColorStateList
+import android.graphics.Canvas
 import android.graphics.Rect
 import android.graphics.drawable.ColorDrawable
 import android.graphics.drawable.Drawable
@@ -52,6 +53,15 @@
         // noop
     }
 
+    override fun draw(canvas: Canvas) {
+        if (!isAttachedToWindow) {
+            // Cleanup any existing ripples if we added a ripple after being detached b/377222399
+            disposeRipple()
+            return
+        }
+        super.draw(canvas)
+    }
+
     override fun refreshDrawableState() {
         // We don't want the View to manage the drawable state, so avoid updating the ripple's
         // state (via View.mBackground) when we lose window focus, or other events.