Skip to content

Commit e3739a8

Browse files
committed
feat: implement Android experimental backend using new app.rive.* API
Uses the new handle-based Rive Android SDK (app.rive.*) with CommandQueue, path-based ViewModelInstance property access, and Kotlin Flows for reactivity. Throws UnsupportedOperationException for SMI inputs, text runs, and events. Also fixes Gradle property resolution to use rootProject.findProperty.
1 parent cf71394 commit e3739a8

24 files changed

Lines changed: 1399 additions & 2 deletions

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ android {
107107
targetCompatibility JavaVersion.VERSION_1_8
108108
}
109109

110-
def useNewRiveApi = findProperty('USE_RIVE_NEW_API') == 'true'
110+
def useNewRiveApi = rootProject.findProperty('USE_RIVE_NEW_API') == 'true'
111111

112112
sourceSets {
113113
main {

android/gradle.properties

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@ Rive_minSdkVersion=24
33
Rive_targetSdkVersion=34
44
Rive_compileSdkVersion=35
55
Rive_ndkVersion=27.1.12297006
6-
USE_RIVE_NEW_API=false

android/src/experimental/java/com/margelo/nitro/rive/.gitkeep

Whitespace-only changes.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.margelo.nitro.rive
2+
3+
import android.util.Log
4+
import app.rive.AudioAsset
5+
import app.rive.FontAsset
6+
import app.rive.ImageAsset
7+
import app.rive.core.CommandQueue
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.launch
11+
12+
object ExperimentalAssetLoader {
13+
private const val TAG = "ExperimentalAssetLoader"
14+
15+
fun registerAssets(
16+
referencedAssets: ReferencedAssetsType?,
17+
riveWorker: CommandQueue
18+
) {
19+
val assetsData = referencedAssets?.data ?: return
20+
val scope = CoroutineScope(Dispatchers.IO)
21+
22+
for ((name, assetData) in assetsData) {
23+
val source = DataSourceResolver.resolve(assetData) ?: continue
24+
scope.launch {
25+
try {
26+
val loader = source.createLoader()
27+
val data = loader.load(source)
28+
val type = inferAssetType(name, data)
29+
registerAsset(data, name, type, riveWorker)
30+
} catch (e: Exception) {
31+
Log.e(TAG, "Failed to load asset '$name'", e)
32+
}
33+
}
34+
}
35+
}
36+
37+
fun updateAssets(
38+
referencedAssets: ReferencedAssetsType,
39+
riveWorker: CommandQueue
40+
) {
41+
val assetsData = referencedAssets.data ?: return
42+
val scope = CoroutineScope(Dispatchers.IO)
43+
44+
for ((name, assetData) in assetsData) {
45+
val source = DataSourceResolver.resolve(assetData) ?: continue
46+
scope.launch {
47+
try {
48+
val loader = source.createLoader()
49+
val data = loader.load(source)
50+
val type = inferAssetType(name, data)
51+
registerAsset(data, name, type, riveWorker)
52+
} catch (e: Exception) {
53+
Log.e(TAG, "Failed to update asset '$name'", e)
54+
}
55+
}
56+
}
57+
}
58+
59+
private suspend fun registerAsset(
60+
data: ByteArray,
61+
name: String,
62+
type: AssetType,
63+
riveWorker: CommandQueue
64+
) {
65+
Log.i(TAG, "Registering $type asset '$name' (${data.size} bytes)")
66+
when (type) {
67+
AssetType.IMAGE -> {
68+
riveWorker.unregisterImage(name)
69+
val result = ImageAsset.fromBytes(riveWorker, data)
70+
if (result is app.rive.Result.Success) {
71+
result.value.register(name)
72+
Log.i(TAG, "Image '$name' registered")
73+
}
74+
}
75+
AssetType.FONT -> {
76+
riveWorker.unregisterFont(name)
77+
val result = FontAsset.fromBytes(riveWorker, data)
78+
if (result is app.rive.Result.Success) {
79+
result.value.register(name)
80+
Log.i(TAG, "Font '$name' registered")
81+
}
82+
}
83+
AssetType.AUDIO -> {
84+
riveWorker.unregisterAudio(name)
85+
val result = AudioAsset.fromBytes(riveWorker, data)
86+
if (result is app.rive.Result.Success) {
87+
result.value.register(name)
88+
Log.i(TAG, "Audio '$name' registered")
89+
}
90+
}
91+
}
92+
}
93+
94+
private fun inferAssetType(name: String, data: ByteArray): AssetType {
95+
val ext = name.substringAfterLast('.', "").lowercase()
96+
return when (ext) {
97+
"png", "jpg", "jpeg", "webp", "gif", "bmp", "svg" -> AssetType.IMAGE
98+
"ttf", "otf", "woff", "woff2" -> AssetType.FONT
99+
"wav", "mp3", "ogg", "flac", "aac", "m4a" -> AssetType.AUDIO
100+
else -> inferFromMagicBytes(data)
101+
}
102+
}
103+
104+
private fun inferFromMagicBytes(data: ByteArray): AssetType {
105+
if (data.size < 4) return AssetType.IMAGE
106+
107+
// PNG: 89 50 4E 47
108+
if (data[0] == 0x89.toByte() && data[1] == 0x50.toByte() &&
109+
data[2] == 0x4E.toByte() && data[3] == 0x47.toByte()) return AssetType.IMAGE
110+
// JPEG: FF D8 FF
111+
if (data[0] == 0xFF.toByte() && data[1] == 0xD8.toByte() &&
112+
data[2] == 0xFF.toByte()) return AssetType.IMAGE
113+
// WebP: RIFF....WEBP
114+
if (data[0] == 0x52.toByte() && data[1] == 0x49.toByte() &&
115+
data[2] == 0x46.toByte() && data[3] == 0x46.toByte()) return AssetType.IMAGE
116+
// ID3 (MP3): 49 44 33
117+
if (data[0] == 0x49.toByte() && data[1] == 0x44.toByte() &&
118+
data[2] == 0x33.toByte()) return AssetType.AUDIO
119+
// TrueType: 00 01 00 00
120+
if (data[0] == 0x00.toByte() && data[1] == 0x01.toByte() &&
121+
data[2] == 0x00.toByte() && data[3] == 0x00.toByte()) return AssetType.FONT
122+
// OpenType: 4F 54 54 4F ("OTTO")
123+
if (data[0] == 0x4F.toByte() && data[1] == 0x54.toByte() &&
124+
data[2] == 0x54.toByte() && data[3] == 0x4F.toByte()) return AssetType.FONT
125+
126+
return AssetType.IMAGE
127+
}
128+
129+
enum class AssetType { IMAGE, FONT, AUDIO }
130+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.margelo.nitro.rive
2+
3+
import androidx.annotation.Keep
4+
import com.facebook.proguard.annotations.DoNotStrip
5+
6+
@Keep
7+
@DoNotStrip
8+
class HybridBindableArtboard(
9+
private val name: String,
10+
internal val file: HybridRiveFile
11+
) : HybridBindableArtboardSpec() {
12+
13+
override val artboardName: String
14+
get() = name
15+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.margelo.nitro.rive
2+
3+
import android.util.Log
4+
import androidx.annotation.Keep
5+
import app.rive.Artboard
6+
import app.rive.RiveFile
7+
import app.rive.ViewModelSource
8+
import app.rive.core.CommandQueue
9+
import com.facebook.proguard.annotations.DoNotStrip
10+
import java.lang.ref.WeakReference
11+
import kotlinx.coroutines.runBlocking
12+
13+
@Keep
14+
@DoNotStrip
15+
class HybridRiveFile(
16+
internal var riveFile: RiveFile?,
17+
internal val riveWorker: CommandQueue
18+
) : HybridRiveFileSpec() {
19+
companion object {
20+
private const val TAG = "HybridRiveFile"
21+
}
22+
23+
private val weakViews = mutableListOf<WeakReference<HybridRiveView>>()
24+
25+
override val viewModelCount: Double?
26+
get() {
27+
val file = riveFile ?: return null
28+
return try {
29+
runBlocking { file.getViewModelNames() }.size.toDouble()
30+
} catch (e: Exception) {
31+
Log.e(TAG, "viewModelCount failed", e)
32+
null
33+
}
34+
}
35+
36+
override fun viewModelByIndex(index: Double): HybridViewModelSpec? {
37+
val file = riveFile ?: return null
38+
return try {
39+
val names = runBlocking { file.getViewModelNames() }
40+
val idx = index.toInt()
41+
if (idx < 0 || idx >= names.size) return null
42+
HybridViewModel(file, riveWorker, names[idx], this)
43+
} catch (e: Exception) {
44+
Log.e(TAG, "viewModelByIndex($index) failed", e)
45+
null
46+
}
47+
}
48+
49+
override fun viewModelByName(name: String): HybridViewModelSpec? {
50+
val file = riveFile ?: return null
51+
return try {
52+
val names = runBlocking { file.getViewModelNames() }
53+
if (!names.contains(name)) return null
54+
HybridViewModel(file, riveWorker, name, this)
55+
} catch (e: Exception) {
56+
Log.e(TAG, "viewModelByName('$name') failed", e)
57+
null
58+
}
59+
}
60+
61+
override fun defaultArtboardViewModel(artboardBy: ArtboardBy?): HybridViewModelSpec? {
62+
val file = riveFile ?: return null
63+
return try {
64+
val artboardNames = runBlocking { file.getArtboardNames() }
65+
val artboardName = when (artboardBy?.type) {
66+
ArtboardByTypes.INDEX -> artboardNames.getOrNull(artboardBy.index!!.toInt())
67+
ArtboardByTypes.NAME -> artboardBy.name
68+
null -> artboardNames.firstOrNull()
69+
} ?: return null
70+
71+
val artboard = Artboard.fromFile(file, artboardName)
72+
val vmSource = ViewModelSource.DefaultForArtboard(artboard)
73+
val vmNames = runBlocking { file.getViewModelNames() }
74+
if (vmNames.isEmpty()) return null
75+
HybridViewModel(file, riveWorker, vmNames.first(), this)
76+
} catch (e: Exception) {
77+
Log.e(TAG, "defaultArtboardViewModel failed", e)
78+
null
79+
}
80+
}
81+
82+
override val artboardCount: Double
83+
get() {
84+
val file = riveFile ?: return 0.0
85+
return try {
86+
runBlocking { file.getArtboardNames() }.size.toDouble()
87+
} catch (e: Exception) {
88+
Log.e(TAG, "artboardCount failed", e)
89+
0.0
90+
}
91+
}
92+
93+
override val artboardNames: Array<String>
94+
get() {
95+
val file = riveFile ?: return emptyArray()
96+
return try {
97+
runBlocking { file.getArtboardNames() }.toTypedArray()
98+
} catch (e: Exception) {
99+
Log.e(TAG, "artboardNames failed", e)
100+
emptyArray()
101+
}
102+
}
103+
104+
override fun getBindableArtboard(name: String): HybridBindableArtboardSpec {
105+
return HybridBindableArtboard(name, this)
106+
}
107+
108+
override fun getEnums(): Array<RiveEnumDefinition> {
109+
val file = riveFile ?: return emptyArray()
110+
return try {
111+
val enums = runBlocking { file.getEnums() }
112+
enums.map { enum ->
113+
RiveEnumDefinition(
114+
name = enum.name,
115+
values = enum.values.toTypedArray()
116+
)
117+
}.toTypedArray()
118+
} catch (e: Exception) {
119+
Log.e(TAG, "getEnums failed", e)
120+
emptyArray()
121+
}
122+
}
123+
124+
override fun updateReferencedAssets(referencedAssets: ReferencedAssetsType) {
125+
ExperimentalAssetLoader.updateAssets(referencedAssets, riveWorker)
126+
}
127+
128+
fun registerView(view: HybridRiveView) {
129+
weakViews.add(WeakReference(view))
130+
}
131+
132+
fun unregisterView(view: HybridRiveView) {
133+
weakViews.removeAll { it.get() == view }
134+
}
135+
136+
override fun dispose() {
137+
weakViews.clear()
138+
riveFile?.close()
139+
riveFile = null
140+
}
141+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.margelo.nitro.rive
2+
3+
import android.annotation.SuppressLint
4+
import android.util.Log
5+
import androidx.annotation.Keep
6+
import app.rive.RiveFile
7+
import app.rive.RiveFileSource
8+
import app.rive.core.CommandQueue
9+
import com.facebook.proguard.annotations.DoNotStrip
10+
import com.margelo.nitro.core.ArrayBuffer
11+
import com.margelo.nitro.core.Promise
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.withContext
14+
15+
@Keep
16+
@DoNotStrip
17+
class HybridRiveFileFactory : HybridRiveFileFactorySpec() {
18+
companion object {
19+
private const val TAG = "HybridRiveFileFactory"
20+
21+
@Volatile
22+
private var sharedWorker: CommandQueue? = null
23+
24+
@Synchronized
25+
fun getSharedWorker(): CommandQueue {
26+
return sharedWorker ?: CommandQueue().also { sharedWorker = it }
27+
}
28+
}
29+
30+
private suspend fun buildRiveFile(
31+
data: ByteArray,
32+
referencedAssets: ReferencedAssetsType?
33+
): HybridRiveFile {
34+
val worker = getSharedWorker()
35+
36+
ExperimentalAssetLoader.registerAssets(referencedAssets, worker)
37+
38+
val source = RiveFileSource.Bytes(data)
39+
val result = RiveFile.fromSource(source, worker)
40+
41+
val riveFile = when (result) {
42+
is app.rive.Result.Success -> result.value
43+
is app.rive.Result.Error -> throw Error("Failed to load Rive file: ${result.throwable.message}")
44+
else -> throw Error("Failed to load Rive file: unexpected result")
45+
}
46+
47+
return HybridRiveFile(riveFile, worker)
48+
}
49+
50+
override fun fromURL(url: String, loadCdn: Boolean, referencedAssets: ReferencedAssetsType?): Promise<HybridRiveFileSpec> {
51+
return Promise.async {
52+
val data = withContext(Dispatchers.IO) {
53+
HTTPDataLoader.downloadBytes(url)
54+
}
55+
buildRiveFile(data, referencedAssets)
56+
}
57+
}
58+
59+
override fun fromFileURL(fileURL: String, loadCdn: Boolean, referencedAssets: ReferencedAssetsType?): Promise<HybridRiveFileSpec> {
60+
if (!fileURL.startsWith("file://")) {
61+
throw Error("fromFileURL: URL must be a file URL: $fileURL")
62+
}
63+
64+
return Promise.async {
65+
val uri = java.net.URI(fileURL)
66+
val path = uri.path ?: throw Error("fromFileURL: Invalid URL: $fileURL")
67+
val data = withContext(Dispatchers.IO) {
68+
FileDataLoader.loadBytes(path)
69+
}
70+
buildRiveFile(data, referencedAssets)
71+
}
72+
}
73+
74+
@SuppressLint("DiscouragedApi")
75+
override fun fromResource(resource: String, loadCdn: Boolean, referencedAssets: ReferencedAssetsType?): Promise<HybridRiveFileSpec> {
76+
return Promise.async {
77+
val data = withContext(Dispatchers.IO) {
78+
ResourceDataLoader.loadBytes(resource)
79+
}
80+
buildRiveFile(data, referencedAssets)
81+
}
82+
}
83+
84+
override fun fromBytes(bytes: ArrayBuffer, loadCdn: Boolean, referencedAssets: ReferencedAssetsType?): Promise<HybridRiveFileSpec> {
85+
val buffer = bytes.getBuffer(false)
86+
return Promise.async {
87+
val byteArray = ByteArray(buffer.remaining())
88+
buffer.get(byteArray)
89+
buildRiveFile(byteArray, referencedAssets)
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)