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..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 @@ -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,64 @@ class LayoutGeometryProcessor { Rect(x, y, x + w, y + h) ) } + + 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() + val verticalRadioRun = mutableListOf() + + fun flushVerticalRadioRun() { + if (verticalRadioRun.isNotEmpty()) { + items.add(LayoutItem.RadioGroup(verticalRadioRun.toList(), "vertical")) + verticalRadioRun.clear() + } + } + + rows.forEach { rawRow -> + val row = mergeRadioLabels(rawRow) + 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..99cf298265 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -0,0 +1,117 @@ +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 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" + + 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" + "slider" -> "SeekBar" + 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), + ) +} + +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..47d8164824 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt @@ -0,0 +1,109 @@ +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 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") + } 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 -> + 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 = safeAttrs + ) + 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..aa28393d80 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt @@ -0,0 +1,55 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +class XmlContext( + val builder: StringBuilder = StringBuilder(), + private val counters: MutableMap = mutableMapOf() +) { + private val usedIds = mutableSetOf() + + fun nextId(label: String): String { + val safeLabel = label.replace(Regex("[^a-zA-Z0-9_]"), "_") + + var count = counters.getOrDefault(safeLabel, -1) + 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 resolveId(requestedId: String?, fallbackLabel: String): String { + return if (requestedId != null) { + registerId(requestedId) + requestedId + } else { + nextId(fallbackLabel) + } + } + + 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("'", "'")