diff --git a/packages/voltra/android/build.gradle b/packages/voltra/android/build.gradle index 15c3bf49..3fd6edb0 100644 --- a/packages/voltra/android/build.gradle +++ b/packages/voltra/android/build.gradle @@ -102,9 +102,6 @@ dependencies { // Google Tink (encryption for credential storage) implementation "com.google.crypto.tink:tink-android:1.19.0" - // JSON parsing - implementation "com.google.code.gson:gson:2.10.1" - // Coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" @@ -114,4 +111,5 @@ dependencies { // Unit tests testImplementation "junit:junit:4.13.2" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1" + testImplementation "org.robolectric:robolectric:4.14.1" } diff --git a/packages/voltra/android/src/main/java/voltra/glance/GlanceFactory.kt b/packages/voltra/android/src/main/java/voltra/glance/GlanceFactory.kt index 4358c8d2..8215aa84 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/GlanceFactory.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/GlanceFactory.kt @@ -9,7 +9,7 @@ import voltra.models.VoltraNode class GlanceFactory( private val widgetId: String, private val sharedElements: List? = null, - private val sharedStyles: List>? = null, + private val sharedStyles: List>? = null, private val widgetSize: DpSize? = null, ) { @Composable diff --git a/packages/voltra/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt b/packages/voltra/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt index ea350fdd..bfd11580 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/RemoteViewsGenerator.kt @@ -62,7 +62,7 @@ object RemoteViewsGenerator { context: Context, node: VoltraNode, sharedElements: List?, - sharedStyles: List>?, + sharedStyles: List>?, size: DpSize, ): RemoteViews { // Create a new GlanceRemoteViews instance each time to avoid caching issues diff --git a/packages/voltra/android/src/main/java/voltra/glance/StyleUtils.kt b/packages/voltra/android/src/main/java/voltra/glance/StyleUtils.kt index 96064236..31ea325f 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/StyleUtils.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/StyleUtils.kt @@ -17,8 +17,8 @@ data class ResolvedStyle( @Composable fun resolveAndApplyStyle( - props: Map?, - sharedStyles: List>?, + props: Map?, + sharedStyles: List>?, ): ResolvedStyle { val resolvedStyle = resolveStyle(props, sharedStyles) val compositeStyle = @@ -42,9 +42,9 @@ fun resolveAndApplyStyle( * or {"s": {...}} for inline styles. */ private fun resolveStyle( - props: Map?, - sharedStyles: List>?, -): Map? { + props: Map?, + sharedStyles: List>?, +): Map? { if (props == null) return null val styleRef = props["style"] @@ -58,7 +58,7 @@ private fun resolveStyle( is Map<*, *> -> { // It's already an inline style @Suppress("UNCHECKED_CAST") - styleRef as? Map + styleRef as? Map } else -> { @@ -82,7 +82,7 @@ private fun resolveStyle( @Composable fun applyClickableIfNeeded( modifier: GlanceModifier, - props: Map?, + props: Map?, elementId: String?, widgetId: String, componentType: Int, diff --git a/packages/voltra/android/src/main/java/voltra/glance/VoltraRenderContext.kt b/packages/voltra/android/src/main/java/voltra/glance/VoltraRenderContext.kt index 79af5a54..83f3735d 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/VoltraRenderContext.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/VoltraRenderContext.kt @@ -7,7 +7,7 @@ import voltra.models.VoltraNode data class VoltraRenderContext( val widgetId: String, val sharedElements: List? = null, - val sharedStyles: List>? = null, + val sharedStyles: List>? = null, val widgetSize: DpSize? = null, ) diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt index f64a0a76..988054f3 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/ButtonRenderers.kt @@ -13,7 +13,6 @@ import androidx.glance.appwidget.components.OutlineButton import androidx.glance.appwidget.components.SquareIconButton import androidx.glance.layout.Box import androidx.glance.unit.ColorProvider -import com.google.gson.Gson import voltra.glance.LocalVoltraRenderContext import voltra.glance.applyClickableIfNeeded import voltra.glance.resolveAndApplyStyle @@ -21,9 +20,6 @@ import voltra.models.VoltraElement import voltra.styling.JSColorParser import voltra.styling.toColorProvider -private const val TAG = "ButtonRenderers" -private val gson = Gson() - @Composable fun RenderButton( element: VoltraElement, diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt index 651f9de0..4171d465 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartBitmapRenderer.kt @@ -6,13 +6,10 @@ import android.graphics.DashPathEffect import android.graphics.Paint import android.graphics.Path import android.graphics.RectF -import android.util.Log import androidx.compose.ui.graphics.toArgb import voltra.styling.JSColorParser import voltra.styling.VoltraColorValue -private const val TAG = "ChartBitmapRenderer" - private val DEFAULT_PALETTE = intArrayOf( 0xFF4E79A7.toInt(), // blue @@ -46,24 +43,19 @@ data class SectorPoint( ) fun parseMarksJson(marksJson: String): List { - return try { - val gson = com.google.gson.Gson() - val type = object : com.google.gson.reflect.TypeToken>>() {}.type - val outer: List> = gson.fromJson(marksJson, type) - outer.mapNotNull { row -> - if (row.size < 3) return@mapNotNull null - val markType = row[0] as? String ?: return@mapNotNull null - - @Suppress("UNCHECKED_CAST") - val data = row[1] as? List> - - @Suppress("UNCHECKED_CAST") - val props = (row[2] as? Map) ?: emptyMap() - WireMark(markType, data, props) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to parse marks JSON", e) - emptyList() + return parseMarksTuples(marksJson).mapNotNull { row -> + if (row.size < 3) return@mapNotNull null + val markType = row[0] as? String ?: return@mapNotNull null + + @Suppress("UNCHECKED_CAST") + val data = + (row[1] as? List<*>)?.mapNotNull { point -> + (point as? List<*>)?.toList() as? List + } + + @Suppress("UNCHECKED_CAST") + val props = row[2] as? Map ?: emptyMap() + WireMark(markType, data, props) } } diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt index 9b41475d..6eb77eca 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/ChartRenderers.kt @@ -152,16 +152,7 @@ fun RenderChart( @Composable private fun parseForegroundStyleScale(json: String?): Map? { - if (json.isNullOrEmpty()) return null - val pairs: List> = - try { - val gson = com.google.gson.Gson() - val type = object : com.google.gson.reflect.TypeToken>>() {}.type - gson.fromJson(json, type) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse foregroundStyleScale", e) - return null - } + val pairs = parseForegroundStyleScaleEntries(json) ?: return null val map = mutableMapOf() for (pair in pairs) { diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt index d2b070ec..ac0568aa 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/ComplexRenderers.kt @@ -9,7 +9,6 @@ import androidx.glance.LocalContext import androidx.glance.appwidget.components.Scaffold import androidx.glance.appwidget.components.TitleBar import androidx.glance.text.FontFamily -import com.google.gson.Gson import voltra.glance.LocalVoltraRenderContext import voltra.glance.applyClickableIfNeeded import voltra.glance.resolveAndApplyStyle @@ -19,9 +18,6 @@ import voltra.payload.ComponentTypeID import voltra.styling.JSColorParser import voltra.styling.toColorProvider -private const val TAG = "ComplexRenderers" -private val gson = Gson() - @Composable fun RenderTitleBar( element: VoltraElement, diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt index 6da9e206..b85d7f2d 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/LazyListRenderers.kt @@ -132,7 +132,7 @@ fun RenderLazyVerticalGrid( } } -private fun extractHorizontalAlignment(props: Map?): Alignment.Horizontal = +private fun extractHorizontalAlignment(props: Map?): Alignment.Horizontal = when (props?.get("horizontalAlignment") as? String) { "start" -> Alignment.Horizontal.Start "center-horizontally" -> Alignment.Horizontal.CenterHorizontally @@ -140,7 +140,7 @@ private fun extractHorizontalAlignment(props: Map?): Alignment.Hori else -> Alignment.Horizontal.Start } -private fun extractVerticalAlignment(props: Map?): Alignment.Vertical = +private fun extractVerticalAlignment(props: Map?): Alignment.Vertical = when (props?.get("verticalAlignment") as? String) { "top" -> Alignment.Vertical.Top "center" -> Alignment.Vertical.CenterVertically diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt index b33fe765..a6808907 100644 --- a/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/RenderCommon.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.graphics.BitmapFactory import android.graphics.drawable.Icon import android.net.Uri -import android.util.Log import androidx.compose.runtime.Composable import androidx.glance.GlanceModifier import androidx.glance.ImageProvider @@ -13,8 +12,6 @@ import androidx.glance.LocalContext import androidx.glance.action.Action import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.layout.ContentScale -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import voltra.glance.LocalVoltraRenderContext import voltra.images.VoltraImageManager import voltra.models.VoltraElement @@ -22,12 +19,9 @@ import voltra.models.VoltraNode import voltra.payload.ComponentTypeID import voltra.styling.CompositeStyle -private val gson = Gson() -private const val TAG = "RenderCommon" - fun getOnClickAction( context: Context, - props: Map?, + props: Map?, widgetId: String, componentId: String, ): Action { @@ -66,30 +60,10 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { if (sourceProp == null) return null val context = LocalContext.current - val sourceMap = - when (sourceProp) { - is String -> { - try { - val type = object : TypeToken>() {}.type - gson.fromJson>(sourceProp, type) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse image source JSON: $sourceProp", e) - null - } - } - - is Map<*, *> -> { - @Suppress("UNCHECKED_CAST") - sourceProp as? Map - } - - else -> { - null - } - } ?: return null + val source = parseEncodedImageSource(sourceProp) ?: return null - val assetName = sourceMap["assetName"] as? String - val base64 = sourceMap["base64"] as? String + val assetName = source.assetName + val base64 = source.base64 if (assetName != null) { // Try as drawable resource first @@ -107,7 +81,7 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { return ImageProvider(Icon.createWithBitmap(bitmap)) } } catch (e: Exception) { - Log.e(TAG, "Failed to decode preloaded image: $assetName", e) + android.util.Log.e("RenderCommon", "Failed to decode preloaded image: $assetName", e) } } } @@ -120,7 +94,7 @@ fun extractImageProvider(sourceProp: Any?): ImageProvider? { return ImageProvider(Icon.createWithBitmap(bitmap)) } } catch (e: Exception) { - Log.e(TAG, "Failed to decode base64 image", e) + android.util.Log.e("RenderCommon", "Failed to decode base64 image", e) } } diff --git a/packages/voltra/android/src/main/java/voltra/glance/renderers/RendererJson.kt b/packages/voltra/android/src/main/java/voltra/glance/renderers/RendererJson.kt new file mode 100644 index 00000000..bbc5fdf3 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/glance/renderers/RendererJson.kt @@ -0,0 +1,128 @@ +package voltra.glance.renderers + +import android.util.Log +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import voltra.parsing.toDynamicObject +import voltra.parsing.toDynamicValue + +private const val TAG = "RendererJson" + +@OptIn(ExperimentalSerializationApi::class) +internal val rendererJson: Json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + } + +@Serializable +internal data class EncodedImageSource( + val assetName: String? = null, + val base64: String? = null, +) + +internal fun parseImageSourceMap(sourceProp: Any?): Map? = + when (sourceProp) { + null -> { + null + } + + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + sourceProp as? Map + } + + is String -> { + parseRendererJsonObject(sourceProp, "image source JSON") + } + + else -> { + null + } + } + +internal fun parseEncodedImageSource(sourceProp: Any?): EncodedImageSource? { + return when (sourceProp) { + null -> { + null + } + + is Map<*, *> -> { + val map = parseImageSourceMap(sourceProp) ?: return null + EncodedImageSource( + assetName = map["assetName"] as? String, + base64 = map["base64"] as? String, + ) + } + + is String -> { + try { + rendererJson.decodeFromString(sourceProp) + } catch (error: Exception) { + warn("Failed to parse image source JSON: $sourceProp", error) + null + } + } + + else -> { + null + } + } +} + +internal fun parseForegroundStyleScaleEntries(json: String?): List>? { + if (json.isNullOrEmpty()) return null + return try { + rendererJson.decodeFromString(ListSerializer(ListSerializer(String.serializer())), json) + } catch (error: Exception) { + warn("Failed to parse foregroundStyleScale", error) + null + } +} + +internal fun parseMarksTuples(marksJson: String): List> { + return try { + val element = rendererJson.parseToJsonElement(marksJson) + val array = element as? JsonArray ?: return emptyList() + array.mapNotNull { tupleElement -> + val tupleArray = tupleElement as? JsonArray ?: return@mapNotNull null + tupleArray.map { it.toDynamicValue() } + } + } catch (error: Exception) { + warn("Failed to parse marks JSON", error) + emptyList() + } +} + +internal fun parseRendererJsonObject( + json: String, + label: String, +): Map? = + try { + val element = rendererJson.parseToJsonElement(json) + val jsonObject = + element as? JsonObject + ?: throw SerializationException("Expected JSON object for $label") + jsonObject.toDynamicObject() + } catch (error: Exception) { + warn("Failed to parse $label", error) + null + } + +private fun warn( + message: String, + error: Throwable, +) { + try { + Log.w(TAG, message, error) + } catch (_: RuntimeException) { + // Local unit tests may not provide android.util.Log. + } +} diff --git a/packages/voltra/android/src/main/java/voltra/models/VoltraPayload.kt b/packages/voltra/android/src/main/java/voltra/models/VoltraPayload.kt index f294db0f..4af05f8a 100644 --- a/packages/voltra/android/src/main/java/voltra/models/VoltraPayload.kt +++ b/packages/voltra/android/src/main/java/voltra/models/VoltraPayload.kt @@ -1,14 +1,23 @@ package voltra.models +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import voltra.parsing.DynamicObjectListSerializer +import voltra.parsing.DynamicObjectSerializer +import voltra.parsing.VoltraNodeSerializer + /** * Root payload for both Live Updates and Widgets */ +@Serializable data class VoltraPayload( + @SerialName("v") val v: Int, // Version val collapsed: VoltraNode? = null, // Collapsed content (Live Updates) val expanded: VoltraNode? = null, // Expanded content (Live Updates) val variants: Map? = null, // Size variants (Widgets) - val s: List>? = null, // Shared styles + @Serializable(with = DynamicObjectListSerializer::class) + val s: List>? = null, // Shared styles val e: List? = null, // Shared elements val smallIcon: String? = null, val channelId: String? = null, @@ -17,23 +26,32 @@ data class VoltraPayload( /** * Element matching VoltraElementJson { t, i?, c?, p? } */ +@Serializable data class VoltraElement( + @SerialName("t") val t: Int, // Component type ID + @SerialName("i") val i: String? = null, // Optional ID + @SerialName("c") val c: VoltraNode? = null, // Children - val p: Map? = null, // Props including style + @SerialName("p") + @Serializable(with = DynamicObjectSerializer::class) + val p: Map? = null, // Props including style ) /** * Reference to shared element { $r: index } */ +@Serializable data class VoltraElementRef( + @SerialName("\$r") val `$r`: Int, ) /** * Union type for nodes: Element | Array | Ref | String */ +@Serializable(with = VoltraNodeSerializer::class) sealed class VoltraNode { data class Element( val element: VoltraElement, @@ -59,7 +77,7 @@ sealed class VoltraNode { @Suppress("UNCHECKED_CAST") fun resolveToVoltraNode( value: Any?, - sharedStyles: List>?, + sharedStyles: List>?, sharedElements: List?, ): VoltraNode? { return when (value) { @@ -89,7 +107,7 @@ fun resolveToVoltraNode( } is Map<*, *> -> { - val map = value as Map + val map = value as Map val ref = map["\$r"] as? Number if (ref != null) { return sharedElements?.getOrNull(ref.toInt()) @@ -97,7 +115,7 @@ fun resolveToVoltraNode( 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 + val props = map["p"] as? Map VoltraNode.Element(VoltraElement(t = typeId.toInt(), i = id, c = child, p = props)) } @@ -113,7 +131,7 @@ fun resolveToVoltraNode( */ fun VoltraElement.componentProp( propName: String, - sharedStyles: List>?, + sharedStyles: List>?, sharedElements: List?, ): VoltraNode? { val value = p?.get(propName) ?: return null diff --git a/packages/voltra/android/src/main/java/voltra/parsing/VoltraDecompressor.kt b/packages/voltra/android/src/main/java/voltra/parsing/VoltraDecompressor.kt index 795372a1..761ac53b 100644 --- a/packages/voltra/android/src/main/java/voltra/parsing/VoltraDecompressor.kt +++ b/packages/voltra/android/src/main/java/voltra/parsing/VoltraDecompressor.kt @@ -1,9 +1,10 @@ package voltra.parsing import android.util.Log -import com.google.gson.Gson import voltra.generated.ShortNames -import voltra.models.* +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.models.VoltraPayload /** * Utility to expand shortened keys in the Voltra payload back to their full names. @@ -11,7 +12,6 @@ import voltra.models.* */ object VoltraDecompressor { private const val TAG = "VoltraDecompressor" - private val gson = Gson() /** * Decompress the entire payload recursively. @@ -42,15 +42,15 @@ object VoltraDecompressor { * Recursively decompress a map of props or styles. */ @Suppress("UNCHECKED_CAST") - private fun decompressMap(map: Map): Map { - val result = mutableMapOf() + private fun decompressMap(map: Map): Map { + val result = mutableMapOf() for ((key, value) in map) { val expandedKey = ShortNames.expand(key) val expandedValue = when (value) { is Map<*, *> -> { - val mapValue = value as Map + val mapValue = value as Map // Detect if this is a VoltraElement structure if (mapValue["t"] is Number) { Log.d( @@ -67,7 +67,7 @@ object VoltraDecompressor { value.map { when (it) { is Map<*, *> -> { - val mapItem = it as Map + val mapItem = it as Map // Detect elements in lists too if (mapItem["t"] is Number) { decompressElementStructure(mapItem) @@ -98,13 +98,13 @@ object VoltraDecompressor { * Structure keys like "t", "i", "c" are preserved, but nested props and children are processed. */ @Suppress("UNCHECKED_CAST") - private fun decompressElementStructure(map: Map): Map { + private fun decompressElementStructure(map: Map): Map { 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 + val originalProps = map["p"] as Map Log.d(TAG, "decompressElementStructure: Original props keys: ${originalProps.keys}") val decompressedProps = decompressMap(originalProps) Log.d(TAG, "decompressElementStructure: Decompressed props keys: ${decompressedProps.keys}") @@ -127,7 +127,7 @@ object VoltraDecompressor { private fun decompressNodeValue(value: Any): Any = when (value) { is Map<*, *> -> { - val mapValue = value as Map + val mapValue = value as Map // Check if it's an element structure if (mapValue["t"] is Number) { decompressElementStructure(mapValue) diff --git a/packages/voltra/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt b/packages/voltra/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt deleted file mode 100644 index 5804071c..00000000 --- a/packages/voltra/android/src/main/java/voltra/parsing/VoltraNodeDeserializer.kt +++ /dev/null @@ -1,43 +0,0 @@ -package voltra.parsing - -import com.google.gson.* -import voltra.models.* -import java.lang.reflect.Type - -class VoltraNodeDeserializer : JsonDeserializer { - override fun deserialize( - json: JsonElement, - typeOfT: Type, - context: JsonDeserializationContext, - ): VoltraNode = - when { - // String → Text node - json.isJsonPrimitive && json.asJsonPrimitive.isString -> { - VoltraNode.Text(json.asString) - } - - // Array → Array of nodes - json.isJsonArray -> { - val elements = - json.asJsonArray.map { - context.deserialize(it, VoltraNode::class.java) - } - VoltraNode.Array(elements) - } - - // Object with $r → Reference - json.isJsonObject && json.asJsonObject.has("\$r") -> { - VoltraNode.Ref(json.asJsonObject.get("\$r").asInt) - } - - // Object with t → Element - json.isJsonObject && json.asJsonObject.has("t") -> { - val element = context.deserialize(json, VoltraElement::class.java) - VoltraNode.Element(element) - } - - else -> { - throw JsonParseException("Unknown VoltraNode format: $json") - } - } -} diff --git a/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt b/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt index 8f061c94..68db7aca 100644 --- a/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt +++ b/packages/voltra/android/src/main/java/voltra/parsing/VoltraPayloadParser.kt @@ -1,23 +1,27 @@ package voltra.parsing import android.util.Log -import com.google.gson.GsonBuilder -import voltra.models.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import voltra.models.VoltraPayload +@OptIn(ExperimentalSerializationApi::class) object VoltraPayloadParser { private const val TAG = "VoltraPayloadParser" - private val gson = - GsonBuilder() - .registerTypeAdapter(VoltraNode::class.java, VoltraNodeDeserializer()) - .create() + private val json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + } fun parse(jsonString: String): VoltraPayload { Log.d(TAG, "Parsing payload, length=${jsonString.length}") // Log first 500 chars to see the structure Log.d(TAG, "Payload preview: ${jsonString.take(500)}") - val rawResult = gson.fromJson(jsonString, VoltraPayload::class.java) + val rawResult = json.decodeFromString(jsonString) Log.d(TAG, "Decompressing payload...") val result = VoltraDecompressor.decompress(rawResult) diff --git a/packages/voltra/android/src/main/java/voltra/parsing/VoltraSerializers.kt b/packages/voltra/android/src/main/java/voltra/parsing/VoltraSerializers.kt new file mode 100644 index 00000000..05d1be95 --- /dev/null +++ b/packages/voltra/android/src/main/java/voltra/parsing/VoltraSerializers.kt @@ -0,0 +1,231 @@ +package voltra.parsing + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import voltra.models.VoltraElement +import voltra.models.VoltraNode + +object VoltraNodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("voltra.models.VoltraNode") { + element("shape") + } + + override fun deserialize(decoder: Decoder): VoltraNode { + val jsonDecoder = decoder as? JsonDecoder ?: error("VoltraNodeSerializer only supports JSON") + return jsonDecoder.decodeJsonElement().toVoltraNode(jsonDecoder.json) + } + + override fun serialize( + encoder: Encoder, + value: VoltraNode, + ) { + val jsonEncoder = encoder as? JsonEncoder ?: error("VoltraNodeSerializer only supports JSON") + val jsonElement = + when (value) { + is VoltraNode.Text -> JsonPrimitive(value.text) + is VoltraNode.Array -> JsonArray(value.elements.map { toJsonElement(jsonEncoder.json, it) }) + is VoltraNode.Ref -> JsonObject(mapOf("\$r" to JsonPrimitive(value.ref))) + is VoltraNode.Element -> jsonEncoder.json.encodeToJsonElement(VoltraElement.serializer(), value.element) + } + jsonEncoder.encodeJsonElement(jsonElement) + } +} + +object DynamicObjectSerializer : KSerializer> { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("voltra.parsing.DynamicObject") + + override fun deserialize(decoder: Decoder): Map { + val jsonDecoder = decoder as? JsonDecoder ?: error("DynamicObjectSerializer only supports JSON") + val element = jsonDecoder.decodeJsonElement() + return element.toDynamicObject() + } + + override fun serialize( + encoder: Encoder, + value: Map, + ) { + val jsonEncoder = encoder as? JsonEncoder ?: error("DynamicObjectSerializer only supports JSON") + jsonEncoder.encodeJsonElement(toJsonElement(jsonEncoder.json, value)) + } +} + +object DynamicObjectListSerializer : KSerializer>> { + private val delegate = ListSerializer(DynamicObjectSerializer) + + override val descriptor: SerialDescriptor = delegate.descriptor + + override fun deserialize(decoder: Decoder): List> = delegate.deserialize(decoder) + + override fun serialize( + encoder: Encoder, + value: List>, + ) { + delegate.serialize(encoder, value) + } +} + +internal fun JsonElement.toVoltraNode(json: Json): VoltraNode = + when (this) { + is JsonPrimitive -> { + if (isString) { + VoltraNode.Text(content) + } else { + throw SerializationException("Unsupported VoltraNode primitive: $this") + } + } + + is JsonArray -> { + VoltraNode.Array(map { it.toVoltraNode(json) }) + } + + is JsonObject -> { + when { + "\$r" in this -> { + val ref = + this.getValue("\$r").jsonPrimitive.intOrNull + ?: throw SerializationException("VoltraNode ref must be an integer: $this") + VoltraNode.Ref(ref) + } + + "t" in this -> { + VoltraNode.Element(json.decodeFromJsonElement(VoltraElement.serializer(), this)) + } + + else -> { + throw SerializationException("Unsupported VoltraNode shape: $this") + } + } + } + } + +internal fun JsonElement.toDynamicValue(): Any? = + when (this) { + JsonNull -> { + null + } + + is JsonObject -> { + toDynamicObject() + } + + is JsonArray -> { + map { it.toDynamicValue() } + } + + is JsonPrimitive -> { + when { + isString -> content + booleanOrNull != null -> booleanOrNull + intOrNull != null -> intOrNull + longOrNull != null -> longOrNull + doubleOrNull != null -> doubleOrNull + else -> throw SerializationException("Unsupported JSON primitive: $this") + } + } + } + +internal fun JsonElement.toDynamicObject(): Map { + val jsonObject = + this as? JsonObject + ?: throw SerializationException("Expected JSON object but found: $this") + return linkedMapOf().apply { + jsonObject.forEach { (key, value) -> + put(key, value.toDynamicValue()) + } + } +} + +private fun toJsonElement( + json: Json, + value: Any?, +): JsonElement = + when (value) { + null -> { + JsonNull + } + + is VoltraNode -> { + toJsonElement(json, value) + } + + is Map<*, *> -> { + JsonObject( + value.entries.associate { (key, itemValue) -> + val stringKey = + key as? String + ?: throw SerializationException("Dynamic object keys must be strings: $key") + stringKey to toJsonElement(json, itemValue) + }, + ) + } + + is List<*> -> { + JsonArray(value.map { toJsonElement(json, it) }) + } + + is String -> { + JsonPrimitive(value) + } + + is Boolean -> { + JsonPrimitive(value) + } + + is Int -> { + JsonPrimitive(value) + } + + is Long -> { + JsonPrimitive(value) + } + + is Float -> { + JsonPrimitive(value) + } + + is Double -> { + JsonPrimitive(value) + } + + is Number -> { + JsonPrimitive(value.toDouble()) + } + + else -> { + throw SerializationException("Unsupported dynamic value type: ${value::class.qualifiedName}") + } + } + +private fun toJsonElement( + json: Json, + node: VoltraNode, +): JsonElement = + when (node) { + is VoltraNode.Text -> JsonPrimitive(node.text) + is VoltraNode.Array -> JsonArray(node.elements.map { toJsonElement(json, it) }) + is VoltraNode.Ref -> JsonObject(mapOf("\$r" to JsonPrimitive(node.ref))) + is VoltraNode.Element -> json.encodeToJsonElement(VoltraElement.serializer(), node.element) + } diff --git a/packages/voltra/android/src/test/java/voltra/glance/renderers/RendererJsonTest.kt b/packages/voltra/android/src/test/java/voltra/glance/renderers/RendererJsonTest.kt new file mode 100644 index 00000000..f233e23e --- /dev/null +++ b/packages/voltra/android/src/test/java/voltra/glance/renderers/RendererJsonTest.kt @@ -0,0 +1,106 @@ +package voltra.glance.renderers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RendererJsonTest { + @Test + fun parsesImageSourceMapFromJsonString() { + val result = parseImageSourceMap("""{"assetName":"hero","base64":"abc"}""") + + assertNotNull(result) + assertEquals("hero", result?.get("assetName")) + assertEquals("abc", result?.get("base64")) + } + + @Test + fun returnsNullForInvalidImageSourceJsonString() { + val result = parseImageSourceMap("{not-valid") + + assertNull(result) + } + + @Test + fun passesThroughImageSourceMapInput() { + val source = linkedMapOf("assetName" to "hero", "base64" to null) + + val result = parseImageSourceMap(source) + + assertTrue(result === source) + } + + @Test + fun handlesNullImageSourceInput() { + assertNull(parseImageSourceMap(null)) + assertNull(parseEncodedImageSource(null)) + } + + @Test + fun parsesEncodedImageSourceFromMapAndString() { + val fromMap = parseEncodedImageSource(mapOf("assetName" to "logo")) + val fromString = parseEncodedImageSource("""{"base64":"Zm9v","ignored":true}""") + + assertEquals("logo", fromMap?.assetName) + assertNull(fromMap?.base64) + assertEquals("Zm9v", fromString?.base64) + assertNull(fromString?.assetName) + } + + @Test + fun parsesForegroundStyleScaleEntries() { + val result = parseForegroundStyleScaleEntries("""[["sales","#ff0000"],["profit","#00ff00"]]""") + + assertEquals(listOf(listOf("sales", "#ff0000"), listOf("profit", "#00ff00")), result) + } + + @Test + fun returnsNullForInvalidForegroundStyleScaleEntries() { + assertNull(parseForegroundStyleScaleEntries("{")) + } + + @Test + fun parsesMarksJsonPreservingNumbersAndNullRuleData() { + val marks = + parseMarksJson( + """ + [ + ["bar", [["Jan", 10, "sales"], ["Feb", 12.5, "sales"]], {"w": 18, "cr": 4.5, "c": "#ff0000", "stk": "grouped"}], + ["rule", null, {"yv": 8, "lw": 1.5}], + ["sector", [[25, "alpha"], [75, "beta"]], {"ir": 0.4, "or": 1, "agin": 2}] + ] + """.trimIndent(), + ) + + assertEquals(3, marks.size) + + val barMark = marks[0] + assertEquals("bar", barMark.type) + assertNotNull(barMark.data) + assertTrue(barMark.data!![0][1] is Number) + assertEquals(10, (barMark.data!![0][1] as Number).toInt()) + assertTrue(barMark.data!![1][1] is Number) + assertEquals(12.5, (barMark.data!![1][1] as Number).toDouble(), 0.0) + assertTrue(barMark.props["w"] is Number) + assertEquals(18, (barMark.props["w"] as Number).toInt()) + assertTrue(barMark.props["cr"] is Number) + assertEquals(4.5, (barMark.props["cr"] as Number).toDouble(), 0.0) + + val ruleMark = marks[1] + assertEquals("rule", ruleMark.type) + assertNull(ruleMark.data) + assertEquals(8.0, (ruleMark.props["yv"] as Number).toDouble(), 0.0) + + val sectorMark = marks[2] + assertEquals("sector", sectorMark.type) + assertEquals(2, sectorMark.data?.size) + assertEquals("alpha", sectorMark.data?.get(0)?.get(1)) + } + + @Test + fun returnsEmptyListForMalformedMarksJson() { + assertTrue(parseMarksJson("[").isEmpty()) + } +} diff --git a/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt b/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt new file mode 100644 index 00000000..d7826df5 --- /dev/null +++ b/packages/voltra/android/src/test/java/voltra/parsing/VoltraPayloadParserTest.kt @@ -0,0 +1,235 @@ +package voltra.parsing + +import kotlinx.serialization.SerializationException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import voltra.models.VoltraElement +import voltra.models.VoltraNode +import voltra.models.resolveToVoltraNode + +@RunWith(RobolectricTestRunner::class) +class VoltraPayloadParserTest { + @Test + fun parsesPayloadNodeShapesAndMetadata() { + val payload = + VoltraPayloadParser.parse( + """ + { + "v": 1, + "collapsed": "Hello", + "expanded": [{"t": 18, "p": {"txt": "Expanded"}}, {"${'$'}r": 0}], + "variants": { + "100x100": {"t": 18, "i": "variant-text", "c": "Variant child", "p": {"txt": "Variant"}} + }, + "s": [{"bg": "#fff", "nullable": null}], + "e": [{"t": 18, "p": {"txt": "Shared"}}], + "unknown": true + } + """.trimIndent(), + ) + + assertEquals(1, payload.v) + assertEquals(VoltraNode.Text("Hello"), payload.collapsed) + + val expandedNode = payload.expanded + assertTrue(expandedNode is VoltraNode.Array) + val expanded = expandedNode as VoltraNode.Array + val expandedFirst = expanded.elements[0] + assertTrue(expandedFirst is VoltraNode.Element) + val expandedElement = expandedFirst as VoltraNode.Element + assertEquals(18, expandedElement.element.t) + assertEquals("Expanded", expandedElement.element.p?.get("text")) + assertEquals(VoltraNode.Ref(0), expanded.elements[1]) + + val variantNode = payload.variants?.get("100x100") + assertTrue(variantNode is VoltraNode.Element) + val variant = variantNode as VoltraNode.Element + assertEquals("variant-text", variant.element.i) + assertEquals(VoltraNode.Text("Variant child"), variant.element.c) + + assertEquals(1, payload.s?.size) + assertTrue(payload.s?.first()?.containsKey("nullable") == true) + assertNull(payload.s?.first()?.get("nullable")) + + val sharedElementNode = payload.e?.firstOrNull() + assertTrue(sharedElementNode is VoltraNode.Element) + val sharedElement = sharedElementNode as VoltraNode.Element + assertEquals("Shared", sharedElement.element.p?.get("text")) + } + + @Test + fun preservesDynamicPropTypesAndNodeLikeStructures() { + val payload = + VoltraPayloadParser.parse( + """ + { + "v": 1, + "collapsed": { + "t": 18, + "p": { + "txt": "hello", + "enabled": true, + "count": 7, + "ratio": 1.5, + "nullable": null, + "nested": {"flag": false, "value": 2}, + "items": [1, null, {"deep": "value"}], + "fallback": {"t": 18, "p": {"txt": "child"}}, + "listChildren": ["a", {"t": 18, "p": {"txt": "b"}}] + } + } + } + """.trimIndent(), + ) + + val collapsedNode = payload.collapsed + assertTrue(collapsedNode is VoltraNode.Element) + val element = (collapsedNode as VoltraNode.Element).element + assertTrue(element.p != null) + val props = element.p as Map + + assertEquals("hello", props["text"]) + assertEquals(true, props["enabled"]) + assertTrue(props["count"] is Number) + assertEquals(7, (props["count"] as Number).toInt()) + assertTrue(props["ratio"] is Number) + assertEquals(1.5, (props["ratio"] as Number).toDouble(), 0.0) + assertTrue(props.containsKey("nullable")) + assertNull(props["nullable"]) + + val nestedValue = props["nested"] + assertTrue(nestedValue is Map<*, *>) + val nested = nestedValue as Map<*, *> + assertEquals(false, nested["flag"]) + assertEquals(2, (nested["value"] as Number).toInt()) + + val itemsValue = props["items"] + assertTrue(itemsValue is List<*>) + val items = itemsValue as List<*> + assertEquals(3, items.size) + assertNull(items[1]) + assertEquals("value", (items[2] as Map<*, *>)["deep"]) + + val fallbackNode = resolveToVoltraNode(props["fallback"], payload.s, payload.e) + assertEquals( + VoltraNode.Element(VoltraElement(t = 18, p = mapOf("text" to "child"))), + fallbackNode, + ) + + val listChildrenNode = resolveToVoltraNode(props["listChildren"], payload.s, payload.e) + assertTrue(listChildrenNode is VoltraNode.Array) + val listChildren = listChildrenNode as VoltraNode.Array + assertEquals(2, listChildren.elements.size) + assertEquals(VoltraNode.Text("a"), listChildren.elements[0]) + assertTrue(listChildren.elements[1] is VoltraNode.Element) + } + + @Test + fun decompressesShortenedKeysInNestedPropsWithoutChangingElementShape() { + val payload = + VoltraPayloadParser.parse( + """ + { + "v": 1, + "collapsed": { + "t": 18, + "p": { + "txt": "Root", + "s": {"bg": "#fff", "nullable": null}, + "fallback": { + "t": 18, + "p": { + "txt": "Nested", + "s": {"bg": "#000"} + }, + "c": "Child" + } + } + }, + "s": [{"bg": "#123456", "op": 0.4}] + } + """.trimIndent(), + ) + + val collapsedNode = payload.collapsed + assertTrue(collapsedNode is VoltraNode.Element) + val collapsed = (collapsedNode as VoltraNode.Element).element + assertEquals("Root", collapsed.p?.get("text")) + + assertTrue(collapsed.p != null) + val collapsedProps = collapsed.p as Map + val styleValue = collapsedProps["style"] + assertTrue(styleValue is Map<*, *>) + val style = styleValue as Map<*, *> + assertEquals("#fff", style["backgroundColor"]) + assertTrue(style.containsKey("nullable")) + assertNull(style["nullable"]) + + val fallbackValue = collapsedProps["fallback"] + assertTrue(fallbackValue is Map<*, *>) + val fallback = fallbackValue as Map<*, *> + assertEquals(18, (fallback["t"] as Number).toInt()) + assertEquals("Child", fallback["c"]) + val fallbackPropsValue = fallback["p"] + assertTrue(fallbackPropsValue is Map<*, *>) + val fallbackProps = fallbackPropsValue as Map<*, *> + assertEquals("Nested", fallbackProps["text"]) + assertEquals("#000", (fallbackProps["style"] as Map<*, *>)["backgroundColor"]) + + assertTrue(payload.s?.isNotEmpty() == true) + val sharedStyle = payload.s!!.first() + assertEquals("#123456", sharedStyle["backgroundColor"]) + assertEquals(0.4, (sharedStyle["opacity"] as Number).toDouble(), 0.0) + } + + @Test + fun ignoresUnknownKeysAndFailsClearlyForInvalidShapes() { + val invalidNodeError = + try { + VoltraPayloadParser.parse( + """ + { + "v": 1, + "collapsed": {"unexpected": true}, + "ignored": "value" + } + """.trimIndent(), + ) + null + } catch (error: SerializationException) { + error + } + + assertTrue(invalidNodeError?.message?.contains("Unsupported VoltraNode shape") == true) + + val missingFieldError = + try { + VoltraPayloadParser.parse("""{"collapsed":"missing version"}""") + null + } catch (error: SerializationException) { + error + } + + assertTrue(missingFieldError?.message?.contains("v") == true) + + val payload = + VoltraPayloadParser.parse( + """ + { + "v": 1, + "collapsed": "ok", + "extra": {"ignored": true} + } + """.trimIndent(), + ) + + assertEquals(VoltraNode.Text("ok"), payload.collapsed) + assertFalse(payload.variants?.containsKey("extra") == true) + } +}