Skip to content
Merged
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
1 change: 1 addition & 0 deletions android/src/main/java/voltra/generated/ShortNames.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ object ShortNames {
"en" to "enabled",
"end" to "endAtMs",
"ep" to "endPoint",
"flb" to "fallback",
"fmh" to "fillMaxHeight",
"fmw" to "fillMaxWidth",
"fsh" to "fixedSizeHorizontal",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,18 @@ package voltra.glance.renderers
import androidx.compose.runtime.Composable
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.LocalContext
import androidx.glance.background
import androidx.glance.text.Text
import com.google.gson.Gson
import androidx.glance.unit.ColorProvider
import voltra.glance.LocalVoltraRenderContext
import voltra.glance.ResolvedStyle
import voltra.glance.applyClickableIfNeeded
import voltra.glance.resolveAndApplyStyle
import voltra.models.VoltraElement
import voltra.models.VoltraNode
import voltra.models.componentProp
import voltra.styling.JSColorParser
import voltra.styling.toGlanceTextStyle

private const val TAG = "TextAndImageRenderers"
private val gson = Gson()

@Composable
fun RenderText(
element: VoltraElement,
Expand Down Expand Up @@ -94,7 +92,20 @@ fun RenderImage(
alpha = alpha,
)
} else {
androidx.glance.layout.Box(modifier = finalModifier) {}
val fallbackNode =
element.componentProp(
"fallback",
renderContext.sharedStyles,
renderContext.sharedElements,
)

if (fallbackNode != null) {
androidx.glance.layout.Box(modifier = finalModifier) {
RenderNode(fallbackNode)
}
} else {
androidx.glance.layout.Box(modifier = finalModifier) {}
}
}
}

Expand Down
68 changes: 68 additions & 0 deletions android/src/main/java/voltra/models/VoltraPayload.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,71 @@ sealed class VoltraNode {
val text: String,
) : VoltraNode()
}

/**
* Resolve a raw prop value into a VoltraNode with shared context.
* Unlike parseVoltraNode, this eagerly resolves $r references (matching iOS behavior).
*/
@Suppress("UNCHECKED_CAST")
fun resolveToVoltraNode(
value: Any?,
sharedStyles: List<Map<String, Any>>?,
sharedElements: List<VoltraNode>?,
): VoltraNode? {
return when (value) {
null -> {
null
}

is VoltraNode -> {
value
}

is String -> {
VoltraNode.Text(value)
}

is Number -> {
VoltraNode.Text(value.toString())
}

is Boolean -> {
VoltraNode.Text(value.toString())
}

is List<*> -> {
val elements = value.mapNotNull { resolveToVoltraNode(it, sharedStyles, sharedElements) }
if (elements.isEmpty()) null else VoltraNode.Array(elements)
}

is Map<*, *> -> {
val map = value as Map<String, Any>
val ref = map["\$r"] as? Number
if (ref != null) {
return sharedElements?.getOrNull(ref.toInt())
}
val typeId = map["t"] as? Number ?: return null
val id = map["i"] as? String
val child = resolveToVoltraNode(map["c"], sharedStyles, sharedElements)
val props = map["p"] as? Map<String, Any>
VoltraNode.Element(VoltraElement(t = typeId.toInt(), i = id, c = child, p = props))
}

else -> {
null
}
}
}

/**
* Extract a ReactNode-typed prop from this element, resolving it with shared context.
* Mirrors iOS's VoltraElement.componentProp(_:).
*/
fun VoltraElement.componentProp(
propName: String,
sharedStyles: List<Map<String, Any>>?,
sharedElements: List<VoltraNode>?,
): VoltraNode? {
val value = p?.get(propName) ?: return null
return resolveToVoltraNode(value, sharedStyles, sharedElements)
}
106 changes: 103 additions & 3 deletions android/src/main/java/voltra/parsing/VoltraDecompressor.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package voltra.parsing

import android.util.Log
import com.google.gson.Gson
import voltra.generated.ShortNames
import voltra.models.*

Expand All @@ -8,6 +10,9 @@ import voltra.models.*
* This should be run as the first step after JSON parsing.
*/
object VoltraDecompressor {
private const val TAG = "VoltraDecompressor"
private val gson = Gson()

/**
* Decompress the entire payload recursively.
*/
Expand Down Expand Up @@ -44,13 +49,108 @@ object VoltraDecompressor {
val expandedKey = ShortNames.expand(key)
val expandedValue =
when (value) {
is Map<*, *> -> decompressMap(value as Map<String, Any>)
is List<*> -> value.map { if (it is Map<*, *>) decompressMap(it as Map<String, Any>) else it }
else -> value
is Map<*, *> -> {
val mapValue = value as Map<String, Any>
// Detect if this is a VoltraElement structure
if (mapValue["t"] is Number) {
Log.d(
TAG,
"decompressMap: Detected element structure in prop '$key' (expanded: '$expandedKey')",
)
decompressElementStructure(mapValue)
} else {
decompressMap(mapValue)
}
}

is List<*> -> {
value.map {
when (it) {
is Map<*, *> -> {
val mapItem = it as Map<String, Any>
// Detect elements in lists too
if (mapItem["t"] is Number) {
decompressElementStructure(mapItem)
} else {
decompressMap(mapItem)
}
}

else -> {
it
}
}
}
}

else -> {
value
}
}
result[expandedKey] = expandedValue
}

return result
}

/**
* Decompress a VoltraElement structure within props without expanding its structure field keys.
* Structure keys like "t", "i", "c" are preserved, but nested props and children are processed.
*/
@Suppress("UNCHECKED_CAST")
private fun decompressElementStructure(map: Map<String, Any>): Map<String, Any> {
Log.d(TAG, "decompressElementStructure: Processing element with type=${map["t"]}")
val result = map.toMutableMap()

// Decompress the "p" (props) field - expand prop keys
if (map["p"] is Map<*, *>) {
val originalProps = map["p"] as Map<String, Any>
Log.d(TAG, "decompressElementStructure: Original props keys: ${originalProps.keys}")
val decompressedProps = decompressMap(originalProps)
Log.d(TAG, "decompressElementStructure: Decompressed props keys: ${decompressedProps.keys}")
result["p"] = decompressedProps
}

// Decompress the "c" (children) field recursively
val childValue = map["c"]
if (childValue != null) {
result["c"] = decompressNodeValue(childValue)
}

return result
}

/**
* Decompress a value that represents a VoltraNode (element, array, text, or ref).
*/
@Suppress("UNCHECKED_CAST")
private fun decompressNodeValue(value: Any): Any =
when (value) {
is Map<*, *> -> {
val mapValue = value as Map<String, Any>
// Check if it's an element structure
if (mapValue["t"] is Number) {
decompressElementStructure(mapValue)
} else {
// Could be a ref ($r) or other map - don't expand keys for refs
mapValue
}
}

is List<*> -> {
// Array of nodes - recursively decompress each item
value.map { item ->
if (item != null) {
decompressNodeValue(item)
} else {
item
}
}
}

else -> {
// Text nodes (strings, numbers) or null - pass through
value
}
}
}
11 changes: 11 additions & 0 deletions data/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"color": "c",
"letterSpacing": "ls",
"fontVariant": "fvar",
"fallback": "flb",
"width": "w",
"opacity": "op",
"overflow": "ov",
Expand Down Expand Up @@ -318,6 +319,11 @@
"enum": ["cover", "contain", "stretch", "repeat", "center"],
"default": "cover",
"description": "How the image should be resized to fit its container"
},
"fallback": {
"type": "component",
"optional": true,
"description": "Custom fallback content rendered when the image is missing"
}
}
},
Expand All @@ -339,6 +345,11 @@
"enum": ["cover", "contain", "stretch", "repeat", "center"],
"default": "cover",
"description": "Resizing mode"
},
"fallback": {
"type": "component",
"optional": true,
"description": "Custom fallback content rendered when the image is missing"
}
}
},
Expand Down
10 changes: 10 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen"
},
{
"id": "image_fallback",
"displayName": "Image Fallback Widget",
"description": "Test image fallback with backgroundColor from styles",
"targetCellWidth": 2,
"targetCellHeight": 2,
"resizeMode": "horizontal|vertical",
"widgetCategory": "home_screen",
"initialStatePath": "./widgets/android-image-fallback-initial.tsx"
}
]
},
Expand Down
5 changes: 5 additions & 0 deletions example/app/android-widgets/image-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import AndroidImageFallbackScreen from '~/screens/android/AndroidImageFallbackScreen'

export default function AndroidImageFallbackIndex() {
return <AndroidImageFallbackScreen />
}
5 changes: 5 additions & 0 deletions example/app/testing-grounds/image-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ImageFallbackScreen from '~/screens/testing-grounds/ImageFallbackScreen'

export default function ImageFallbackIndex() {
return <ImageFallbackScreen />
}
Loading
Loading