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..a62c7744 100644 --- a/kotlin/src/main/java/app/rive/runtime/kotlin/RiveAnimationView.kt +++ b/kotlin/src/main/java/app/rive/runtime/kotlin/RiveAnimationView.kt @@ -442,6 +442,7 @@ open class RiveAnimationView(context: Context, attrs: AttributeSet? = null) : override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { super.onSurfaceTextureSizeChanged(surface, width, height) controller.targetBounds = RectF(0.0f, 0.0f, width.toFloat(), height.toFloat()) + controller.requireArtboardResize.set(true) } override fun onSurfaceTextureAvailable( @@ -449,6 +450,7 @@ open class RiveAnimationView(context: Context, attrs: AttributeSet? = null) : ) { super.onSurfaceTextureAvailable(surfaceTexture, width, height) controller.targetBounds = RectF(0.0f, 0.0f, width.toFloat(), height.toFloat()) + controller.requireArtboardResize.set(true) } private fun loadFileFromResource(onComplete: (File) -> Unit) { 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() } }