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() } }