Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@
<activity
android:name=".EmptyActivity"
android:exported="true" />
<activity
android:name=".FitLayoutReproActivity"
android:exported="true" />
</application>

</manifest>
194 changes: 194 additions & 0 deletions app/src/main/java/app/rive/runtime/example/FitLayoutReproActivity.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading