Skip to content

Commit dd06b3a

Browse files
committed
feat: implement Android experimental backend with CommandQueue API
Custom TextureView renderer with Choreographer render loop, CommandQueue polling infrastructure, ViewModel resolution and property validation, and example Compose test activities for manual testing.
1 parent daac64f commit dd06b3a

31 files changed

Lines changed: 1096 additions & 61 deletions

android/src/experimental/java/com/margelo/nitro/rive/ExperimentalAssetLoader.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,14 @@ object ExperimentalAssetLoader {
110110
// JPEG: FF D8 FF
111111
if (data[0] == 0xFF.toByte() && data[1] == 0xD8.toByte() &&
112112
data[2] == 0xFF.toByte()) return AssetType.IMAGE
113-
// WebP: RIFF....WEBP
113+
// RIFF container: WebP (RIFF....WEBP) or WAV (RIFF....WAVE)
114114
if (data[0] == 0x52.toByte() && data[1] == 0x49.toByte() &&
115-
data[2] == 0x46.toByte() && data[3] == 0x46.toByte()) return AssetType.IMAGE
115+
data[2] == 0x46.toByte() && data[3] == 0x46.toByte()) {
116+
if (data.size >= 12 &&
117+
data[8] == 0x57.toByte() && data[9] == 0x41.toByte() &&
118+
data[10] == 0x56.toByte() && data[11] == 0x45.toByte()) return AssetType.AUDIO // "WAVE"
119+
return AssetType.IMAGE // assume WebP for other RIFF
120+
}
116121
// ID3 (MP3): 49 44 33
117122
if (data[0] == 0x49.toByte() && data[1] == 0x44.toByte() &&
118123
data[2] == 0x33.toByte()) return AssetType.AUDIO

android/src/experimental/java/com/margelo/nitro/rive/HybridRiveFile.kt

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import android.util.Log
44
import androidx.annotation.Keep
55
import app.rive.Artboard
66
import app.rive.RiveFile
7+
import app.rive.ViewModelInstance
78
import app.rive.ViewModelSource
89
import app.rive.core.CommandQueue
10+
import app.rive.runtime.kotlin.core.ViewModel.PropertyDataType
911
import com.facebook.proguard.annotations.DoNotStrip
1012
import java.lang.ref.WeakReference
13+
import kotlinx.coroutines.flow.first
1114
import kotlinx.coroutines.runBlocking
1215

1316
@Keep
@@ -61,18 +64,23 @@ class HybridRiveFile(
6164
override fun defaultArtboardViewModel(artboardBy: ArtboardBy?): HybridViewModelSpec? {
6265
val file = riveFile ?: return null
6366
return try {
64-
val artboardNames = runBlocking { file.getArtboardNames() }
6567
val artboardName = when (artboardBy?.type) {
66-
ArtboardByTypes.INDEX -> artboardNames.getOrNull(artboardBy.index!!.toInt())
68+
ArtboardByTypes.INDEX -> {
69+
val artboardNames = runBlocking { file.getArtboardNames() }
70+
artboardNames.getOrNull(artboardBy.index!!.toInt())
71+
}
6772
ArtboardByTypes.NAME -> artboardBy.name
68-
null -> artboardNames.firstOrNull()
69-
} ?: return null
73+
null -> null
74+
}
7075

71-
val artboard = Artboard.fromFile(file, artboardName)
76+
val artboard = if (artboardName != null) {
77+
Artboard.fromFile(file, artboardName)
78+
} else {
79+
Artboard.fromFile(file)
80+
}
7281
val vmSource = ViewModelSource.DefaultForArtboard(artboard)
73-
val vmNames = runBlocking { file.getViewModelNames() }
74-
if (vmNames.isEmpty()) return null
75-
HybridViewModel(file, riveWorker, vmNames.first(), this)
82+
val resolvedName = runBlocking { resolveDefaultVMName(file, vmSource) }
83+
HybridViewModel(file, riveWorker, resolvedName, this, vmSource)
7684
} catch (e: Exception) {
7785
Log.e(TAG, "defaultArtboardViewModel failed", e)
7886
null
@@ -133,6 +141,49 @@ class HybridRiveFile(
133141
weakViews.removeAll { it.get() == view }
134142
}
135143

144+
/**
145+
* Resolves the actual ViewModel name for a DefaultForArtboard source.
146+
* The new Rive SDK doesn't expose VM name from DefaultForArtboard directly,
147+
* so we compare property values between the artboard VMI and named VMIs.
148+
*/
149+
private suspend fun resolveDefaultVMName(
150+
file: RiveFile,
151+
vmSource: ViewModelSource.DefaultForArtboard
152+
): String {
153+
val vmNames = file.getViewModelNames()
154+
if (vmNames.size <= 1) return vmNames.firstOrNull() ?: "default"
155+
156+
val artboardVmi = ViewModelInstance.fromFile(file, vmSource.defaultInstance())
157+
try {
158+
// Find a string property to use as identifier for value comparison
159+
val testPropName = vmNames.firstNotNullOfOrNull { name ->
160+
file.getViewModelProperties(name)
161+
.firstOrNull { it.type == PropertyDataType.STRING }
162+
?.name
163+
} ?: return vmNames.first()
164+
165+
val artboardValue = try {
166+
artboardVmi.getStringFlow(testPropName).first()
167+
} catch (_: Exception) { return vmNames.first() }
168+
169+
for (name in vmNames) {
170+
val namedVmi = ViewModelInstance.fromFile(file, ViewModelSource.Named(name).defaultInstance())
171+
try {
172+
val namedValue = try {
173+
namedVmi.getStringFlow(testPropName).first()
174+
} catch (_: Exception) { continue }
175+
if (namedValue == artboardValue) return name
176+
} finally {
177+
namedVmi.close()
178+
}
179+
}
180+
} finally {
181+
artboardVmi.close()
182+
}
183+
184+
return vmNames.firstOrNull() ?: "default"
185+
}
186+
136187
override fun dispose() {
137188
weakViews.clear()
138189
riveFile?.close()

android/src/experimental/java/com/margelo/nitro/rive/HybridRiveFileFactory.kt

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.margelo.nitro.rive
22

33
import android.annotation.SuppressLint
4+
import android.os.Handler
5+
import android.os.Looper
46
import android.util.Log
7+
import android.view.Choreographer
58
import androidx.annotation.Keep
69
import app.rive.RiveFile
710
import app.rive.RiveFileSource
@@ -12,6 +15,49 @@ import com.margelo.nitro.core.Promise
1215
import kotlinx.coroutines.Dispatchers
1316
import kotlinx.coroutines.withContext
1417

18+
/**
19+
* Custom RiveLog logger that logs to Logcat and broadcasts error messages
20+
* to registered listeners. This captures C++ errors from the Rive CommandQueue
21+
* (e.g., "State machine not found", "Draw failed") that are otherwise silent.
22+
*/
23+
object RiveErrorLogger : app.rive.RiveLog.Logger {
24+
private val logcat = app.rive.RiveLog.LogcatLogger()
25+
private val listeners = mutableListOf<(String) -> Unit>()
26+
private val reportedErrors = mutableSetOf<String>()
27+
28+
fun addListener(listener: (String) -> Unit) {
29+
synchronized(listeners) { listeners.add(listener) }
30+
}
31+
32+
fun removeListener(listener: (String) -> Unit) {
33+
synchronized(listeners) { listeners.remove(listener) }
34+
}
35+
36+
private fun broadcastError(tag: String, msg: String) {
37+
val key = "$tag:$msg"
38+
synchronized(reportedErrors) {
39+
if (!reportedErrors.add(key)) return
40+
}
41+
synchronized(listeners) {
42+
listeners.toList().forEach { it("[$tag] $msg") }
43+
}
44+
}
45+
46+
fun resetReportedErrors() {
47+
synchronized(reportedErrors) { reportedErrors.clear() }
48+
}
49+
50+
override fun v(tag: String, msg: () -> String) = logcat.v(tag, msg)
51+
override fun d(tag: String, msg: () -> String) = logcat.d(tag, msg)
52+
override fun i(tag: String, msg: () -> String) = logcat.i(tag, msg)
53+
override fun w(tag: String, msg: () -> String) = logcat.w(tag, msg)
54+
override fun e(tag: String, t: Throwable?, msg: () -> String) {
55+
val message = msg()
56+
logcat.e(tag, t) { message }
57+
broadcastError(tag, message)
58+
}
59+
}
60+
1561
@Keep
1662
@DoNotStrip
1763
class HybridRiveFileFactory : HybridRiveFileFactorySpec() {
@@ -20,10 +66,42 @@ class HybridRiveFileFactory : HybridRiveFileFactorySpec() {
2066

2167
@Volatile
2268
private var sharedWorker: CommandQueue? = null
69+
private var pollingStarted = false
2370

2471
@Synchronized
2572
fun getSharedWorker(): CommandQueue {
26-
return sharedWorker ?: CommandQueue().also { sharedWorker = it }
73+
if (app.rive.RiveLog.logger !is RiveErrorLogger) {
74+
app.rive.RiveLog.logger = RiveErrorLogger
75+
Log.d(TAG, "RiveErrorLogger installed")
76+
}
77+
return sharedWorker ?: CommandQueue().also {
78+
sharedWorker = it
79+
Log.d(TAG, "Created CommandQueue, refCount=${it.refCount}")
80+
startPolling(it)
81+
}
82+
}
83+
84+
/**
85+
* The experimental Rive SDK's CommandQueue needs to be polled every frame
86+
* to process responses from the C++ command server. Without polling,
87+
* all suspend functions (like RiveFile.fromSource) hang indefinitely.
88+
*/
89+
private fun startPolling(worker: CommandQueue) {
90+
if (pollingStarted) return
91+
pollingStarted = true
92+
Handler(Looper.getMainLooper()).post {
93+
val callback = object : Choreographer.FrameCallback {
94+
override fun doFrame(frameTimeNanos: Long) {
95+
try {
96+
worker.pollMessages()
97+
} catch (e: Exception) {
98+
Log.e(TAG, "pollMessages error", e)
99+
}
100+
Choreographer.getInstance().postFrameCallback(this)
101+
}
102+
}
103+
Choreographer.getInstance().postFrameCallback(callback)
104+
}
27105
}
28106
}
29107

android/src/experimental/java/com/margelo/nitro/rive/HybridRiveView.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
4949
private const val TAG = "HybridRiveView"
5050
}
5151

52-
override val view: RiveReactNativeView = RiveReactNativeView(context)
52+
override val view: RiveReactNativeView = RiveReactNativeView(context).apply {
53+
onError = { msg ->
54+
this@HybridRiveView.onError(RiveError(type = RiveErrorType.UNKNOWN, message = msg))
55+
}
56+
}
5357
private var needsReload = false
5458
private var dataBindingChanged = false
5559
private var initialUpdate = true
@@ -157,13 +161,15 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
157161
val hybridFile = file as? HybridRiveFile
158162
val riveFile = hybridFile?.riveFile ?: return@logged
159163

164+
val convertedFit = convertFit(fit, layoutScaleFactor?.toFloat()) ?: DefaultConfiguration.FIT
160165
val config = ViewConfiguration(
161166
artboardName = artboardName,
162167
stateMachineName = stateMachineName,
163168
autoPlay = autoPlay ?: DefaultConfiguration.AUTOPLAY,
164169
riveFile = riveFile,
170+
riveWorker = HybridRiveFileFactory.getSharedWorker(),
165171
alignment = convertAlignment(alignment) ?: DefaultConfiguration.ALIGNMENT,
166-
fit = convertFit(fit) ?: DefaultConfiguration.FIT,
172+
fit = convertedFit,
167173
layoutScaleFactor = layoutScaleFactor?.toFloat() ?: DefaultConfiguration.LAYOUTSCALEFACTOR,
168174
bindData = dataBind.toBindData()
169175
)
@@ -225,7 +231,7 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
225231
}
226232
}
227233

228-
private fun convertFit(fit: Fit?): RiveFit? {
234+
private fun convertFit(fit: Fit?, layoutScaleFactor: Float? = null): RiveFit? {
229235
if (fit == null) return null
230236
return when (fit) {
231237
Fit.FILL -> RiveFit.Fill
@@ -235,7 +241,7 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() {
235241
Fit.FITHEIGHT -> RiveFit.FitHeight()
236242
Fit.NONE -> RiveFit.None()
237243
Fit.SCALEDOWN -> RiveFit.ScaleDown()
238-
Fit.LAYOUT -> RiveFit.Layout()
244+
Fit.LAYOUT -> RiveFit.Layout(scaleFactor = layoutScaleFactor ?: 1f)
239245
}
240246
}
241247

android/src/experimental/java/com/margelo/nitro/rive/HybridViewModel.kt

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,33 +16,43 @@ class HybridViewModel(
1616
private val riveFile: RiveFile,
1717
private val riveWorker: CommandQueue,
1818
private val viewModelName: String,
19-
private val parentFile: HybridRiveFile
19+
private val parentFile: HybridRiveFile,
20+
private val vmSource: ViewModelSource = ViewModelSource.Named(viewModelName)
2021
) : HybridViewModelSpec() {
2122
companion object {
2223
private const val TAG = "HybridViewModel"
2324
}
2425

2526
override val propertyCount: Double
26-
get() = 0.0
27+
get() = try {
28+
runBlocking { riveFile.getViewModelProperties(viewModelName) }.size.toDouble()
29+
} catch (e: Exception) {
30+
Log.e(TAG, "propertyCount failed", e)
31+
0.0
32+
}
2733

2834
override val instanceCount: Double
29-
get() = 0.0
35+
get() = try {
36+
runBlocking { riveFile.getViewModelInstanceNames(viewModelName) }.size.toDouble()
37+
} catch (e: Exception) {
38+
Log.e(TAG, "instanceCount failed", e)
39+
0.0
40+
}
3041

3142
override val modelName: String
3243
get() = viewModelName
3344

34-
private val vmSource: ViewModelSource.Named
35-
get() = ViewModelSource.Named(viewModelName)
36-
3745
override fun createInstanceByIndex(index: Double): HybridViewModelInstanceSpec? {
3846
return createDefaultInstance()
3947
}
4048

4149
override fun createInstanceByName(name: String): HybridViewModelInstanceSpec? {
4250
return try {
51+
val instanceNames = runBlocking { riveFile.getViewModelInstanceNames(viewModelName) }
52+
if (!instanceNames.contains(name)) return null
4353
val source = vmSource.namedInstance(name)
4454
val vmi = ViewModelInstance.fromFile(riveFile, source)
45-
HybridViewModelInstance(vmi, riveWorker, parentFile)
55+
HybridViewModelInstance(vmi, riveWorker, parentFile, viewModelName, name)
4656
} catch (e: Exception) {
4757
Log.e(TAG, "createInstanceByName('$name') failed", e)
4858
null
@@ -53,7 +63,7 @@ class HybridViewModel(
5363
return try {
5464
val source = vmSource.defaultInstance()
5565
val vmi = ViewModelInstance.fromFile(riveFile, source)
56-
HybridViewModelInstance(vmi, riveWorker, parentFile)
66+
HybridViewModelInstance(vmi, riveWorker, parentFile, viewModelName)
5767
} catch (e: Exception) {
5868
Log.e(TAG, "createDefaultInstance failed", e)
5969
null
@@ -64,7 +74,7 @@ class HybridViewModel(
6474
return try {
6575
val source = vmSource.blankInstance()
6676
val vmi = ViewModelInstance.fromFile(riveFile, source)
67-
HybridViewModelInstance(vmi, riveWorker, parentFile)
77+
HybridViewModelInstance(vmi, riveWorker, parentFile, viewModelName)
6878
} catch (e: Exception) {
6979
Log.e(TAG, "createInstance (blank) failed", e)
7080
null

android/src/experimental/java/com/margelo/nitro/rive/HybridViewModelArtboardProperty.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ class HybridViewModelArtboardProperty(
1919

2020
override fun set(artboard: HybridBindableArtboardSpec?) {
2121
val hybridArtboard = artboard as? HybridBindableArtboard ?: return
22-
val file = riveFile.riveFile ?: return
22+
val sourceFile = hybridArtboard.file.riveFile ?: return
2323
try {
24-
val newArtboard = Artboard.fromFile(file, hybridArtboard.artboardName)
24+
val newArtboard = Artboard.fromFile(sourceFile, hybridArtboard.artboardName)
2525
instance.setArtboard(path, newArtboard)
2626
} catch (e: Exception) {
2727
Log.e(TAG, "Failed to set artboard for path '$path'", e)

android/src/experimental/java/com/margelo/nitro/rive/HybridViewModelColorProperty.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class HybridViewModelColorProperty(
2828
}
2929
}
3030
set(value) {
31-
instance.setColor(path, value.toInt())
31+
instance.setColor(path, value.toLong().toInt())
3232
}
3333

3434
override fun addListener(onChanged: (value: Double) -> Unit): () -> Unit {

0 commit comments

Comments
 (0)