Skip to content
Open
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 @@ -27,6 +27,7 @@ interface ComputerVisionRepository {
suspend fun generateXml(
detections: List<DetectionResult>,
annotations: Map<String, String>,
selectedImagesByPlaceholderId: Map<String, String>,
sourceImageWidth: Int,
sourceImageHeight: Int,
targetDpWidth: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ComputerVisionRepositoryImpl(
override suspend fun generateXml(
detections: List<DetectionResult>,
annotations: Map<String, String>,
selectedImagesByPlaceholderId: Map<String, String>,
sourceImageWidth: Int,
sourceImageHeight: Int,
targetDpWidth: Int,
Expand All @@ -65,6 +66,7 @@ class ComputerVisionRepositoryImpl(
YoloToXmlConverter.generateXmlLayout(
detections = detections,
annotations = annotations,
selectedImagesByPlaceholderId = selectedImagesByPlaceholderId,
sourceImageWidth = sourceImageWidth,
sourceImageHeight = sourceImageHeight,
targetDpWidth = targetDpWidth,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package org.appdevforall.codeonthego.computervision.data.repository

import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Locale

class DrawableImportHelper(
private val contentResolver: ContentResolver
) {

suspend fun importDrawable(
sourceUri: Uri,
layoutFilePath: String?,
fallbackName: String
): Result<ImportedDrawable> = withContext(Dispatchers.IO) {
runCatching {
requireNotNull(layoutFilePath) { "Layout file path is not available." }
val layoutFile = File(layoutFilePath)

val drawableDir = resolveDrawableDir(layoutFile)
check(drawableDir.exists() || drawableDir.mkdirs()) {
"Could not create drawable directory: ${drawableDir.absolutePath}"
}

val extension = resolveSupportedExtension(sourceUri)
val baseName = sanitizeResourceName(resolveDisplayName(sourceUri) ?: fallbackName)
val destinationFile = resolveAvailableFile(drawableDir, baseName, extension)

contentResolver.openInputStream(sourceUri)?.use { input ->
destinationFile.outputStream().use(input::copyTo)
} ?: error("Could not open selected image.")

ImportedDrawable(
resourceName = destinationFile.nameWithoutExtension,
drawableReference = "@drawable/${destinationFile.nameWithoutExtension}",
file = destinationFile
)
}
}

private fun resolveDrawableDir(layoutFile: File): File {
val resDir = generateSequence(layoutFile.parentFile) { it.parentFile }
.firstOrNull { it.name == "res" }
?: throw IllegalStateException("Could not resolve res directory from: ${layoutFile.absolutePath}")

return File(resDir, "drawable")
}

private fun resolveDisplayName(uri: Uri): String? {
return contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null
}
}

private fun resolveSupportedExtension(uri: Uri): String {
val displayName = resolveDisplayName(uri)
val extension = displayName
?.substringAfterLast('.', missingDelimiterValue = "")
?.lowercase(Locale.US)
.orEmpty()

return when (extension) {
"png", "jpg", "jpeg", "webp" -> extension
else -> throw IllegalArgumentException("Unsupported image format. Use PNG, JPG, JPEG, or WEBP.")
}
}

private fun sanitizeResourceName(rawName: String): String {
val nameWithoutExtension = rawName.substringBeforeLast('.')
val normalized = nameWithoutExtension
.lowercase(Locale.US)
.replace(Regex("[^a-z0-9_]"), "_")
.replace(Regex("_+"), "_")
.trim('_')

val safeName = normalized.ifBlank { "imported_image" }

return if (safeName.first().isDigit()) {
"img_$safeName"
} else {
safeName
}
}

private fun resolveAvailableFile(
drawableDir: File,
baseName: String,
extension: String
): File {
var candidate = File(drawableDir, "$baseName.$extension")
var index = 1

while (candidate.exists()) {
candidate = File(drawableDir, "${baseName}_$index.$extension")
index++
}

return candidate
}
}

data class ImportedDrawable(
val resourceName: String,
val drawableReference: String,
val file: File
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.appdevforall.codeonthego.computervision.di

import org.appdevforall.codeonthego.computervision.data.repository.ComputerVisionRepository
import org.appdevforall.codeonthego.computervision.data.repository.ComputerVisionRepositoryImpl
import org.appdevforall.codeonthego.computervision.data.repository.DrawableImportHelper
import org.appdevforall.codeonthego.computervision.data.source.OcrSource
import org.appdevforall.codeonthego.computervision.data.source.YoloModelSource
import org.appdevforall.codeonthego.computervision.domain.RegionOcrProcessor
Expand All @@ -26,9 +27,16 @@ val computerVisionModule = module {
)
}

single {
DrawableImportHelper(
contentResolver = androidContext().contentResolver
)
}

viewModel { (layoutFilePath: String?, layoutFileName: String?) ->
ComputerVisionViewModel(
repository = get(),
drawableImportHelper = get(),
contentResolver = androidContext().contentResolver,
layoutFilePath = layoutFilePath,
layoutFileName = layoutFileName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,7 @@ object MarginAnnotationParser {

for (block in explicitBlocks) {
val tag = block.tag ?: continue
if (canvasTags.isEmpty() || canvasTags.any { (canvasTag, _) -> canvasTag == tag }) {
annotationMap[tag] = block.annotationText
}
annotationMap[tag] = block.annotationText
}

if (canvasTags.isEmpty()) return annotationMap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox
class WidgetAnnotationMatcher {
companion object {
private val TAG_REGEX = Regex("^(?i)(B|P|D|T|C|R|SW|S)-\\d+$")
private val TAG_EXTRACT_REGEX = Regex("^(?i)(SW|S\\s*8|8\\s*W|[BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([\\dlIoO!]+)$")
private val TAG_EXTRACT_REGEX = Regex("^(?i)(SW|S\\s*8|8\\s*W|[BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([\\dlIoO!]+)(?:\\s+(.+))?$")
}

internal fun matchAnnotationsToElements(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.appdevforall.codeonthego.computervision.domain

import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult
import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox
import org.appdevforall.codeonthego.computervision.domain.xml.AndroidXmlGenerator
import org.appdevforall.codeonthego.computervision.utils.TextCleaner
import org.appdevforall.codeonthego.computervision.utils.getSortedScaledPlaceholders
import kotlin.comparisons.compareBy

class YoloToXmlConverter(
Expand All @@ -13,6 +16,7 @@ class YoloToXmlConverter(
fun generateXmlLayout(
detections: List<DetectionResult>,
annotations: Map<String, String>,
selectedImagesByPlaceholderId: Map<String, String>,
sourceImageWidth: Int,
sourceImageHeight: Int,
targetDpWidth: Int,
Expand All @@ -21,6 +25,7 @@ class YoloToXmlConverter(
): String {
val uiCandidates = detections
.filter { (it.isYolo || it.label == "text") && it.label != "widget_tag" }
.filter { it.isYolo || !TextCleaner.isCanvasMetadata(it.text) }
.distinctBy {
if (it.label.startsWith("switch")) {
"${((it.boundingBox.top + it.boundingBox.bottom) / 2f).toInt() / 50}"
Expand All @@ -46,13 +51,38 @@ class YoloToXmlConverter(

val sortedBoxes = uiElements.sortedWith(compareBy({ it.y }, { it.x }))

return xmlGenerator.buildXml(sortedBoxes, finalAnnotations, targetDpHeight, wrapInScroll)
val selectedImageOverrides = buildSelectedImageOverrides(
uiElements = uiElements,
selectedImagesByPlaceholderId = selectedImagesByPlaceholderId
)

return xmlGenerator.buildXml(
boxes = sortedBoxes,
annotations = finalAnnotations,
selectedImageOverrides = selectedImageOverrides,
targetDpHeight = targetDpHeight,
wrapInScroll = wrapInScroll
)
}

private fun buildSelectedImageOverrides(
uiElements: List<ScaledBox>,
selectedImagesByPlaceholderId: Map<String, String>
): Map<ScaledBox, String> {
val placeholders = uiElements.getSortedScaledPlaceholders()

return placeholders.mapIndexedNotNull { index, box ->
val drawableReference = selectedImagesByPlaceholderId["ph_$index"]
?: return@mapIndexedNotNull null
box to drawableReference
}.toMap()
}

companion object {
fun generateXmlLayout(
detections: List<DetectionResult>,
annotations: Map<String, String>,
selectedImagesByPlaceholderId: Map<String, String> = emptyMap(),
sourceImageWidth: Int,
sourceImageHeight: Int,
targetDpWidth: Int,
Expand All @@ -66,13 +96,14 @@ class YoloToXmlConverter(
val converter = YoloToXmlConverter(geometry, matcher, generator)

return converter.generateXmlLayout(
detections,
annotations,
sourceImageWidth,
sourceImageHeight,
targetDpWidth,
targetDpHeight,
wrapInScroll
detections = detections,
annotations = annotations,
selectedImagesByPlaceholderId = selectedImagesByPlaceholderId,
sourceImageWidth = sourceImageWidth,
sourceImageHeight = sourceImageHeight,
targetDpWidth = targetDpWidth,
targetDpHeight = targetDpHeight,
wrapInScroll = wrapInScroll
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ object EntriesValidator : AttributeValidator {
val trimmed = rawValue.trim()
if (trimmed.startsWith("@")) return trimmed

val hasBrackets = isEnclosedInBrackets(trimmed)
val content = trimmed.removeSurrounding("[", "]")
val content = trimmed
.replace(Regex("^[({\\[<]"), "")
.replace(Regex("[)}\\]>]$"), "")
.trim()

val rawItems = content.split(",")

Expand All @@ -74,11 +76,7 @@ object EntriesValidator : AttributeValidator {
}

val finalString = cleanedItems.joinToString(", ")
return if (hasBrackets) "[$finalString]" else finalString
}

private fun isEnclosedInBrackets(text: String): Boolean {
return text.startsWith("[") && text.endsWith("]")
return "[$finalString]"
}

private fun isEntireArrayLikelyNumeric(items: List<String>): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ object ImageViewGrammar : WidgetGrammar {
"android:layout_height" to DimensionValidator,
"android:id" to PassThroughValidator,
"android:src" to PassThroughValidator,
"android:layout_gravity" to CategoricalValidator(gravityValues)
"android:layout_gravity" to CategoricalValidator(gravityValues),
"android:background" to PassThroughValidator,
"app:backgroundTint" to PassThroughValidator
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class AndroidXmlGenerator(
internal fun buildXml(
boxes: List<ScaledBox>,
annotations: Map<ScaledBox, String>,
selectedImageOverrides: Map<ScaledBox, String>,
targetDpHeight: Int,
wrapInScroll: Boolean
): String {
Expand All @@ -19,7 +20,7 @@ class AndroidXmlGenerator(
appendHeaders(context, needScroll)

val layoutItems = geometryProcessor.buildLayoutTree(boxes)
val renderer = LayoutRenderer(context, annotations)
val renderer = LayoutRenderer(context, annotations, selectedImageOverrides = selectedImageOverrides)

layoutItems.forEach { item ->
renderer.render(item, " ")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser

class LayoutRenderer(
private val context: XmlContext,
private val annotations: Map<ScaledBox, String>
private val annotations: Map<ScaledBox, String>,
private val selectedImageOverrides: Map<ScaledBox, String> = emptyMap()
) {
private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$")
private val radioChildGroupIdPatterns = listOf(
Expand All @@ -33,8 +34,13 @@ class LayoutRenderer(
) {
val tag = AndroidWidget.getTagFor(box.label)
val parsedAttrs = parsedAttrsOverride ?: FuzzyAttributeParser.parse(annotations[box], tag)
val finalParsedAttrs = parsedAttrs.toMutableMap()

val widget = AndroidWidget.create(box, parsedAttrs)
selectedImageOverrides[box]?.let { drawableReference ->
finalParsedAttrs["android:src"] = drawableReference
}

val widget = AndroidWidget.create(box, finalParsedAttrs)
widget.render(context, indent, extraAttrs, idOverride)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ class ComputerVisionActivity : AppCompatActivity() {
else Toast.makeText(this, R.string.msg_camera_permission_required, Toast.LENGTH_LONG).show()
}

private val pickPlaceholderImageLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let {
viewModel.onEvent(ComputerVisionEvent.PlaceholderImageSelected(it))
} ?: Toast.makeText(this, R.string.msg_no_image_selected, Toast.LENGTH_SHORT).show()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityComputerVisionBinding.inflate(layoutInflater)
Expand Down Expand Up @@ -118,6 +125,17 @@ class ComputerVisionActivity : AppCompatActivity() {
binding.saveButton.setOnClickListener {
viewModel.onEvent(ComputerVisionEvent.SaveToDownloads)
}
binding.imageView.onImageTapListener = imageTap@{ imageX, imageY ->
if (!viewModel.isImagePlaceholderAt(imageX, imageY)) return@imageTap false

viewModel.onEvent(
ComputerVisionEvent.ImagePlaceholderTapped(
imageX = imageX,
imageY = imageY
)
)
true
}
}

private fun observeViewModel() {
Expand Down Expand Up @@ -164,7 +182,6 @@ class ComputerVisionActivity : AppCompatActivity() {
}
binding.guidelinesView.updateGuidelines(state.leftGuidePct, state.rightGuidePct)

val isIdle = state.currentOperation == CvOperation.Idle
binding.detectButton.isEnabled = state.canRunDetection
binding.updateButton.isEnabled = state.canGenerateXml
binding.saveButton.isEnabled = state.canGenerateXml
Expand All @@ -188,6 +205,8 @@ class ComputerVisionActivity : AppCompatActivity() {
is ComputerVisionEffect.ReturnXmlResult -> returnXmlResult(effect.xml)
is ComputerVisionEffect.FileSaved -> saveXmlToFile(effect.fileName)
ComputerVisionEffect.NavigateBack -> finish()
ComputerVisionEffect.OpenPlaceholderImagePicker ->
pickPlaceholderImageLauncher.launch("image/*")
}
}

Expand Down
Loading
Loading