Skip to content

Commit 95f803a

Browse files
umberto-sonninoumberto-sonnino
umberto-sonnino
andcommitted
fix(playback): LinearAnimationInstances advance the Artboard when no StateMachineInstance is playing 966a68a08f
Co-authored-by: Umberto Sonnino <umberto@rive.app>
1 parent 203dafd commit 95f803a

File tree

10 files changed

+418
-13
lines changed

10 files changed

+418
-13
lines changed

.rive_head

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4f9625183f27235352619de688c0cf6a66e932d7
1+
966a68a08f925601ea51b91f5615b4e379436bbb

kotlin/src/androidTest/java/app/rive/runtime/kotlin/core/RiveControllerTest.kt

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package app.rive.runtime.kotlin.core
22

33
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
45
import app.rive.runtime.kotlin.ChangedInput
56
import app.rive.runtime.kotlin.controllers.RiveFileController
7+
import app.rive.runtime.kotlin.core.errors.ArtboardException
68
import app.rive.runtime.kotlin.test.R
79
import org.junit.Assert.assertEquals
810
import org.junit.Assert.assertFalse
@@ -14,6 +16,7 @@ import org.junit.Test
1416
import org.junit.runner.RunWith
1517
import java.util.concurrent.ConcurrentLinkedQueue
1618
import java.util.concurrent.CountDownLatch
19+
import java.util.concurrent.locks.ReentrantLock
1720
import kotlin.concurrent.thread
1821

1922

@@ -146,7 +149,7 @@ class RiveControllerTest {
146149
// To simulate the threading conditions, we need a mock queue that allows interrupting at the precise moment when the bug occurs.
147150
class MockInputQueue(
148151
private val latch: CountDownLatch,
149-
items: Collection<ChangedInput> = emptyList()
152+
items: Collection<ChangedInput> = emptyList(),
150153
) : ConcurrentLinkedQueue<ChangedInput>(items) {
151154
override fun isEmpty(): Boolean {
152155
val wasEmpty = super.isEmpty() // Cache the result before it's cleared
@@ -179,4 +182,109 @@ class RiveControllerTest {
179182

180183
// No assertions. The intent is to run without throwing a NoSuchElementException.
181184
}
185+
186+
private class ArtboardSpy(unsafeCppPointer: Long, lock: ReentrantLock) :
187+
Artboard(unsafeCppPointer, lock) {
188+
final var advanceCount = 0
189+
private set
190+
191+
override fun advance(elapsedTime: Float): Boolean {
192+
advanceCount++
193+
return super.advance(elapsedTime)
194+
}
195+
196+
fun resetSpy() {
197+
advanceCount = 0
198+
}
199+
}
200+
201+
// Custom File class to ensure ArtboardSpy is instantiated
202+
private class FileSpy(bytes: ByteArray) : File(bytes) {
203+
override fun artboard(index: Int): Artboard {
204+
val artboardPointer = cppArtboardByIndex(cppPointer, index)
205+
if (artboardPointer == NULL_POINTER) {
206+
throw ArtboardException("No Artboard found at index $index.")
207+
}
208+
val ab = ArtboardSpy(artboardPointer, lock) // Instantiate the spy
209+
dependencies.add(ab)
210+
return ab
211+
}
212+
}
213+
214+
@Test
215+
fun artboardAdvancesWhenOnlyLinearAnimationsPlay() {
216+
UiThreadStatement.runOnUiThread {
217+
val mockView = TestUtils.MockRiveAnimationView(appContext)
218+
val file = FileSpy(
219+
appContext.resources.openRawResource(R.raw.off_road_car_blog).readBytes()
220+
)
221+
mockView.setRiveFile(file, animationName = "idle")
222+
val controller = mockView.controller
223+
val artboardSpy = controller.activeArtboard as ArtboardSpy
224+
225+
assertTrue(controller.isAdvancing)
226+
assertEquals(1, controller.playingAnimations.size)
227+
assertEquals(0, controller.playingStateMachines.size)
228+
229+
// On setup, advanced by 0, so the animation does not advance internally.
230+
assertEquals(0, artboardSpy.advanceCount)
231+
232+
// Actually advance.
233+
controller.advance(0.1f)
234+
assertEquals(1, artboardSpy.advanceCount)
235+
}
236+
}
237+
238+
@Test
239+
fun artboardDoesNotAdvanceWhenStateMachinesPlay() {
240+
UiThreadStatement.runOnUiThread {
241+
val mockView = TestUtils.MockNoopRiveAnimationView(appContext)
242+
val file = FileSpy(
243+
appContext.resources.openRawResource(R.raw.multiple_state_machines).readBytes()
244+
)
245+
mockView.setRiveFile(file, stateMachineName = "one")
246+
val controller = mockView.controller
247+
val artboardSpy = controller.activeArtboard as ArtboardSpy
248+
249+
assertTrue(controller.isAdvancing)
250+
assertEquals(0, controller.playingAnimations.size)
251+
assertEquals(1, controller.playingStateMachines.size)
252+
253+
artboardSpy.resetSpy()
254+
assertEquals(0, artboardSpy.advanceCount)
255+
controller.advance(0.1f)
256+
assertEquals(
257+
"State Machines should call advance() internally",
258+
0,
259+
artboardSpy.advanceCount
260+
)
261+
}
262+
}
263+
264+
@Test
265+
fun artboardDoesNotAdvanceWhenNothingPlays() {
266+
UiThreadStatement.runOnUiThread {
267+
val mockView = TestUtils.MockNoopRiveAnimationView(appContext)
268+
val file = FileSpy(
269+
appContext.resources.openRawResource(R.raw.multiple_animations).readBytes()
270+
)
271+
mockView.setRiveFile(file, autoplay = false) // Don't start playing immediately
272+
val controller = mockView.controller
273+
val artboardSpy = controller.activeArtboard as ArtboardSpy
274+
275+
assertFalse(controller.isAdvancing)
276+
assertEquals(0, controller.playingAnimations.size)
277+
assertEquals(0, controller.playingStateMachines.size)
278+
279+
artboardSpy.resetSpy()
280+
assertEquals(0, artboardSpy.advanceCount)
281+
282+
controller.advance(0.1f)
283+
assertEquals(
284+
"Artboard.advance() should NOT be called when nothing is playing.",
285+
0,
286+
artboardSpy.advanceCount
287+
)
288+
}
289+
}
182290
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package app.rive.runtime.kotlin.core
2+
3+
import androidx.test.ext.junit.runners.AndroidJUnit4
4+
import app.rive.runtime.kotlin.test.R
5+
import org.junit.Assert.assertEquals
6+
import org.junit.Assert.assertNull
7+
import org.junit.Before
8+
import org.junit.Test
9+
import org.junit.runner.RunWith
10+
11+
@RunWith(AndroidJUnit4::class)
12+
class RiveLinearAnimationInstanceAdvanceTest {
13+
private val testUtils = TestUtils()
14+
private val appContext = testUtils.context
15+
private lateinit var file: File
16+
private lateinit var artboard: Artboard
17+
18+
private val animDurationFrames = 60
19+
private val animFps = 60
20+
private val animDurationSecs = animDurationFrames.toFloat() / animFps.toFloat() // 1.0f
21+
22+
@Before
23+
fun setup() {
24+
file = File(appContext.resources.openRawResource(R.raw.multiple_animations).readBytes())
25+
artboard = file.firstArtboard // Should load 'New Artboard'
26+
}
27+
28+
@Test
29+
fun testAdvanceResultAdvanced() {
30+
val anim = artboard.animation("one")
31+
anim.loop = Loop.LOOP
32+
33+
// Advance less than full duration
34+
val resultNew = anim.advanceAndGetResult(animDurationSecs * 0.5f)
35+
assertEquals(AdvanceResult.ADVANCED, resultNew)
36+
assertEquals(animDurationSecs * 0.5f, anim.time, 0.001f)
37+
38+
// Check deprecated method compatibility
39+
anim.time(0f)
40+
val resultOld = anim.advance(animDurationSecs * 0.5f)
41+
assertNull(resultOld)
42+
}
43+
44+
@Test
45+
fun testAdvanceResultOneShot() {
46+
val anim = artboard.animation("one")
47+
anim.loop = Loop.ONESHOT
48+
49+
// Advance just before the end
50+
val resultPre = anim.advanceAndGetResult(animDurationSecs * 0.9f)
51+
assertEquals(AdvanceResult.ADVANCED, resultPre)
52+
53+
// Advance past the end
54+
val resultNew = anim.advanceAndGetResult(animDurationSecs * 0.2f)
55+
assertEquals(AdvanceResult.ONESHOT, resultNew)
56+
57+
// Check deprecated method compatibility
58+
anim.time(0f)
59+
val resultOldPre = anim.advance(animDurationSecs * 0.9f)
60+
assertNull(resultOldPre)
61+
val resultOldPost = anim.advance(animDurationSecs * 0.2f)
62+
assertEquals(Loop.ONESHOT, resultOldPost)
63+
}
64+
65+
@Test
66+
fun testAdvanceResultLoop() {
67+
val anim = artboard.animation("one")
68+
anim.loop = Loop.LOOP
69+
// Past the end
70+
val resultNew = anim.advanceAndGetResult(animDurationSecs * 1.2f)
71+
assertEquals(AdvanceResult.LOOP, resultNew)
72+
73+
anim.time(0f)
74+
val resultOld = anim.advance(animDurationSecs * 1.2f)
75+
assertEquals(Loop.LOOP, resultOld)
76+
}
77+
78+
@Test
79+
fun testAdvanceResultPingPongForwardToEnd() {
80+
val anim = artboard.animation("one")
81+
anim.loop = Loop.PINGPONG
82+
anim.direction = Direction.FORWARDS
83+
84+
// Past the end
85+
val resultNew = anim.advanceAndGetResult(animDurationSecs * 1.2f)
86+
assertEquals(AdvanceResult.PINGPONG, resultNew)
87+
assertEquals(Direction.BACKWARDS, anim.direction)
88+
89+
anim.time(0f)
90+
anim.direction = Direction.FORWARDS
91+
val resultOld = anim.advance(animDurationSecs * 1.2f)
92+
assertEquals(Loop.PINGPONG, resultOld)
93+
assertEquals(Direction.BACKWARDS, anim.direction)
94+
}
95+
96+
@Test
97+
fun testAdvanceResultPingPongBackwardToStart() {
98+
val anim = artboard.animation("one")
99+
anim.loop = Loop.PINGPONG
100+
anim.direction = Direction.BACKWARDS
101+
anim.time(anim.endTime)
102+
103+
val resultNew = anim.advanceAndGetResult(animDurationSecs * 1.2f)
104+
assertEquals(AdvanceResult.PINGPONG, resultNew)
105+
assertEquals(Direction.FORWARDS, anim.direction)
106+
107+
anim.time(anim.endTime)
108+
anim.direction = Direction.BACKWARDS
109+
val resultOld = anim.advance(animDurationSecs * 1.2f)
110+
assertEquals(Loop.PINGPONG, resultOld)
111+
assertEquals(Direction.FORWARDS, anim.direction)
112+
}
113+
114+
@Test
115+
fun testAdvanceResultNoneWhenFinished() {
116+
val anim = artboard.animation("one")
117+
anim.loop = Loop.ONESHOT
118+
119+
// Past the end to finish it
120+
val resultFinish = anim.advanceAndGetResult(animDurationSecs * 1.2f)
121+
assertEquals(AdvanceResult.ONESHOT, resultFinish)
122+
assertEquals(animDurationSecs, anim.time)
123+
124+
anim.time(0f)
125+
val resultOldFinish = anim.advance(animDurationSecs * 1.2f)
126+
assertEquals(Loop.ONESHOT, resultOldFinish)
127+
}
128+
129+
@Test
130+
fun testAdvanceResultWithZeroDelta() {
131+
val anim = artboard.animation("one")
132+
133+
anim.loop = Loop.LOOP
134+
val resultNewPlaying = anim.advanceAndGetResult(0.01f)
135+
assertEquals(AdvanceResult.ADVANCED, resultNewPlaying)
136+
137+
anim.loop = Loop.ONESHOT
138+
anim.advanceAndGetResult(animDurationSecs * 1.2f)
139+
assertEquals(animDurationSecs, anim.time)
140+
141+
val resultNewStopped = anim.advanceAndGetResult(0.0f)
142+
assertEquals(AdvanceResult.NONE, resultNewStopped)
143+
assertEquals(animDurationSecs, anim.time)
144+
}
145+
}

kotlin/src/androidTest/java/app/rive/runtime/kotlin/core/TestUtils.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@ package app.rive.runtime.kotlin.core
22

33
import android.content.Context
44
import android.graphics.RectF
5-
import androidx.annotation.VisibleForTesting
65
import androidx.test.platform.app.InstrumentationRegistry
76
import app.rive.runtime.kotlin.RiveAnimationView
8-
import app.rive.runtime.kotlin.renderers.RiveArtboardRenderer
97
import app.rive.runtime.kotlin.controllers.RiveFileController
108
import app.rive.runtime.kotlin.fonts.FontBytes
9+
import app.rive.runtime.kotlin.renderers.RiveArtboardRenderer
1110
import org.junit.Assert.assertEquals
12-
import org.junit.Before
1311
import java.util.concurrent.TimeoutException
1412
import kotlin.time.Duration
1513

kotlin/src/main/cpp/include/jni_refs.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ extern jfieldID GetOneShotLoopField();
3131
extern jfieldID GetLoopLoopField();
3232
extern jfieldID GetPingPongLoopField();
3333

34+
extern jclass GetAdvanceResultClass();
35+
extern jfieldID GetAdvanceResultAdvancedField();
36+
extern jfieldID GetAdvanceResultOneShotField();
37+
extern jfieldID GetAdvanceResultLoopField();
38+
extern jfieldID GetAdvanceResultPingPongField();
39+
extern jfieldID GetAdvanceResultNoneField();
40+
3441
extern jclass GetRiveEventReportClass();
3542
extern jmethodID GetRiveEventReportConstructorId();
3643

kotlin/src/main/cpp/src/bindings/bindings_linear_animation_instance.cpp

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,62 @@ extern "C"
5151
return loopValue;
5252
}
5353

54+
JNIEXPORT jobject JNICALL
55+
Java_app_rive_runtime_kotlin_core_LinearAnimationInstance_cppAdvanceAndGetResult(
56+
JNIEnv* env,
57+
jobject thisObj,
58+
jlong ref,
59+
jfloat elapsedTime)
60+
{
61+
auto animationInstance =
62+
reinterpret_cast<rive::LinearAnimationInstance*>(ref);
63+
64+
bool keepGoing = animationInstance->advance(elapsedTime);
65+
bool didLoop = animationInstance->didLoop();
66+
67+
jfieldID resultFieldId = nullptr;
68+
69+
if (didLoop)
70+
{
71+
rive::Loop loopType = animationInstance->loop();
72+
switch (loopType)
73+
{
74+
case rive::Loop::oneShot:
75+
resultFieldId = GetAdvanceResultOneShotField();
76+
break;
77+
case rive::Loop::loop:
78+
resultFieldId = GetAdvanceResultLoopField();
79+
break;
80+
case rive::Loop::pingPong:
81+
resultFieldId = GetAdvanceResultPingPongField();
82+
break;
83+
default:
84+
// This should not happen: if we looped, we should get a
85+
// loop result.
86+
assert(
87+
false); // N.B. asserts are compiled out in release mode
88+
resultFieldId = GetAdvanceResultNoneField();
89+
break;
90+
}
91+
}
92+
else if (keepGoing)
93+
{
94+
resultFieldId = GetAdvanceResultAdvancedField();
95+
}
96+
else
97+
{
98+
resultFieldId = GetAdvanceResultNoneField();
99+
}
100+
101+
jclass jAdvanceResultClass = GetAdvanceResultClass();
102+
103+
jobject advanceResultValue =
104+
env->GetStaticObjectField(jAdvanceResultClass, resultFieldId);
105+
env->DeleteLocalRef(jAdvanceResultClass);
106+
107+
return advanceResultValue;
108+
}
109+
54110
JNIEXPORT void JNICALL
55111
Java_app_rive_runtime_kotlin_core_LinearAnimationInstance_cppApply(
56112
JNIEnv* env,

0 commit comments

Comments
 (0)