diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index f5c12a4fef..8964fb74b3 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -121,6 +121,8 @@ import com.itsaky.androidide.utils.FlashType import com.itsaky.androidide.utils.InstallationResultHandler.onResult import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.StringsInjectionException +import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyResponsiveAppBarInsets import com.itsaky.androidide.utils.applyImmersiveModeInsets import com.itsaky.androidide.utils.applyRootSystemInsetsAsPadding @@ -1089,28 +1091,66 @@ abstract class BaseEditorActivity : private fun handleUiDesignerResult(result: ActivityResult) { if (result.resultCode != RESULT_OK || result.data == null) { - log.warn( - "UI Designer returned invalid result: resultCode={}, data={}", - result.resultCode, - result.data, - ) - return - } - val generated = - result.data!!.getStringExtra(UIDesignerActivity.RESULT_GENERATED_XML) - if (TextUtils.isEmpty(generated)) { - log.warn("UI Designer returned blank generated XML code") - return - } - val view = provideCurrentEditor() - val text = - view?.editor?.text ?: run { - log.warn("No file opened to append UI designer result") - return - } - val endLine = text.lineCount - 1 - text.replace(0, 0, endLine, text.getColumnCount(endLine), generated) - } + log.warn("UI Designer returned invalid result: resultCode={}, data={}", result.resultCode, result.data) + return + } + + val data = result.data!! + val generatedXml = data.getStringExtra(UIDesignerActivity.RESULT_GENERATED_XML) + + if (TextUtils.isEmpty(generatedXml)) { + log.warn("UI Designer returned blank generated XML code") + return + } + + editorActivityScope.launch { + val injectionSuccess = handleStringsInjection(data) + + if (injectionSuccess) { + withContext(Dispatchers.Main) { applyGeneratedXmlToEditor(generatedXml!!) } + } else { + log.warn("Aborting layout update due to string injection failure.") + } + } + } + + private suspend fun handleStringsInjection(data: Intent): Boolean { + val stringsXml = data.getStringExtra(UIDesignerActivity.EXTRA_GENERATED_STRINGS) + val layoutFilePath = data.getStringExtra(UIDesignerActivity.EXTRA_LAYOUT_FILE_PATH) + + if (stringsXml.isNullOrBlank()) return true + + if (layoutFilePath.isNullOrBlank()) { + log.warn("Skipping string injection: generated strings present but layout file path is missing.") + return false + } + + val result = StringsXmlInjector.inject(layoutFilePath, stringsXml) + + result.onFailure { error -> + log.error("String injection failed", error) + withContext(Dispatchers.Main) { + val message = when (error) { + is StringsInjectionException -> getString(error.messageRes) + else -> getString(string.msg_strings_injection_failed) + } + flashError(message) + } + } + + return result.isSuccess + } + + private fun applyGeneratedXmlToEditor(generatedXml: String) { + val view = provideCurrentEditor() + val text = view?.editor?.text ?: run { + log.warn("No file opened to append UI designer result") + return + } + + val endLine = text.lineCount - 1 + text.replace(0, 0, endLine, text.getColumnCount(endLine), generatedXml) + } private fun setupDrawers() { // Note: Drawer toggle is now set up in setupToolbar() on the title toolbar @@ -1137,7 +1177,6 @@ abstract class BaseEditorActivity : content.progressIndicator.visibility = if (visible) View.VISIBLE else View.GONE invalidateOptionsMenu() } - private fun setupStateObservers() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.CREATED) { diff --git a/app/src/main/java/com/itsaky/androidide/api/commands/AddStringArrayResourceCommand.kt b/app/src/main/java/com/itsaky/androidide/api/commands/AddStringArrayResourceCommand.kt new file mode 100644 index 0000000000..8a33c4f5a0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/api/commands/AddStringArrayResourceCommand.kt @@ -0,0 +1,150 @@ +package com.itsaky.androidide.api.commands + +import com.blankj.utilcode.util.FileIOUtils +import com.itsaky.androidide.agent.model.ToolResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.xml.sax.EntityResolver +import org.xml.sax.InputSource +import java.io.File +import java.io.StringReader +import java.util.concurrent.ConcurrentHashMap +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import java.io.StringWriter + +class AddStringArrayResourceCommand( + private val stringsFilePath: String, + private val name: String, + private val items: List +) : SuspendCommand { + + companion object { + private val log = LoggerFactory.getLogger(AddStringArrayResourceCommand::class.java) + private val fileMutexes = ConcurrentHashMap() + } + + override suspend fun execute(): ToolResult { + if (name.isBlank()) { + return ToolResult.failure("String-array name cannot be blank.") + } + + val stringsFile = withContext(Dispatchers.IO) { File(stringsFilePath).canonicalFile } + if (!stringsFile.exists() || !stringsFile.isFile) { + return ToolResult.failure("strings.xml file not found at '$stringsFilePath'.") + } + + val fileMutex = fileMutexes.computeIfAbsent(stringsFile.path) { Mutex() } + + return fileMutex.withLock { + try { + withContext(Dispatchers.IO) { + val currentContent = FileIOUtils.readFile2String(stringsFile) + val updatedContent = upsertStringArray(currentContent, name, items) + + if (FileIOUtils.writeFileFromString(stringsFile, updatedContent)) { + ToolResult.success( + message = "Successfully added or updated string-array '$name'.", + data = "R.array.$name" + ) + } else { + ToolResult.failure("Failed to write to strings.xml.") + } + } + } catch (e: Exception) { + ToolResult.failure( + message = "An error occurred while adding or updating the string-array resource.", + error_details = e.message + ) + } + } + } + + private fun upsertStringArray(currentContent: String, name: String, items: List): String { + val document = newDocumentBuilder() + .parse(InputSource(StringReader(currentContent))) + + val resources = document.getElementsByTagName("resources").item(0) as? Element + ?: throw IllegalStateException("The strings.xml file does not contain the tag") + + val newNode = document.createElement("string-array").apply { + setAttribute("name", name) + items.forEach { itemValue -> + appendChild(document.createElement("item").apply { + appendChild(document.createTextNode(itemValue)) + }) + } + } + + val existingNode = List(document.getElementsByTagName("string-array").length) { index -> + document.getElementsByTagName("string-array").item(index) as Element + }.firstOrNull { it.getAttribute("name") == name } + + if (existingNode != null) { + existingNode.parentNode.replaceChild(newNode, existingNode) + } else { + appendWithIndentation(document, resources, newNode) + } + + return serializeDocument(document) + } + + private fun appendWithIndentation(document: Document, parent: Element, child: Element) { + val closingIndentation = parent.lastChild + val childIndentation = document.createTextNode("\n ") + + if (closingIndentation != null && closingIndentation.isResourcesClosingIndentation()) { + parent.insertBefore(childIndentation, closingIndentation) + parent.insertBefore(child, closingIndentation) + } else { + parent.appendChild(childIndentation) + parent.appendChild(child) + parent.appendChild(document.createTextNode("\n")) + } + } + + private fun serializeDocument(document: Document): String { + val transformer = TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.INDENT, "yes") + setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4") + setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no") + } + return StringWriter().also { writer -> + transformer.transform(DOMSource(document), StreamResult(writer)) + }.toString() + } + + private fun newDocumentBuilder(): DocumentBuilder { + return DocumentBuilderFactory.newInstance().apply { + setFeatureIfSupported("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeatureIfSupported("http://xml.org/sax/features/external-general-entities", false) + setFeatureIfSupported("http://xml.org/sax/features/external-parameter-entities", false) + isExpandEntityReferences = false + }.newDocumentBuilder().apply { + setEntityResolver { _, _ -> InputSource(StringReader("")) } + } + } + + private fun DocumentBuilderFactory.setFeatureIfSupported(name: String, value: Boolean) { + try { + setFeature(name, value) + } catch (_: ParserConfigurationException) { + log.warn("XML parser does not support feature '{}'; continuing without it.", name) + } + } + + private fun Node.isResourcesClosingIndentation(): Boolean { + return nodeType == Node.TEXT_NODE && textContent.contains('\n') + } +} diff --git a/app/src/main/java/com/itsaky/androidide/api/commands/AddStringResourceCommand.kt b/app/src/main/java/com/itsaky/androidide/api/commands/AddStringResourceCommand.kt index 29f4c3cc35..a1f25e1779 100644 --- a/app/src/main/java/com/itsaky/androidide/api/commands/AddStringResourceCommand.kt +++ b/app/src/main/java/com/itsaky/androidide/api/commands/AddStringResourceCommand.kt @@ -3,8 +3,8 @@ package com.itsaky.androidide.api.commands import com.blankj.utilcode.util.FileIOUtils import com.itsaky.androidide.agent.model.ToolResult import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.utils.ProjectStringsXmlResolver import org.apache.commons.text.StringEscapeUtils -import java.io.File /** * A command to add or update a string resource in the project's strings.xml file. @@ -16,14 +16,10 @@ class AddStringResourceCommand( override fun execute(): ToolResult { return try { val baseDir = IProjectManager.getInstance().projectDir - // Standard path for the default strings.xml file. - val stringsFile = File(baseDir, "app/src/main/res/values/strings.xml") - - if (!stringsFile.exists()) { - return ToolResult.failure( + val stringsFile = + ProjectStringsXmlResolver.findNow(baseDir.path) ?: return ToolResult.failure( message = "strings.xml not found at the standard path: app/src/main/res/values/strings.xml" ) - } val content = FileIOUtils.readFile2String(stringsFile) @@ -87,4 +83,4 @@ class AddStringResourceCommand( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/itsaky/androidide/api/commands/SuspendCommand.kt b/app/src/main/java/com/itsaky/androidide/api/commands/SuspendCommand.kt new file mode 100644 index 0000000000..76a66090f5 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/api/commands/SuspendCommand.kt @@ -0,0 +1,7 @@ +package com.itsaky.androidide.api.commands + +import com.itsaky.androidide.agent.model.ToolResult + +interface SuspendCommand { + suspend fun execute(): ToolResult +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/ProjectStringsXmlResolver.kt b/app/src/main/java/com/itsaky/androidide/utils/ProjectStringsXmlResolver.kt new file mode 100644 index 0000000000..d0c3ce9c06 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/ProjectStringsXmlResolver.kt @@ -0,0 +1,37 @@ +package com.itsaky.androidide.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Resolves the project's default strings.xml file while ensuring access stays + * within the provided project root. + */ +object ProjectStringsXmlResolver { + + private const val STRINGS_XML_RELATIVE_PATH = "app/src/main/res/values/strings.xml" + + suspend fun find(projectRootPath: String): File? = withContext(Dispatchers.IO) { + findNow(projectRootPath) + } + + fun findNow(projectRootPath: String): File? { + val projectRoot = projectRootPath.toCanonicalDirectory() ?: return null + val stringsFile = File(projectRoot, STRINGS_XML_RELATIVE_PATH).canonicalFile + return stringsFile.takeIf { + it.exists() && it.isFile && it.isWithin(projectRoot) + } + } + + private fun String.toCanonicalDirectory(): File? { + val dir = File(this).canonicalFile + return dir.takeIf { it.exists() && it.isDirectory } + } + + private fun File.isWithin(root: File): Boolean { + val rootPath = root.toPath() + val filePath = toPath() + return filePath.startsWith(rootPath) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/StringsXmlInjector.kt b/app/src/main/java/com/itsaky/androidide/utils/StringsXmlInjector.kt new file mode 100644 index 0000000000..42ad265cd6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/StringsXmlInjector.kt @@ -0,0 +1,106 @@ +package com.itsaky.androidide.utils + +import androidx.annotation.StringRes +import com.itsaky.androidide.R +import com.itsaky.androidide.api.commands.AddStringArrayResourceCommand +import com.itsaky.androidide.projects.IProjectManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import org.w3c.dom.Element +import org.xml.sax.EntityResolver +import org.xml.sax.InputSource +import java.io.File +import java.io.FileNotFoundException +import java.io.StringReader +import javax.xml.parsers.DocumentBuilder +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException + +/** + * Parses generated string-array XML and delegates file updates to + * [AddStringArrayResourceCommand]. + */ +object StringsXmlInjector { + private val log = LoggerFactory.getLogger(StringsXmlInjector::class.java) + + suspend fun inject(layoutFilePath: String, newStringsXml: String): Result = + withContext(Dispatchers.IO) { + try { + val stringsFile = findProjectStringsFile() + ?: return@withContext Result.failure(FileNotFoundException( + "Cannot resolve strings.xml path for layout: $layoutFilePath") + ) + + parseStringArrays(newStringsXml).forEach { (arrayName, items) -> + val result = AddStringArrayResourceCommand( + stringsFilePath = stringsFile.path, + name = arrayName, + items = items + ).execute() + + if (!result.success) { + return@withContext Result.failure( + IllegalStateException(result.error_details ?: result.message) + ) + } + } + + Result.success(Unit) + } catch (e: Exception) { + log.error("String-array injection failed", e) + Result.failure(e.toUserFacingError()) + } + } + + private suspend fun findProjectStringsFile(): File? { + val projectRootPath = IProjectManager.getInstance().projectDirPath + return ProjectStringsXmlResolver.find(projectRootPath) + } + + private fun parseStringArrays(newStringsXml: String): List>> { + val builder = newDocumentBuilder() + val document = builder.parse(InputSource(StringReader("$newStringsXml"))) + val arrays = document.getElementsByTagName("string-array") + + return List(arrays.length) { index -> + val arrayElement = arrays.item(index) as Element + val arrayName = arrayElement.getAttribute("name") + val items = arrayElement.getElementsByTagName("item") + arrayName to List(items.length) { itemIndex -> items.item(itemIndex).textContent } + } + } + + private fun newDocumentBuilder(): DocumentBuilder { + return DocumentBuilderFactory.newInstance().apply { + setFeatureIfSupported("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeatureIfSupported("http://xml.org/sax/features/external-general-entities", false) + setFeatureIfSupported("http://xml.org/sax/features/external-parameter-entities", false) + isExpandEntityReferences = false + }.newDocumentBuilder().apply { + setEntityResolver { _, _ -> InputSource(StringReader("")) } + } + } + + private fun DocumentBuilderFactory.setFeatureIfSupported(name: String, value: Boolean) { + try { + setFeature(name, value) + } catch (_: ParserConfigurationException) { + log.warn("XML parser does not support feature '{}'; continuing without it.", name) + } + } + + private fun Exception.toUserFacingError(): StringsInjectionException { + val messageRes = when (this) { + is FileNotFoundException -> R.string.msg_strings_injection_file_not_found + is IllegalStateException -> R.string.msg_strings_injection_invalid_xml + else -> R.string.msg_strings_injection_failed + } + return StringsInjectionException(messageRes, this) + } +} + +class StringsInjectionException( + @StringRes val messageRes: Int, + cause: Throwable? = null +) : Exception(null, cause) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 563068d8d0..5272efb569 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,4 +19,8 @@ Code on the Go Use simplified prompt + Failed to update generated string resources. + Could not find the project string resources file. + This device does not support the XML parser configuration required to update generated string resources. + Generated string resources are not valid XML. diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt index ac9f8dc983..3ba2b5991a 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepository.kt @@ -31,7 +31,7 @@ interface ComputerVisionRepository { sourceImageHeight: Int, targetDpWidth: Int, targetDpHeight: Int - ): Result + ): Result> fun isModelInitialized(): Boolean diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt index 631fb282a1..705d8882b0 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/ComputerVisionRepositoryImpl.kt @@ -60,7 +60,7 @@ class ComputerVisionRepositoryImpl( sourceImageHeight: Int, targetDpWidth: Int, targetDpHeight: Int - ): Result = withContext(Dispatchers.Default) { + ): Result> = withContext(Dispatchers.Default) { runCatching { YoloToXmlConverter.generateXmlLayout( detections = detections, diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt index 46ba667498..a296de8711 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt @@ -91,7 +91,7 @@ object FuzzyAttributeParser { TINT("android:tint", listOf("tint"), ValueType.COLOR), STYLE("style", listOf("style")), - ENTRIES("tools:entries", listOf("entries")), + ENTRIES("android:entries", listOf("entries")), CHECKED("android:checked", listOf("checked")), CARD_CORNER_RADIUS("app:cardCornerRadius", listOf("cardcornerradius", "card_corner_radius", "cornerradius", "corner_radius"), ValueType.DIMENSION), 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 b887c60205..0ce2ae1183 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 @@ -18,7 +18,7 @@ class YoloToXmlConverter( targetDpWidth: Int, targetDpHeight: Int, wrapInScroll: Boolean = true - ): String { + ): Pair { val uiCandidates = detections .filter { (it.isYolo || it.label == "text") && it.label != "widget_tag" } .distinctBy { @@ -58,7 +58,7 @@ class YoloToXmlConverter( targetDpWidth: Int, targetDpHeight: Int, wrapInScroll: Boolean = true - ): String { + ): Pair { val geometry = LayoutGeometryProcessor() val matcher = WidgetAnnotationMatcher() val generator = AndroidXmlGenerator(geometry) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt index cc547b1f5f..6d3324175b 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/AttributeValidator.kt @@ -57,9 +57,7 @@ object EntriesValidator : AttributeValidator { val trimmed = rawValue.trim() if (trimmed.startsWith("@")) return trimmed - val hasBrackets = isEnclosedInBrackets(trimmed) val content = trimmed.removeSurrounding("[", "]") - val rawItems = content.split(",") val isNumericArray = isEntireArrayLikelyNumeric(rawItems) @@ -73,8 +71,7 @@ object EntriesValidator : AttributeValidator { } } - val finalString = cleanedItems.joinToString(", ") - return if (hasBrackets) "[$finalString]" else finalString + return cleanedItems.joinToString(",") } private fun isEnclosedInBrackets(text: String): Boolean { diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt index eb77c2cecc..48f3b614a5 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/grammar/WidgetGrammar.kt @@ -1,5 +1,7 @@ package org.appdevforall.codeonthego.computervision.domain.grammar +import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser.AttributeKey + interface WidgetGrammar { val tag: String val attributes: Map @@ -8,11 +10,11 @@ interface WidgetGrammar { object SpinnerGrammar : WidgetGrammar { override val tag = "Spinner" override val attributes = mapOf( - "android:layout_width" to DimensionValidator, - "android:layout_height" to DimensionValidator, - "android:id" to PassThroughValidator, - "android:text" to PassThroughValidator, - "tools:entries" to EntriesValidator + AttributeKey.WIDTH.xmlName to DimensionValidator, + AttributeKey.HEIGHT.xmlName to DimensionValidator, + AttributeKey.ID.xmlName to PassThroughValidator, + AttributeKey.TEXT.xmlName to PassThroughValidator, + AttributeKey.ENTRIES.xmlName to EntriesValidator ) } @@ -21,11 +23,11 @@ object ImageViewGrammar : WidgetGrammar { private val gravityValues = listOf("top", "bottom", "left", "right", "center", "center_vertical", "center_horizontal", "start", "end") override val attributes = mapOf( - "android:layout_width" to DimensionValidator, - "android:layout_height" to DimensionValidator, - "android:id" to PassThroughValidator, - "android:src" to PassThroughValidator, - "android:layout_gravity" to CategoricalValidator(gravityValues) + AttributeKey.WIDTH.xmlName to DimensionValidator, + AttributeKey.HEIGHT.xmlName to DimensionValidator, + AttributeKey.ID.xmlName to PassThroughValidator, + AttributeKey.SRC.xmlName to PassThroughValidator, + AttributeKey.LAYOUT_GRAVITY.xmlName to CategoricalValidator(gravityValues) ) } @@ -34,23 +36,23 @@ object EditTextGrammar : WidgetGrammar { private val inputTypeValues = listOf("text", "textPassword", "number", "numberDecimal", "textEmailAddress", "textUri", "phone") override val attributes = mapOf( - "android:layout_width" to DimensionValidator, - "android:layout_height" to DimensionValidator, - "android:id" to PassThroughValidator, - "android:text" to PassThroughValidator, - "android:inputType" to CategoricalValidator(inputTypeValues), - "android:hint" to PassThroughValidator + AttributeKey.WIDTH.xmlName to DimensionValidator, + AttributeKey.HEIGHT.xmlName to DimensionValidator, + AttributeKey.ID.xmlName to PassThroughValidator, + AttributeKey.TEXT.xmlName to PassThroughValidator, + AttributeKey.INPUT_TYPE.xmlName to CategoricalValidator(inputTypeValues), + AttributeKey.HINT.xmlName to PassThroughValidator ) } object SliderGrammar : WidgetGrammar { override val tag = "com.google.android.material.slider.Slider" override val attributes = mapOf( - "android:layout_width" to DimensionValidator, - "android:layout_height" to DimensionValidator, - "android:id" to PassThroughValidator, - "android:text" to PassThroughValidator, - "android:layout_weight" to PassThroughValidator, - "style" to SliderStyleValidator + AttributeKey.WIDTH.xmlName to DimensionValidator, + AttributeKey.HEIGHT.xmlName to DimensionValidator, + AttributeKey.ID.xmlName to PassThroughValidator, + AttributeKey.TEXT.xmlName to PassThroughValidator, + AttributeKey.LAYOUT_WEIGHT.xmlName to PassThroughValidator, + AttributeKey.STYLE.xmlName to SliderStyleValidator ) } 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 73641c6bb4..5c8cb6cc0b 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 @@ -1,6 +1,7 @@ package org.appdevforall.codeonthego.computervision.domain.xml import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox +import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser.AttributeKey import kotlin.text.substringAfterLast sealed class AndroidWidget( @@ -12,6 +13,9 @@ sealed class AndroidWidget( protected open fun fallbackIdLabel(): String = box.label protected abstract fun specificAttributes(): Map + protected open fun processAttributes(context: XmlContext, id: String, attrs: Map): Map { + return attrs.mapValues { it.value.escapeXmlAttr() } + } fun render( context: XmlContext, @@ -19,21 +23,24 @@ sealed class AndroidWidget( extraAttrs: Map = emptyMap(), idOverride: String? = null ) { - val requestedId = idOverride ?: parsedAttrs["android:id"]?.substringAfterLast('/') + val requestedId = idOverride ?: parsedAttrs[AttributeKey.ID.xmlName]?.substringAfterLast('/') val id = context.resolveId(requestedId, fallbackIdLabel()) - 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 width = parsedAttrs[AttributeKey.WIDTH.xmlName] ?: extraAttrs[AttributeKey.WIDTH.xmlName] ?: "wrap_content" + val height = parsedAttrs[AttributeKey.HEIGHT.xmlName] ?: extraAttrs[AttributeKey.HEIGHT.xmlName] ?: "wrap_content" val finalAttrs = mutableMapOf( - "android:id" to "@+id/${id.escapeXmlAttr()}", - "android:layout_width" to width.escapeXmlAttr(), - "android:layout_height" to height.escapeXmlAttr() + AttributeKey.ID.xmlName to "@+id/${id.escapeXmlAttr()}", + AttributeKey.WIDTH.xmlName to width.escapeXmlAttr(), + AttributeKey.HEIGHT.xmlName to height.escapeXmlAttr() ) specificAttributes().forEach { (k, v) -> finalAttrs[k] = v.escapeXmlAttr() } - (parsedAttrs + extraAttrs).forEach { (key, value) -> - finalAttrs.putIfAbsent(key, value.escapeXmlAttr()) + val mergedAttrs = parsedAttrs + extraAttrs + val processedAttrs = processAttributes(context, id, mergedAttrs) + + processedAttrs.forEach { (key, value) -> + finalAttrs.putIfAbsent(key, value) } context.append("$indent<$tag\n") @@ -52,6 +59,7 @@ sealed class AndroidWidget( "switch_off", "switch_on" -> SwitchWidget(box, parsedAttrs) "text_entry_box" -> InputWidget(box, parsedAttrs) "image_placeholder", "icon" -> ImageWidget(box, parsedAttrs) + "dropdown" -> SpinnerWidget(box, parsedAttrs) else -> GenericWidget(box, parsedAttrs, getTagFor(box.label)) } } @@ -71,6 +79,31 @@ sealed class AndroidWidget( } } +class SpinnerWidget( + box: ScaledBox, parsedAttrs: Map +) : AndroidWidget(box, parsedAttrs) { + override val tag = "Spinner" + override fun fallbackIdLabel(): String = "spinner" + override fun specificAttributes() = emptyMap() + + override fun processAttributes(context: XmlContext, id: String, attrs: Map): Map { + val processed = mutableMapOf() + + attrs.forEach { (key, value) -> + if (key == AttributeKey.ENTRIES.xmlName && !value.startsWith("@")) { + val arrayName = "${id}_array" + val items = value.split(",").map { it.trim() }.filter { it.isNotEmpty() } + + context.stringArrays[arrayName] = items + processed[key] = "@array/$arrayName" + } else { + processed[key] = value.escapeXmlAttr() + } + } + return processed + } +} + class TextBasedWidget( box: ScaledBox, parsedAttrs: Map, override val tag: String @@ -78,18 +111,18 @@ class TextBasedWidget( override fun specificAttributes(): Map { val attrs = mutableMapOf() val widgetTags = setOf("Switch", "CheckBox", "RadioButton") - val rawViewText = parsedAttrs["android:text"] + val rawViewText = parsedAttrs[AttributeKey.TEXT.xmlName] ?: box.text.takeIf { it.isNotEmpty() && it != box.label } ?: if (tag in widgetTags) tag else box.label - attrs["android:text"] = rawViewText + attrs[AttributeKey.TEXT.xmlName] = rawViewText attrs["tools:ignore"] = "HardcodedText" if (tag == "TextView") { - attrs["android:textSize"] = parsedAttrs["android:textSize"] ?: "16sp" + attrs[AttributeKey.TEXT_SIZE.xmlName] = parsedAttrs[AttributeKey.TEXT_SIZE.xmlName] ?: "16sp" } if (box.label.contains("_checked") || box.label.contains("_on")) { - attrs["android:checked"] = parsedAttrs["android:checked"] ?: "true" + attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: "true" } return attrs } @@ -104,14 +137,14 @@ class CheckBoxWidget( val attrs = mutableMapOf() val rawViewText = box.text.takeIf { it.isNotEmpty() && it != box.label } - ?: parsedAttrs["android:text"] + ?: parsedAttrs[AttributeKey.TEXT.xmlName] ?: "CheckBox" - attrs["android:text"] = rawViewText + attrs[AttributeKey.TEXT.xmlName] = rawViewText attrs["tools:ignore"] = "HardcodedText" if (box.label.contains("_checked")) { - attrs["android:checked"] = parsedAttrs["android:checked"] ?: "true" + attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: "true" } return attrs } @@ -127,13 +160,13 @@ class SwitchWidget( override fun specificAttributes(): Map { val attrs = mutableMapOf() - val switchText = parsedAttrs["android:text"] ?: box.text.trim().takeIf { it.isNotEmpty() && it != box.label } ?: "Switch" + val switchText = parsedAttrs[AttributeKey.TEXT.xmlName] ?: box.text.trim().takeIf { it.isNotEmpty() && it != box.label } ?: "Switch" - attrs["android:text"] = switchText + attrs[AttributeKey.TEXT.xmlName] = switchText attrs["tools:ignore"] = "HardcodedText" if (box.label.contains("_on")) { - attrs["android:checked"] = parsedAttrs["android:checked"] ?: "true" + attrs[AttributeKey.CHECKED.xmlName] = parsedAttrs[AttributeKey.CHECKED.xmlName] ?: "true" } return attrs @@ -145,8 +178,8 @@ class InputWidget( ) : 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"), + AttributeKey.HINT.xmlName to (parsedAttrs[AttributeKey.HINT.xmlName] ?: box.text.ifEmpty { "Enter text..." }), + AttributeKey.INPUT_TYPE.xmlName to (parsedAttrs[AttributeKey.INPUT_TYPE.xmlName] ?: "text"), "tools:ignore" to "HardcodedText" ) } @@ -156,7 +189,7 @@ class ImageWidget( ) : AndroidWidget(box, parsedAttrs) { override val tag = "ImageView" override fun specificAttributes(): Map = mapOf( - "android:contentDescription" to (parsedAttrs["android:contentDescription"] ?: box.label), + AttributeKey.CONTENT_DESCRIPTION.xmlName to (parsedAttrs[AttributeKey.CONTENT_DESCRIPTION.xmlName] ?: box.label), ) } 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 index a8043824fa..38d131a75e 100644 --- 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 @@ -11,7 +11,7 @@ class AndroidXmlGenerator( annotations: Map, targetDpHeight: Int, wrapInScroll: Boolean - ): String { + ): Pair { val context = XmlContext() val maxBottom = boxes.maxOfOrNull { it.y + it.h } ?: 0 val needScroll = wrapInScroll && maxBottom > targetDpHeight @@ -21,12 +21,29 @@ class AndroidXmlGenerator( val layoutItems = geometryProcessor.buildLayoutTree(boxes) val renderer = LayoutRenderer(context, annotations) - layoutItems.forEach { item -> - renderer.render(item, " ") - } + layoutItems.forEach { item -> renderer.render(item, " ") } appendFooters(context, needScroll) - return context.toString() + + val layoutXml = context.toString() + val stringsXml = generateStringsResourceXml(context) + + return Pair(layoutXml, stringsXml) + } + + private fun generateStringsResourceXml(context: XmlContext): String { + if (context.stringArrays.isEmpty()) return "" + + val builder = StringBuilder() + context.stringArrays.forEach { (name, items) -> + builder.appendLine(" ") + items.forEach { item -> + builder.appendLine(" ${item.escapeXmlAttr()}") + } + builder.appendLine(" ") + } + + return builder.toString().trimEnd() } private fun appendHeaders(context: XmlContext, needScroll: Boolean) { 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 c339625ee9..737e60f4a2 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 @@ -5,6 +5,7 @@ class XmlContext( private val counters: MutableMap = mutableMapOf() ) { private val usedIds = mutableSetOf() + val stringArrays = mutableMapOf>() fun nextId(label: String, initialIndex: Int = 0): String { val safeLabel = label.replace(Regex("[^a-zA-Z0-9_]"), "_") diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt index 73f9567085..b52a81f84e 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionActivity.kt @@ -185,7 +185,7 @@ class ComputerVisionActivity : AppCompatActivity() { Toast.makeText(this, effect.message, Toast.LENGTH_LONG).show() is ComputerVisionEffect.ShowConfirmDialog -> showUpdateConfirmationDialog(effect.fileName) - is ComputerVisionEffect.ReturnXmlResult -> returnXmlResult(effect.xml) + is ComputerVisionEffect.ReturnXmlResult -> returnXmlResult(effect.layoutXml, effect.stringsXml) is ComputerVisionEffect.FileSaved -> saveXmlToFile(effect.fileName) ComputerVisionEffect.NavigateBack -> finish() } @@ -218,9 +218,11 @@ class ComputerVisionActivity : AppCompatActivity() { .show() } - private fun returnXmlResult(xml: String) { + private fun returnXmlResult(layoutXml: String, stringsXml: String) { setResult(RESULT_OK, Intent().apply { - putExtra(RESULT_GENERATED_XML, xml) + putExtra(RESULT_GENERATED_XML, layoutXml) + putExtra(RESULT_GENERATED_STRINGS, stringsXml) + putExtra(EXTRA_LAYOUT_FILE_PATH, intent.getStringExtra(EXTRA_LAYOUT_FILE_PATH)) }) finish() } @@ -276,5 +278,6 @@ class ComputerVisionActivity : AppCompatActivity() { const val EXTRA_LAYOUT_FILE_PATH = "com.example.images.LAYOUT_FILE_PATH" const val EXTRA_LAYOUT_FILE_NAME = "com.example.images.LAYOUT_FILE_NAME" const val RESULT_GENERATED_XML = "ide.uidesigner.generatedXml" + const val RESULT_GENERATED_STRINGS = "ide.uidesigner.generatedStrings" } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt index 03be87f955..6324c4c4f0 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionUiState.kt @@ -47,7 +47,7 @@ sealed class ComputerVisionEffect { data class ShowToast(val messageResId: Int) : ComputerVisionEffect() data class ShowError(val message: String) : ComputerVisionEffect() data class ShowConfirmDialog(val fileName: String) : ComputerVisionEffect() - data class ReturnXmlResult(val xml: String) : ComputerVisionEffect() + data class ReturnXmlResult(val layoutXml: String, val stringsXml: String) : ComputerVisionEffect() data class FileSaved(val fileName: String) : ComputerVisionEffect() object NavigateBack : ComputerVisionEffect() } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt index b41c6fd9d4..207c3463be 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/viewmodel/ComputerVisionViewModel.kt @@ -303,11 +303,11 @@ class ComputerVisionViewModel( _uiState.update { it.copy(currentOperation = CvOperation.GeneratingXml) } generateXml(state) - .onSuccess { xml -> + .onSuccess { (layoutXml, stringsXml) -> CvAnalyticsUtil.trackXmlGenerated(componentCount = state.detections.size) CvAnalyticsUtil.trackXmlExported(toDownloads = false) _uiState.update { it.copy(currentOperation = CvOperation.Idle) } - _uiEffect.send(ComputerVisionEffect.ReturnXmlResult(xml)) + _uiEffect.send(ComputerVisionEffect.ReturnXmlResult(layoutXml, stringsXml)) } .onFailure { exception -> Log.e(TAG, "XML generation failed", exception) @@ -330,11 +330,12 @@ class ComputerVisionViewModel( _uiState.update { it.copy(currentOperation = CvOperation.GeneratingXml) } generateXml(state) - .onSuccess { xml -> + .onSuccess { (layoutXml, stringsXml) -> + val combined = if(stringsXml.isNotBlank()) "$layoutXml\n\n\n" else layoutXml CvAnalyticsUtil.trackXmlGenerated(componentCount = state.detections.size) CvAnalyticsUtil.trackXmlExported(toDownloads = true) _uiState.update { it.copy(currentOperation = CvOperation.SavingFile) } - saveXmlFile(xml) + saveXmlFile(combined) } .onFailure { exception -> Log.e(TAG, "XML generation failed", exception) @@ -344,7 +345,7 @@ class ComputerVisionViewModel( } } - private suspend fun generateXml(state: ComputerVisionUiState): Result { + private suspend fun generateXml(state: ComputerVisionUiState): Result> { val bitmap = state.currentBitmap ?: return Result.failure(IllegalStateException("No bitmap available")) return repository.generateXml( detections = state.detections, diff --git a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/UIDesignerActivity.kt b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/UIDesignerActivity.kt index b3623738b5..daaaffeda2 100644 --- a/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/UIDesignerActivity.kt +++ b/uidesigner/src/main/java/com/itsaky/androidide/uidesigner/UIDesignerActivity.kt @@ -101,6 +101,8 @@ class UIDesignerActivity : BaseIDEActivity() { const val EXTRA_FILE = "layout_file" const val RESULT_GENERATED_XML = "ide.uidesigner.generatedXml" + const val EXTRA_GENERATED_STRINGS = "ide.uidesigner.generatedStrings" + const val EXTRA_LAYOUT_FILE_PATH = "com.example.images.LAYOUT_FILE_PATH" } private fun onXmlGenerated(xml: String) {