Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>
) : SuspendCommand<Unit> {

companion object {
private val log = LoggerFactory.getLogger(AddStringArrayResourceCommand::class.java)
private val fileMutexes = ConcurrentHashMap<String, Mutex>()
}

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>): 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 <resources> 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')
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand Down Expand Up @@ -87,4 +83,4 @@ class AddStringResourceCommand(
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.itsaky.androidide.api.commands

import com.itsaky.androidide.agent.model.ToolResult

interface SuspendCommand<T> {
suspend fun execute(): ToolResult
}
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
jatezzz marked this conversation as resolved.

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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
}
Loading
Loading