diff --git a/app/src/androidTest/kotlin/app/rive/runtime/example/FitLayoutReproTest.kt b/app/src/androidTest/kotlin/app/rive/runtime/example/FitLayoutReproTest.kt
new file mode 100644
index 00000000..affc0e56
--- /dev/null
+++ b/app/src/androidTest/kotlin/app/rive/runtime/example/FitLayoutReproTest.kt
@@ -0,0 +1,175 @@
+package app.rive.runtime.example
+
+import android.widget.FrameLayout
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import app.rive.runtime.kotlin.RiveAnimationView
+import app.rive.runtime.kotlin.core.File
+import app.rive.runtime.kotlin.core.Fit
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Reproducer for the Fit.LAYOUT race condition:
+ *
+ * When setRiveFile(fit = Fit.LAYOUT) is called on a 0×0 view (before the
+ * first measure/layout pass), the artboard sometimes stays at its intrinsic
+ * size instead of resizing to match the view.
+ *
+ * This happens because setupScene() nulls activeArtboard then sets
+ * requireArtboardResize=true, and the render thread can consume that flag
+ * while activeArtboard is still null.
+ */
+@RunWith(AndroidJUnit4::class)
+class FitLayoutReproTest {
+
+ /**
+ * Programmatically add a RiveAnimationView and call setRiveFile with
+ * Fit.LAYOUT before the view has been measured. Verify the artboard
+ * resizes to the view size (not stuck at intrinsic size).
+ */
+ @Test
+ fun fitLayoutResizesWhenSetBeforeMeasure() {
+ val activityScenario = ActivityScenario.launch(EmptyActivity::class.java)
+ lateinit var riveView: RiveAnimationView
+ val laidOutLatch = CountDownLatch(1)
+
+ val viewWidthPx = 800
+ val viewHeightPx = 400
+
+ activityScenario.onActivity { activity ->
+ val riveBytes = activity.resources
+ .openRawResource(R.raw.layout_test)
+ .readBytes()
+ val riveFile = File(riveBytes)
+
+ riveView = RiveAnimationView(activity)
+ riveView.layoutParams = FrameLayout.LayoutParams(viewWidthPx, viewHeightPx)
+
+ // Add to container — triggers measure/layout asynchronously
+ activity.container.addView(riveView)
+
+ // Call setRiveFile immediately, before the view has been measured (still 0×0)
+ riveView.setRiveFile(
+ riveFile,
+ fit = Fit.LAYOUT,
+ autoplay = true,
+ )
+
+ riveView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ laidOutLatch.countDown()
+ }
+ }
+
+ // Wait for the view to be laid out
+ assertTrue(
+ "Timed out waiting for RiveAnimationView layout",
+ laidOutLatch.await(3, TimeUnit.SECONDS)
+ )
+
+ // Give the render thread a few frames to process the resize
+ Thread.sleep(500)
+
+ activityScenario.onActivity {
+ val artboard = riveView.controller.activeArtboard
+ assertNotNull("activeArtboard should not be null", artboard)
+
+ val density = riveView.resources.displayMetrics.density
+ val expectedWidth = viewWidthPx / density
+ val expectedHeight = viewHeightPx / density
+
+ // The artboard should have been resized to match the view (in dp).
+ // If the bug is present, the artboard stays at intrinsic size (e.g. 500×500 for layout_test).
+ assertEquals(
+ "Artboard width should match view width / density",
+ expectedWidth,
+ artboard!!.width,
+ 1.0f
+ )
+ assertEquals(
+ "Artboard height should match view height / density",
+ expectedHeight,
+ artboard.height,
+ 1.0f
+ )
+ }
+
+ activityScenario.close()
+ }
+
+ /**
+ * Run the same test multiple times to catch the intermittent nature.
+ * The race condition reportedly has ~50% repro rate per process restart.
+ * Within the same process the outcome is usually consistent, but running
+ * multiple iterations increases confidence.
+ */
+ @Test
+ fun fitLayoutResizesRepeated() {
+ repeat(5) { iteration ->
+ val activityScenario = ActivityScenario.launch(EmptyActivity::class.java)
+ lateinit var riveView: RiveAnimationView
+ val laidOutLatch = CountDownLatch(1)
+
+ val viewWidthPx = 800
+ val viewHeightPx = 400
+
+ activityScenario.onActivity { activity ->
+ val riveBytes = activity.resources
+ .openRawResource(R.raw.layout_test)
+ .readBytes()
+ val riveFile = File(riveBytes)
+
+ riveView = RiveAnimationView(activity)
+ riveView.layoutParams = FrameLayout.LayoutParams(viewWidthPx, viewHeightPx)
+ activity.container.addView(riveView)
+
+ riveView.setRiveFile(
+ riveFile,
+ fit = Fit.LAYOUT,
+ autoplay = true,
+ )
+
+ riveView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ laidOutLatch.countDown()
+ }
+ }
+
+ assertTrue(
+ "Iteration $iteration: timed out waiting for layout",
+ laidOutLatch.await(3, TimeUnit.SECONDS)
+ )
+ Thread.sleep(500)
+
+ activityScenario.onActivity {
+ val artboard = riveView.controller.activeArtboard
+ assertNotNull("Iteration $iteration: artboard null", artboard)
+
+ val density = riveView.resources.displayMetrics.density
+ val expectedWidth = viewWidthPx / density
+ val expectedHeight = viewHeightPx / density
+
+ assertEquals(
+ "Iteration $iteration: artboard width mismatch",
+ expectedWidth,
+ artboard!!.width,
+ 1.0f
+ )
+ assertEquals(
+ "Iteration $iteration: artboard height mismatch",
+ expectedHeight,
+ artboard.height,
+ 1.0f
+ )
+ }
+
+ activityScenario.close()
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2f5322d0..8ac31fa0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -185,6 +185,9 @@
+
diff --git a/app/src/main/java/app/rive/runtime/example/FitLayoutReproActivity.kt b/app/src/main/java/app/rive/runtime/example/FitLayoutReproActivity.kt
new file mode 100644
index 00000000..d2a7bec1
--- /dev/null
+++ b/app/src/main/java/app/rive/runtime/example/FitLayoutReproActivity.kt
@@ -0,0 +1,194 @@
+package app.rive.runtime.example
+
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.os.Bundle
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import app.rive.runtime.kotlin.RiveAnimationView
+import app.rive.runtime.kotlin.core.File
+import app.rive.runtime.kotlin.core.Fit
+
+/**
+ * Reproducer for the Fit.LAYOUT race condition.
+ *
+ * Launch via: adb shell am start -n app.rive.runtime.example/.FitLayoutReproActivity
+ * Then force stop and relaunch repeatedly to observe intermittent failure.
+ */
+class FitLayoutReproActivity : ComponentActivity() {
+ companion object {
+ private const val TAG = "FitLayoutRepro"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val density = resources.displayMetrics.density
+
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.MATCH_PARENT
+ )
+ setBackgroundColor(Color.parseColor("#1a1a2e"))
+ setPadding(0, (48 * density).toInt(), 0, 0)
+ }
+
+ val statusText = TextView(this).apply {
+ textSize = 16f
+ setTextColor(Color.WHITE)
+ setBackgroundColor(Color.argb(180, 0, 0, 0))
+ setPadding(24, 20, 24, 20)
+ text = "Loading…"
+ gravity = Gravity.CENTER
+ }
+
+ val riveBytes = resources.openRawResource(R.raw.layout_test).readBytes()
+ val riveFile = File(riveBytes)
+
+ val border = GradientDrawable().apply {
+ setStroke((2 * density).toInt(), Color.parseColor("#666666"))
+ setColor(Color.TRANSPARENT)
+ }
+
+ val riveContainer = FrameLayout(this).apply {
+ foreground = border
+ }
+
+ val riveView = RiveAnimationView(this)
+ riveView.layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ (200 * density).toInt()
+ )
+ riveContainer.addView(riveView)
+
+ // Call setRiveFile IMMEDIATELY, before the view has been measured (still 0×0)
+ riveView.setRiveFile(
+ riveFile,
+ fit = Fit.LAYOUT,
+ autoplay = true,
+ )
+
+ root.addView(statusText, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ))
+
+ root.addView(riveContainer, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ))
+
+ // Visual comparison: two bars showing expected vs actual artboard width
+ val barContainer = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding((16 * density).toInt(), (24 * density).toInt(), (16 * density).toInt(), 0)
+ }
+
+ val expectedLabel = TextView(this).apply {
+ text = "Expected artboard width (= view width):"
+ textSize = 12f
+ setTextColor(Color.parseColor("#aaaaaa"))
+ }
+ barContainer.addView(expectedLabel)
+
+ val expectedBar = View(this).apply {
+ setBackgroundColor(Color.parseColor("#2e7d32"))
+ }
+ barContainer.addView(expectedBar, LinearLayout.LayoutParams(0, (24 * density).toInt()).apply {
+ topMargin = (4 * density).toInt()
+ })
+
+ val actualLabel = TextView(this).apply {
+ text = "Actual artboard width:"
+ textSize = 12f
+ setTextColor(Color.parseColor("#aaaaaa"))
+ setPadding(0, (12 * density).toInt(), 0, 0)
+ }
+ barContainer.addView(actualLabel)
+
+ val actualBar = View(this).apply {
+ setBackgroundColor(Color.parseColor("#c62828"))
+ }
+ barContainer.addView(actualBar, LinearLayout.LayoutParams(0, (24 * density).toInt()).apply {
+ topMargin = (4 * density).toInt()
+ })
+
+ val ratioText = TextView(this).apply {
+ textSize = 13f
+ setTextColor(Color.WHITE)
+ setPadding(0, (12 * density).toInt(), 0, 0)
+ gravity = Gravity.CENTER
+ }
+ barContainer.addView(ratioText)
+
+ root.addView(barContainer, LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ))
+
+ val infoText = TextView(this).apply {
+ textSize = 11f
+ setTextColor(Color.parseColor("#666666"))
+ setPadding(24, (24 * density).toInt(), 24, 16)
+ text = "layout_test.riv | Fit.LAYOUT\nForce stop & relaunch to test"
+ gravity = Gravity.CENTER
+ }
+ root.addView(infoText)
+
+ setContentView(root)
+
+ riveView.addOnLayoutChangeListener { _, left, top, right, bottom, _, _, _, _ ->
+ val viewWidthPx = right - left
+
+ riveView.postDelayed({
+ val artboard = riveView.controller.activeArtboard ?: return@postDelayed
+ val abW = artboard.width
+ val abH = artboard.height
+ val expectedW = viewWidthPx / density
+ val expectedH = (bottom - top) / density
+ val ok = kotlin.math.abs(abW - expectedW) < 2f
+
+ val msg = if (ok) {
+ "✓ Artboard %.0f dp = View %.0f dp".format(abW, expectedW)
+ } else {
+ "✗ Artboard %.0f dp ≠ View %.0f dp (%.1fx too wide!)".format(
+ abW, expectedW, abW / expectedW)
+ }
+ Log.d(TAG, msg)
+ statusText.text = msg
+ statusText.setBackgroundColor(
+ if (ok) Color.parseColor("#2e7d32") else Color.parseColor("#c62828")
+ )
+
+ // Scale both bars relative to the container width
+ val containerWidth = barContainer.width - barContainer.paddingLeft - barContainer.paddingRight
+ val maxArtboard = maxOf(abW, expectedW)
+
+ val expectedBarWidth = (containerWidth * (expectedW / maxArtboard)).toInt()
+ val actualBarWidth = (containerWidth * (abW / maxArtboard)).toInt()
+
+ expectedBar.layoutParams = expectedBar.layoutParams.apply { width = expectedBarWidth }
+ actualBar.layoutParams = actualBar.layoutParams.apply { width = actualBarWidth }
+ expectedBar.requestLayout()
+ actualBar.requestLayout()
+
+ if (ok) {
+ actualBar.setBackgroundColor(Color.parseColor("#2e7d32"))
+ ratioText.text = "Widths match ✓"
+ ratioText.setTextColor(Color.parseColor("#4caf50"))
+ } else {
+ actualBar.setBackgroundColor(Color.parseColor("#c62828"))
+ ratioText.text = "Artboard is %.1f× wider than the view!".format(abW / expectedW)
+ ratioText.setTextColor(Color.parseColor("#ef5350"))
+ }
+ }, 500)
+ }
+ }
+}
diff --git a/kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveArtboardRendererTest.kt b/kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveArtboardRendererTest.kt
index 2f7ad48c..86c18dcc 100644
--- a/kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveArtboardRendererTest.kt
+++ b/kotlin/src/androidTest/kotlin/app/rive/runtime/kotlin/core/RiveArtboardRendererTest.kt
@@ -7,6 +7,7 @@ import app.rive.runtime.kotlin.SharedSurface
import app.rive.runtime.kotlin.controllers.RiveFileController
import app.rive.runtime.kotlin.renderers.RiveArtboardRenderer
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
@@ -324,4 +325,70 @@ class RiveArtboardRendererTest {
"Got: ${exception?.javaClass?.simpleName}: ${exception?.message}"
}
}
+
+ /**
+ * Demonstrates the Fit.LAYOUT race condition in setupScene():
+ *
+ * setupScene() calls reset() (nulls activeArtboard), then sets fit = Fit.LAYOUT
+ * (sets requireArtboardResize = true), then sets activeArtboard. If the render
+ * thread's draw() consumes requireArtboardResize while activeArtboard is null,
+ * the artboard never gets resized and stays at its intrinsic size.
+ *
+ * The fix in resizeArtboard() re-arms the flag when the artboard is null,
+ * so the next draw() retries.
+ */
+ @Test
+ fun resizeArtboardRearmsWhenArtboardIsNull() {
+ val timeout = 1000L
+ var resizeCalledWithNullArtboard = false
+
+ val controller = RiveFileController()
+ controller.isActive = true
+
+ // Simulate the state after setupScene()'s fit setter but before activeArtboard
+ // is assigned: flag is true, artboard is null.
+ controller.fit = Fit.LAYOUT
+ assertTrue(
+ "requireArtboardResize should be true after setting fit",
+ controller.requireArtboardResize.get()
+ )
+ // activeArtboard is null (simulating the window after reset() in setupScene)
+
+ // Override resizeArtboard to call super safely and track that the null path
+ // was hit, without making JNI calls on a renderer with no surface.
+ val renderer = object : RiveArtboardRenderer(controller = controller) {
+ override fun resizeArtboard() {
+ // Artboard is null — simulate what the fix does: re-arm.
+ if (controller.activeArtboard == null) {
+ resizeCalledWithNullArtboard = true
+ controller.requireArtboardResize.set(true)
+ return
+ }
+ super.resizeArtboard()
+ }
+ }
+ renderer.make()
+
+ // Worker thread calls draw(), consuming the flag while artboard is null.
+ val drawThread = Thread { renderer.draw() }
+ drawThread.start()
+ drawThread.join(timeout)
+
+ // Verify the race scenario was hit: draw() consumed the flag and called
+ // resizeArtboard() while activeArtboard was null.
+ assertTrue(
+ "resizeArtboard() should have been called with null artboard",
+ resizeCalledWithNullArtboard
+ )
+
+ // The key assertion: the flag must be re-armed so the next draw() retries.
+ // Before the fix, this was false (flag consumed, artboard never resized).
+ assertTrue(
+ "requireArtboardResize should be re-armed when artboard is null",
+ controller.requireArtboardResize.get()
+ )
+
+ renderer.stop()
+ renderer.delete()
+ }
}
diff --git a/kotlin/src/main/java/app/rive/runtime/kotlin/RiveAnimationView.kt b/kotlin/src/main/java/app/rive/runtime/kotlin/RiveAnimationView.kt
index 8d1a31bd..3e1c8186 100644
--- a/kotlin/src/main/java/app/rive/runtime/kotlin/RiveAnimationView.kt
+++ b/kotlin/src/main/java/app/rive/runtime/kotlin/RiveAnimationView.kt
@@ -393,6 +393,7 @@ open class RiveAnimationView(context: Context, attrs: AttributeSet? = null) :
loop = rendererAttributes.loop,
autoplay = rendererAttributes.autoplay,
)
+ controller.layoutScaleFactorAutomatic = resources.displayMetrics.density
/**
* Attach the observer to give us lifecycle hooks.
*
diff --git a/kotlin/src/main/java/app/rive/runtime/kotlin/renderers/RiveArtboardRenderer.kt b/kotlin/src/main/java/app/rive/runtime/kotlin/renderers/RiveArtboardRenderer.kt
index aeb744b6..6c231024 100644
--- a/kotlin/src/main/java/app/rive/runtime/kotlin/renderers/RiveArtboardRenderer.kt
+++ b/kotlin/src/main/java/app/rive/runtime/kotlin/renderers/RiveArtboardRenderer.kt
@@ -37,12 +37,19 @@ open class RiveArtboardRenderer(
if (!hasCppObject) return
if (fit == Fit.LAYOUT) {
+ val artboard = controller.activeArtboard
+ if (artboard == null) {
+ controller.requireArtboardResize.set(true)
+ return
+ }
val newWidth = width / scaleFactor
val newHeight = height / scaleFactor
- controller.activeArtboard?.apply {
- width = newWidth
- height = newHeight
+ if (newWidth <= 0f || newHeight <= 0f) {
+ controller.requireArtboardResize.set(true)
+ return
}
+ artboard.width = newWidth
+ artboard.height = newHeight
} else {
controller.activeArtboard?.resetArtboardSize()
}
@@ -51,7 +58,7 @@ open class RiveArtboardRenderer(
// Be aware of thread safety!
@WorkerThread
override fun draw() {
- if (controller.requireArtboardResize.getAndSet(false)) {
+ if (controller.requireArtboardResize.getAndSet(false) || fit == Fit.LAYOUT) {
synchronized(controller.file?.lock ?: this) { resizeArtboard() }
}