From 34ab3216fd6d8c920080b6d48706346aaa413567 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 20 Apr 2026 14:59:42 -0500 Subject: [PATCH 1/5] feat: group adjacent radio buttons into RadioGroup layout Updated layout geometry processor to build a layout tree that detects contiguous radio buttons and generates corresponding `` XML tags. --- .../domain/AndroidXmlGenerator.kt | 217 ------------------ .../domain/LayoutGeometryProcessor.kt | 38 +++ .../domain/YoloToXmlConverter.kt | 1 + .../computervision/domain/model/LayoutItem.kt | 7 + .../domain/xml/AndroidWidget.kt | 119 ++++++++++ .../domain/xml/AndroidXmlGenerator.kt | 48 ++++ .../domain/xml/LayoutRenderer.kt | 99 ++++++++ .../computervision/domain/xml/XmlContext.kt | 29 +++ 8 files changed, 341 insertions(+), 217 deletions(-) create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/AndroidXmlGenerator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/AndroidXmlGenerator.kt index d0fe8ad4ab..e69de29bb2 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/AndroidXmlGenerator.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/AndroidXmlGenerator.kt @@ -1,217 +0,0 @@ -package org.appdevforall.codeonthego.computervision.domain - -import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox -import kotlin.text.substringAfterLast - -class AndroidXmlGenerator( - private val geometryProcessor: LayoutGeometryProcessor -) { - companion object { - private val WIDGET_TAGS = setOf("Switch", "CheckBox", "RadioButton") - } - - internal fun buildXml( - boxes: List, - annotations: Map, - targetDpHeight: Int, - wrapInScroll: Boolean - ): String { - val xml = StringBuilder() - val maxBottom = boxes.maxOfOrNull { it.y + it.h } ?: 0 - val needScroll = wrapInScroll && maxBottom > targetDpHeight - val namespaces = - """xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"""" - - xml.appendLine("") - if (needScroll) { - xml.appendLine("") - xml.appendLine(" ") - } else { - xml.appendLine("") - } - xml.appendLine() - - val rows = geometryProcessor.groupIntoRows(boxes) - val counters = mutableMapOf() - rows.forEach { row -> - if (row.size == 1) { - appendSimpleView(xml, row.first(), counters, " ", annotations) - } else { - appendHorizontalRow(xml, row, counters, annotations) - } - xml.appendLine() - } - - xml.appendLine(if (needScroll) " \n" else "") - return xml.toString() - } - - private fun appendHorizontalRow( - xml: StringBuilder, - row: List, - counters: MutableMap, - annotations: Map - ) { - xml.appendLine( - """ - | - """.trimMargin() - ) - - row.forEachIndexed { index, box -> - val extraAttrs = if (index < row.lastIndex) { - val nextBox = row[index + 1] - val gap = (nextBox.x - (box.x + box.w)) - val marginEnd = maxOf(0, gap) - - mapOf("android:layout_marginEnd" to "${marginEnd}dp") - } else { - emptyMap() - } - appendSimpleView(xml, box, counters, " ", annotations, extraAttrs) - xml.appendLine() - } - - xml.append(" ") - } - - private fun escapeXmlAttr(value: String): String = - value.trim() - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - - private fun viewTagFor(label: String): String = when (label) { - "text" -> "TextView" - "button" -> "Button" - "image_placeholder", "icon" -> "ImageView" - "checkbox_unchecked", "checkbox_checked" -> "CheckBox" - "radio_button_unchecked", "radio_button_checked" -> "RadioButton" - "switch_off", "switch_on" -> "Switch" - "text_entry_box" -> "EditText" - "dropdown" -> "Spinner" - "card" -> "androidx.cardview.widget.CardView" - "slider" -> "com.google.android.material.slider.Slider" - else -> "View" - } - - private fun parseMarginAnnotations(annotation: String?, tag: String): Map { - return FuzzyAttributeParser.parse(annotation, tag) - } - - private fun appendSimpleView( - xml: StringBuilder, - box: ScaledBox, - counters: MutableMap, - indent: String, - annotations: Map, - extraAttrs: Map = emptyMap() - ) { - val label = box.label - val tag = viewTagFor(label) - val count = counters.getOrPut(label) { 0 }.let { counters[label] = it + 1; it } - val defaultId = "${label.replace(Regex("[^a-zA-Z0-9_]"), "_")}_$count" - - val parsedAttrs = parseMarginAnnotations(annotations[box], tag) - val attrs = extraAttrs + parsedAttrs - - val width = attrs["android:layout_width"] ?: "wrap_content" - val height = attrs["android:layout_height"] ?: "wrap_content" - val id = attrs["android:id"]?.substringAfterLast('/') ?: defaultId - - val writtenAttrs = mutableSetOf( - "android:id", "android:layout_width", "android:layout_height" - ) - - xml.append("$indent<$tag\n") - xml.append("$indent android:id=\"@+id/${escapeXmlAttr(id)}\"\n") - xml.append("$indent android:layout_width=\"${escapeXmlAttr(width)}\"\n") - xml.append("$indent android:layout_height=\"${escapeXmlAttr(height)}\"\n") - - when (tag) { - "TextView", "Button", "CheckBox", "RadioButton", "Switch" -> - appendTextWidgetAttributes(xml, indent, parsedAttrs, box, label, tag, writtenAttrs) - - "EditText" -> - appendEditTextAttributes(xml, indent, parsedAttrs, box, writtenAttrs) - - "ImageView" -> - appendImageViewAttributes(xml, indent, parsedAttrs, label, writtenAttrs) - } - - attrs.forEach { (key, value) -> - if (key !in writtenAttrs) { - xml.append("$indent $key=\"${escapeXmlAttr(value)}\"\n") - writtenAttrs.add(key) - } - } - xml.append("$indent/>") - } - - private fun appendTextWidgetAttributes( - xml: StringBuilder, - indent: String, - parsedAttrs: Map, - box: ScaledBox, - label: String, - tag: String, - writtenAttrs: MutableSet - ) { - val rawViewText = parsedAttrs["android:text"] - ?: box.text.takeIf { it.isNotEmpty() && it != box.label } - ?: if (tag in WIDGET_TAGS) tag else box.label - - xml.append("$indent android:text=\"${escapeXmlAttr(rawViewText)}\"\n") - writtenAttrs.add("android:text") - if (tag == "TextView") { - val textSize = parsedAttrs["android:textSize"] ?: "16sp" - xml.append("$indent android:textSize=\"${escapeXmlAttr(textSize)}\"\n") - writtenAttrs.add("android:textSize") - } - if (label.contains("_checked") || label.contains("_on")) { - val checked = parsedAttrs["android:checked"] ?: "true" - xml.append("$indent android:checked=\"${escapeXmlAttr(checked)}\"\n") - writtenAttrs.add("android:checked") - } - xml.append("$indent tools:ignore=\"HardcodedText\"\n") - writtenAttrs.add("tools:ignore") - } - - private fun appendEditTextAttributes( - xml: StringBuilder, - indent: String, - parsedAttrs: Map, - box: ScaledBox, - writtenAttrs: MutableSet - ) { - val rawHint = parsedAttrs["android:hint"] ?: box.text.ifEmpty { "Enter text..." } - - xml.append("$indent android:hint=\"${escapeXmlAttr(rawHint)}\"\n") - writtenAttrs.add("android:hint") - - val inputType = parsedAttrs["android:inputType"] ?: "text" - xml.append("$indent android:inputType=\"${escapeXmlAttr(inputType)}\"\n") - writtenAttrs.add("android:inputType") - - xml.append("$indent tools:ignore=\"HardcodedText\"\n") - writtenAttrs.add("tools:ignore") - } - - private fun appendImageViewAttributes( - xml: StringBuilder, - indent: String, - parsedAttrs: Map, - label: String, - writtenAttrs: MutableSet - ) { - val contentDescription = parsedAttrs["android:contentDescription"] ?: label - xml.append("$indent android:contentDescription=\"${escapeXmlAttr(contentDescription)}\"\n") - writtenAttrs.add("android:contentDescription") - } -} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt index 2666570939..786454907f 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt @@ -2,6 +2,7 @@ package org.appdevforall.codeonthego.computervision.domain import android.graphics.Rect import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox import kotlin.math.max import kotlin.math.roundToInt @@ -14,6 +15,9 @@ class LayoutGeometryProcessor { private const val VERTICAL_ALIGN_THRESHOLD = 20 } + private fun isRadioButton(box: ScaledBox): Boolean = + box.label == "radio_button_unchecked" || box.label == "radio_button_checked" + private class LayoutRow(initialBox: ScaledBox) { private val _boxes = mutableListOf(initialBox) val boxes: List get() = _boxes @@ -116,4 +120,38 @@ class LayoutGeometryProcessor { Rect(x, y, x + w, y + h) ) } + + internal fun buildLayoutTree(boxes: List): List { + val rows = groupIntoRows(boxes) + val items = mutableListOf() + val verticalRadioRun = mutableListOf() + + fun flushVerticalRadioRun() { + if (verticalRadioRun.isNotEmpty()) { + items.add(LayoutItem.RadioGroup(verticalRadioRun.toList(), "vertical")) + verticalRadioRun.clear() + } + } + + rows.forEach { row -> + when { + row.all { isRadioButton(it) } && row.size == 1 -> verticalRadioRun.add(row.first()) + row.all { isRadioButton(it) } -> { + flushVerticalRadioRun() + items.add(LayoutItem.RadioGroup(row, "horizontal")) + } + else -> { + flushVerticalRadioRun() + if (row.size == 1) { + items.add(LayoutItem.SimpleView(row.first())) + } else { + items.add(LayoutItem.HorizontalRow(row)) + } + } + } + } + flushVerticalRadioRun() + + return items + } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt index 2dd946e585..9e3dbd2654 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/YoloToXmlConverter.kt @@ -1,6 +1,7 @@ package org.appdevforall.codeonthego.computervision.domain import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.domain.xml.AndroidXmlGenerator import kotlin.comparisons.compareBy class YoloToXmlConverter( diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt new file mode 100644 index 0000000000..168173cc9e --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt @@ -0,0 +1,7 @@ +package org.appdevforall.codeonthego.computervision.domain.model + +sealed interface LayoutItem { + data class SimpleView(val box: ScaledBox) : LayoutItem + data class HorizontalRow(val row: List) : LayoutItem + data class RadioGroup(val boxes: List, val orientation: String) : LayoutItem +} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt new file mode 100644 index 0000000000..a8b885cf59 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -0,0 +1,119 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox +import kotlin.text.substringAfterLast + +sealed class AndroidWidget( + protected val box: ScaledBox, + protected val parsedAttrs: Map +) { + abstract val tag: String + + protected abstract fun specificAttributes(): Map + + fun render( + context: XmlContext, + indent: String, + extraAttrs: Map = emptyMap(), + idOverride: String? = null + ) { + val id = idOverride ?: parsedAttrs["android:id"]?.substringAfterLast('/') ?: context.nextId(box.label) + val width = parsedAttrs["android:layout_width"] ?: extraAttrs["android:layout_width"] ?: "wrap_content" + val height = parsedAttrs["android:layout_height"] ?: extraAttrs["android:layout_height"] ?: "wrap_content" + + val finalAttrs = mutableMapOf( + "android:id" to "@+id/${id.escapeXmlAttr()}", + "android:layout_width" to width.escapeXmlAttr(), + "android:layout_height" to height.escapeXmlAttr() + ) + + specificAttributes().forEach { (k, v) -> finalAttrs[k] = v.escapeXmlAttr() } + + (parsedAttrs + extraAttrs).forEach { (key, value) -> + finalAttrs.putIfAbsent(key, value.escapeXmlAttr()) + } + + context.append("$indent<$tag\n") + finalAttrs.forEach { (key, value) -> + context.append("$indent $key=\"$value\"\n") + } + context.append("$indent/>") + } + + companion object { + fun create(box: ScaledBox, parsedAttrs: Map): AndroidWidget { + return when (box.label) { + "text", "button", "checkbox_unchecked", "checkbox_checked", + "radio_button_unchecked", "radio_button_checked", "switch_off", "switch_on" -> + TextBasedWidget(box, parsedAttrs, getTagFor(box.label)) + "text_entry_box" -> InputWidget(box, parsedAttrs) + "image_placeholder", "icon" -> ImageWidget(box, parsedAttrs) + else -> GenericWidget(box, parsedAttrs, getTagFor(box.label)) + } + } + + fun getTagFor(label: String): String = when (label) { + "text" -> "TextView" + "button" -> "Button" + "image_placeholder", "icon" -> "ImageView" + "checkbox_unchecked", "checkbox_checked" -> "CheckBox" + "radio_button_unchecked", "radio_button_checked" -> "RadioButton" + "switch_off", "switch_on" -> "Switch" + "text_entry_box" -> "EditText" + "dropdown" -> "Spinner" + "card" -> "androidx.cardview.widget.CardView" + "slider" -> "com.google.android.material.slider.Slider" + else -> "View" + } + } +} + +class TextBasedWidget( + box: ScaledBox, parsedAttrs: Map, + override val tag: String +) : AndroidWidget(box, parsedAttrs) { + override fun specificAttributes(): Map { + val attrs = mutableMapOf() + val widgetTags = setOf("Switch", "CheckBox", "RadioButton") + val rawViewText = parsedAttrs["android:text"] + ?: box.text.takeIf { it.isNotEmpty() && it != box.label } + ?: if (tag in widgetTags) tag else box.label + + attrs["android:text"] = rawViewText + attrs["tools:ignore"] = "HardcodedText" + + if (tag == "TextView") { + attrs["android:textSize"] = parsedAttrs["android:textSize"] ?: "16sp" + } + if (box.label.contains("_checked") || box.label.contains("_on")) { + attrs["android:checked"] = parsedAttrs["android:checked"] ?: "true" + } + return attrs + } +} + +class InputWidget( + box: ScaledBox, parsedAttrs: Map +) : AndroidWidget(box, parsedAttrs) { + override val tag = "EditText" + override fun specificAttributes(): Map = mapOf( + "android:hint" to (parsedAttrs["android:hint"] ?: box.text.ifEmpty { "Enter text..." }), + "android:inputType" to (parsedAttrs["android:inputType"] ?: "text"), + "tools:ignore" to "HardcodedText" + ) +} + +class ImageWidget( + box: ScaledBox, parsedAttrs: Map +) : AndroidWidget(box, parsedAttrs) { + override val tag = "ImageView" + override fun specificAttributes(): Map = mapOf( + "android:contentDescription" to (parsedAttrs["android:contentDescription"] ?: box.label), + "android:scaleType" to (parsedAttrs["android:scaleType"] ?: "centerCrop"), + "android:background" to (parsedAttrs["android:background"] ?: "#E0E0E0") + ) +} + +class GenericWidget(box: ScaledBox, parsedAttrs: Map, override val tag: String) : AndroidWidget(box, parsedAttrs) { + override fun specificAttributes() = emptyMap() +} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt new file mode 100644 index 0000000000..a8043824fa --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidXmlGenerator.kt @@ -0,0 +1,48 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +import org.appdevforall.codeonthego.computervision.domain.LayoutGeometryProcessor +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox + +class AndroidXmlGenerator( + private val geometryProcessor: LayoutGeometryProcessor +) { + internal fun buildXml( + boxes: List, + annotations: Map, + targetDpHeight: Int, + wrapInScroll: Boolean + ): String { + val context = XmlContext() + val maxBottom = boxes.maxOfOrNull { it.y + it.h } ?: 0 + val needScroll = wrapInScroll && maxBottom > targetDpHeight + + appendHeaders(context, needScroll) + + val layoutItems = geometryProcessor.buildLayoutTree(boxes) + val renderer = LayoutRenderer(context, annotations) + + layoutItems.forEach { item -> + renderer.render(item, " ") + } + + appendFooters(context, needScroll) + return context.toString() + } + + private fun appendHeaders(context: XmlContext, needScroll: Boolean) { + val namespaces = """xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"""" + context.appendLine("") + + if (needScroll) { + context.appendLine("") + context.appendLine(" ") + } else { + context.appendLine("") + } + context.appendLine() + } + + private fun appendFooters(context: XmlContext, needScroll: Boolean) { + context.appendLine(if (needScroll) " \n" else "") + } +} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt new file mode 100644 index 0000000000..4ee9d6f81a --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt @@ -0,0 +1,99 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox +import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser + +class LayoutRenderer( + private val context: XmlContext, + private val annotations: Map +) { + fun render(item: LayoutItem, indent: String = " ") { + when (item) { + is LayoutItem.SimpleView -> renderSimpleView(item.box, indent) + is LayoutItem.HorizontalRow -> renderHorizontalRow(item.row, indent) + is LayoutItem.RadioGroup -> renderRadioGroup(item.boxes, item.orientation, indent) + } + context.appendLine() + } + + private fun renderSimpleView( + box: ScaledBox, + indent: String, + extraAttrs: Map = emptyMap(), + idOverride: String? = null, + parsedAttrsOverride: Map? = null + ) { + val tag = AndroidWidget.getTagFor(box.label) + val parsedAttrs = parsedAttrsOverride ?: FuzzyAttributeParser.parse(annotations[box], tag) + + val widget = AndroidWidget.create(box, parsedAttrs) + widget.render(context, indent, extraAttrs, idOverride) + } + + private fun renderHorizontalRow(row: List, indent: String) { + context.appendLine( + """ + |$indent + """.trimMargin() + ) + + row.forEachIndexed { index, box -> + val extraAttrs = if (index < row.lastIndex) { + val nextBox = row[index + 1] + val gap = maxOf(0, nextBox.x - (box.x + box.w)) + mapOf("android:layout_marginEnd" to "${gap}dp") + } else emptyMap() + + renderSimpleView(box, "$indent ", extraAttrs) + context.appendLine() + } + context.append("$indent") + } + + private fun renderRadioGroup(boxes: List, orientation: String, indent: String) { + val groupId = context.nextId("radio_group") + + val radios = boxes.mapIndexed { index, box -> + val parsedAttrs = FuzzyAttributeParser.parse(annotations[box], "RadioButton") + val id = context.nextId(box.label) + val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) { + val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w)) + mapOf("android:layout_marginEnd" to "${gap}dp") + } else emptyMap() + + val isChecked = box.label == "radio_button_checked" || + parsedAttrs["android:checked"]?.equals("true", ignoreCase = true) == true + + object { val box = box; val attrs = parsedAttrs; val id = id; val extra = extraAttrs; val checked = isChecked } + } + + val checkedId = radios.firstOrNull { it.checked }?.id + + context.appendLine("$indent") + + radios.forEach { radio -> + renderSimpleView( + box = radio.box, + indent = "$indent ", + extraAttrs = radio.extra, + idOverride = radio.id, + parsedAttrsOverride = radio.attrs + ) + context.appendLine() + } + context.append("$indent") + } +} diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt new file mode 100644 index 0000000000..ba049b1169 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt @@ -0,0 +1,29 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +class XmlContext( + val builder: StringBuilder = StringBuilder(), + private val counters: MutableMap = mutableMapOf() +) { + fun nextId(label: String): String { + val count = counters.getOrPut(label) { 0 } + 1 + counters[label] = count + return "${label.replace(Regex("[^a-zA-Z0-9_]"), "_")}_$count" + } + + fun appendLine(text: String = "") { + builder.appendLine(text) + } + + fun append(text: String) { + builder.append(text) + } + + override fun toString(): String = builder.toString() +} + +fun String.escapeXmlAttr(): String = this.trim() + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") From da52384177c212c15453922847131c38041bb9b3 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 21 Apr 2026 09:40:14 -0500 Subject: [PATCH 2/5] fix: add validation against detected IDs, removed card widget detection and added safe guard for radios checked --- .../domain/xml/AndroidWidget.kt | 9 ++++++-- .../domain/xml/LayoutRenderer.kt | 9 +++++++- .../computervision/domain/xml/XmlContext.kt | 23 ++++++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt index a8b885cf59..eb9727d30e 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -17,7 +17,13 @@ sealed class AndroidWidget( extraAttrs: Map = emptyMap(), idOverride: String? = null ) { - val id = idOverride ?: parsedAttrs["android:id"]?.substringAfterLast('/') ?: context.nextId(box.label) + val manualId = idOverride ?: parsedAttrs["android:id"]?.substringAfterLast('/') + val id = if (manualId != null) { + context.registerId(manualId) + manualId + } else { + context.nextId(box.label) + } val width = parsedAttrs["android:layout_width"] ?: extraAttrs["android:layout_width"] ?: "wrap_content" val height = parsedAttrs["android:layout_height"] ?: extraAttrs["android:layout_height"] ?: "wrap_content" @@ -61,7 +67,6 @@ sealed class AndroidWidget( "switch_off", "switch_on" -> "Switch" "text_entry_box" -> "EditText" "dropdown" -> "Spinner" - "card" -> "androidx.cardview.widget.CardView" "slider" -> "com.google.android.material.slider.Slider" else -> "View" } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt index 4ee9d6f81a..16c36822fe 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt @@ -85,12 +85,19 @@ class LayoutRenderer( context.appendLine("$indent>") radios.forEach { radio -> + val safeAttrs = radio.attrs.toMutableMap() + if (radio.id == checkedId) { + safeAttrs["android:checked"] = "true" + } else { + safeAttrs["android:checked"] = "false" + } + renderSimpleView( box = radio.box, indent = "$indent ", extraAttrs = radio.extra, idOverride = radio.id, - parsedAttrsOverride = radio.attrs + parsedAttrsOverride = safeAttrs ) context.appendLine() } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt index ba049b1169..8d610ce862 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt @@ -4,10 +4,27 @@ class XmlContext( val builder: StringBuilder = StringBuilder(), private val counters: MutableMap = mutableMapOf() ) { + private val usedIds = mutableSetOf() + fun nextId(label: String): String { - val count = counters.getOrPut(label) { 0 } + 1 - counters[label] = count - return "${label.replace(Regex("[^a-zA-Z0-9_]"), "_")}_$count" + val safeLabel = label.replace(Regex("[^a-zA-Z0-9_]"), "_") + + var count = counters.getOrDefault(safeLabel, 0) + var newId: String + + do { + count++ + newId = "${safeLabel}_$count" + } while (usedIds.contains(newId)) + + counters[safeLabel] = count + usedIds.add(newId) + + return newId + } + + fun registerId(id: String) { + usedIds.add(id) } fun appendLine(text: String = "") { From a1819576baeb5efdb80643f847b5dd52a749b102 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 21 Apr 2026 11:04:36 -0500 Subject: [PATCH 3/5] refactor: use android default slider for full compatibility --- .../codeonthego/computervision/domain/xml/AndroidWidget.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt index eb9727d30e..699ef15e48 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -67,7 +67,7 @@ sealed class AndroidWidget( "switch_off", "switch_on" -> "Switch" "text_entry_box" -> "EditText" "dropdown" -> "Spinner" - "slider" -> "com.google.android.material.slider.Slider" + "slider" -> "SeekBar" else -> "View" } } From dd454034164b2dbf6756a15a5bb7e60393348564 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 21 Apr 2026 11:21:34 -0500 Subject: [PATCH 4/5] fix: removed unnecessary `scaleType` and `background` attributes to match the spreadsheet --- .../codeonthego/computervision/domain/xml/AndroidWidget.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt index 699ef15e48..439bba02d4 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -114,8 +114,6 @@ class ImageWidget( override val tag = "ImageView" override fun specificAttributes(): Map = mapOf( "android:contentDescription" to (parsedAttrs["android:contentDescription"] ?: box.label), - "android:scaleType" to (parsedAttrs["android:scaleType"] ?: "centerCrop"), - "android:background" to (parsedAttrs["android:background"] ?: "#E0E0E0") ) } From 0b28785b98d6dfc5e7aa3d042b77717fd98a153a Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 21 Apr 2026 11:56:31 -0500 Subject: [PATCH 5/5] fix: resolve manual/auto ID collisions, fix 0-index counter, and merge radio labels for proper RadioGroup parsing --- .../domain/LayoutGeometryProcessor.kt | 28 ++++++++++++++++++- .../domain/xml/AndroidWidget.kt | 9 ++---- .../domain/xml/LayoutRenderer.kt | 5 +++- .../computervision/domain/xml/XmlContext.kt | 11 +++++++- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt index 786454907f..f5ab77df9c 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt @@ -121,6 +121,31 @@ class LayoutGeometryProcessor { ) } + private fun mergeRadioLabels(row: List): List { + val merged = mutableListOf() + var i = 0 + + while (i < row.size) { + val current = row[i] + + if (isRadioButton(current) && i + 1 < row.size && row[i + 1].label == "text") { + val nextText = row[i + 1] + merged.add(current.copy(text = nextText.text)) + i += 2 + } + else if (current.label == "text" && i + 1 < row.size && isRadioButton(row[i + 1])) { + val nextRadio = row[i + 1] + merged.add(nextRadio.copy(text = current.text)) + i += 2 + } + else { + merged.add(current) + i++ + } + } + return merged + } + internal fun buildLayoutTree(boxes: List): List { val rows = groupIntoRows(boxes) val items = mutableListOf() @@ -133,7 +158,8 @@ class LayoutGeometryProcessor { } } - rows.forEach { row -> + rows.forEach { rawRow -> + val row = mergeRadioLabels(rawRow) when { row.all { isRadioButton(it) } && row.size == 1 -> verticalRadioRun.add(row.first()) row.all { isRadioButton(it) } -> { diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt index 439bba02d4..99cf298265 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -17,13 +17,8 @@ sealed class AndroidWidget( extraAttrs: Map = emptyMap(), idOverride: String? = null ) { - val manualId = idOverride ?: parsedAttrs["android:id"]?.substringAfterLast('/') - val id = if (manualId != null) { - context.registerId(manualId) - manualId - } else { - context.nextId(box.label) - } + val requestedId = idOverride ?: parsedAttrs["android:id"]?.substringAfterLast('/') + val id = context.resolveId(requestedId, box.label) val width = parsedAttrs["android:layout_width"] ?: extraAttrs["android:layout_width"] ?: "wrap_content" val height = parsedAttrs["android:layout_height"] ?: extraAttrs["android:layout_height"] ?: "wrap_content" diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt index 16c36822fe..47d8164824 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt @@ -60,7 +60,10 @@ class LayoutRenderer( val radios = boxes.mapIndexed { index, box -> val parsedAttrs = FuzzyAttributeParser.parse(annotations[box], "RadioButton") - val id = context.nextId(box.label) + + val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') + val id = context.resolveId(requestedId, box.label) + val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) { val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w)) mapOf("android:layout_marginEnd" to "${gap}dp") diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt index 8d610ce862..aa28393d80 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt @@ -9,7 +9,7 @@ class XmlContext( fun nextId(label: String): String { val safeLabel = label.replace(Regex("[^a-zA-Z0-9_]"), "_") - var count = counters.getOrDefault(safeLabel, 0) + var count = counters.getOrDefault(safeLabel, -1) var newId: String do { @@ -27,6 +27,15 @@ class XmlContext( usedIds.add(id) } + fun resolveId(requestedId: String?, fallbackLabel: String): String { + return if (requestedId != null) { + registerId(requestedId) + requestedId + } else { + nextId(fallbackLabel) + } + } + fun appendLine(text: String = "") { builder.appendLine(text) }