From 687fb2522d015f689e7ee354f2238fbd942a4136 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Fri, 24 Apr 2026 13:34:52 -0500 Subject: [PATCH 1/4] feat: automate image import on placeholder tap Adds tap listener and file picker to map selected device images to detected placeholders, copying them to the drawable folder. --- .../repository/ComputerVisionRepository.kt | 1 + .../ComputerVisionRepositoryImpl.kt | 2 + .../data/repository/DrawableImportHelper.kt | 112 ++++++++++++++++++ .../computervision/di/ComputerVisionModule.kt | 8 ++ .../domain/YoloToXmlConverter.kt | 45 +++++-- .../domain/xml/AndroidXmlGenerator.kt | 3 +- .../domain/xml/LayoutRenderer.kt | 3 +- .../ui/ComputerVisionActivity.kt | 21 +++- .../computervision/ui/ComputerVisionEvent.kt | 2 + .../ui/ComputerVisionUiState.kt | 10 +- .../computervision/ui/ZoomableImageView.kt | 37 +++++- .../ui/viewmodel/ComputerVisionViewModel.kt | 91 +++++++++++++- .../computervision/utils/PlaceholderUtils.kt | 16 +++ .../src/main/res/values/strings.xml | 1 + 14 files changed, 333 insertions(+), 19 deletions(-) create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt create mode 100644 cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt 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 3ba2b5991a..714f9aad5a 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 @@ -27,6 +27,7 @@ interface ComputerVisionRepository { suspend fun generateXml( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map, sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, 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 705d8882b0..6d8c3b2290 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 @@ -56,6 +56,7 @@ class ComputerVisionRepositoryImpl( override suspend fun generateXml( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map, sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, @@ -65,6 +66,7 @@ class ComputerVisionRepositoryImpl( YoloToXmlConverter.generateXmlLayout( detections = detections, annotations = annotations, + selectedImagesByPlaceholderId = selectedImagesByPlaceholderId, sourceImageWidth = sourceImageWidth, sourceImageHeight = sourceImageHeight, targetDpWidth = targetDpWidth, diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt new file mode 100644 index 0000000000..fd78361327 --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt @@ -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 = 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 +) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt index bc0583d16d..65842dfdb0 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/di/ComputerVisionModule.kt @@ -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 @@ -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 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 00f48854a7..7eff47b579 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,8 +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.MetadataDetector +import org.appdevforall.codeonthego.computervision.utils.getSortedScaledPlaceholders import kotlin.comparisons.compareBy class YoloToXmlConverter( @@ -14,6 +16,7 @@ class YoloToXmlConverter( fun generateXmlLayout( detections: List, annotations: Map, + selectedImagesByPlaceholderId: Map, sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, @@ -50,13 +53,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, + selectedImagesByPlaceholderId: Map + ): Map { + 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, annotations: Map, + selectedImagesByPlaceholderId: Map = emptyMap(), sourceImageWidth: Int, sourceImageHeight: Int, targetDpWidth: Int, @@ -70,13 +98,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 ) } } 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 38d131a75e..1546e2d03d 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 @@ -9,6 +9,7 @@ class AndroidXmlGenerator( internal fun buildXml( boxes: List, annotations: Map, + selectedImageOverrides: Map, targetDpHeight: Int, wrapInScroll: Boolean ): Pair { @@ -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, " ") } 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 285a51d158..d9ebf0a639 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 @@ -6,8 +6,9 @@ import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox class LayoutRenderer( private val context: XmlContext, annotations: Map + private val selectedImageOverrides: Map = emptyMap() ) { - private val widgetFactory = WidgetFactory(context, annotations) + private val widgetFactory = WidgetFactory(context, annotations, selectedImageOverrides) fun render(item: LayoutItem, indent: String = " ") { val widgets = widgetFactory.createWidgets(item) 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 b52a81f84e..ec448cb673 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 @@ -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) @@ -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() { @@ -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 @@ -188,6 +205,8 @@ class ComputerVisionActivity : AppCompatActivity() { is ComputerVisionEffect.ReturnXmlResult -> returnXmlResult(effect.layoutXml, effect.stringsXml) is ComputerVisionEffect.FileSaved -> saveXmlToFile(effect.fileName) ComputerVisionEffect.NavigateBack -> finish() + ComputerVisionEffect.OpenPlaceholderImagePicker -> + pickPlaceholderImageLauncher.launch("image/*") } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt index dbac173ae9..fd52311408 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ComputerVisionEvent.kt @@ -12,4 +12,6 @@ sealed class ComputerVisionEvent { object OpenImagePicker : ComputerVisionEvent() object RequestCameraPermission : ComputerVisionEvent() data class UpdateGuides(val leftPct: Float, val rightPct: Float) : ComputerVisionEvent() + data class ImagePlaceholderTapped(val imageX: Float, val imageY: Float) : ComputerVisionEvent() + data class PlaceholderImageSelected(val uri: Uri) : ComputerVisionEvent() } 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 6324c4c4f0..69f010b23d 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 @@ -15,7 +15,9 @@ data class ComputerVisionUiState( val currentOperation: CvOperation = CvOperation.Idle, val leftGuidePct: Float = 0.2f, val rightGuidePct: Float = 0.8f, - val parsedAnnotations: Map = emptyMap() // Replaced old marginAnnotations + val parsedAnnotations: Map = emptyMap(), // Replaced old marginAnnotations + val pendingImagePlaceholderId: String? = null, + val selectedImagesByPlaceholderId: Map = emptyMap() ) { val hasImage: Boolean get() = currentBitmap != null @@ -50,4 +52,10 @@ sealed class ComputerVisionEffect { data class ReturnXmlResult(val layoutXml: String, val stringsXml: String) : ComputerVisionEffect() data class FileSaved(val fileName: String) : ComputerVisionEffect() object NavigateBack : ComputerVisionEffect() + object OpenPlaceholderImagePicker : ComputerVisionEffect() } + +data class SelectedImportedImage( + val resourceName: String, + val drawableReference: String +) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt index b99f12707a..0f1b0aab69 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/ui/ZoomableImageView.kt @@ -9,6 +9,7 @@ import android.util.AttributeSet import android.view.MotionEvent import android.view.ScaleGestureDetector import androidx.appcompat.widget.AppCompatImageView +import kotlin.math.abs class ZoomableImageView @JvmOverloads constructor( context: Context, @@ -26,6 +27,7 @@ class ZoomableImageView @JvmOverloads constructor( private var mode = NONE var onMatrixChangeListener: ((Matrix) -> Unit)? = null + var onImageTapListener: ((Float, Float) -> Boolean)? = null init { super.setClickable(true) @@ -54,10 +56,17 @@ class ZoomableImageView @JvmOverloads constructor( } MotionEvent.ACTION_UP -> { mode = NONE - val xDiff = Math.abs(curr.x - start.x).toInt() - val yDiff = Math.abs(curr.y - start.y).toInt() + val xDiff = abs(curr.x - start.x).toInt() + val yDiff = abs(curr.y - start.y).toInt() if (xDiff < CLICK && yDiff < CLICK) { - performClick() + val mappedPoint = mapViewPointToImage(event.x, event.y) + val consumed = mappedPoint?.let { + onImageTapListener?.invoke(it.x, it.y) + } ?: false + + if (!consumed) { + performClick() + } } } MotionEvent.ACTION_POINTER_UP -> mode = NONE @@ -67,6 +76,28 @@ class ZoomableImageView @JvmOverloads constructor( return true } + fun mapViewPointToImage(x: Float, y: Float): PointF? { + val drawable = drawable ?: return null + val points = floatArrayOf(x, y) + val inverseMatrix = Matrix() + + if (!currentMatrix.invert(inverseMatrix)) return null + inverseMatrix.mapPoints(points) + + val imageX = points[0] + val imageY = points[1] + + return PointF(imageX, imageY).takeIf { + it.x in 0f..drawable.intrinsicWidth.toFloat() && + it.y in 0f..drawable.intrinsicHeight.toFloat() + } + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + override fun setImageDrawable(drawable: Drawable?) { super.setImageDrawable(drawable) fitToScreen() 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 207c3463be..4489743318 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 @@ -25,11 +25,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.appdevforall.codeonthego.computervision.R +import org.appdevforall.codeonthego.computervision.data.repository.DrawableImportHelper +import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.ui.SelectedImportedImage import org.appdevforall.codeonthego.computervision.utils.CvAnalyticsUtil import org.appdevforall.codeonthego.computervision.utils.SmartBoundaryDetector +import org.appdevforall.codeonthego.computervision.utils.getSortedPlaceholders class ComputerVisionViewModel( private val repository: ComputerVisionRepository, + private val drawableImportHelper: DrawableImportHelper, private val contentResolver: ContentResolver, layoutFilePath: String?, layoutFileName: String? @@ -78,6 +83,13 @@ class ComputerVisionViewModel( ) } } + is ComputerVisionEvent.ImagePlaceholderTapped -> { + handleImagePlaceholderTap(event.imageX, event.imageY) + } + + is ComputerVisionEvent.PlaceholderImageSelected -> { + handlePlaceholderImageSelected(event.uri) + } } } @@ -137,7 +149,9 @@ class ComputerVisionViewModel( visualizedBitmap = null, leftGuidePct = leftPct, rightGuidePct = rightPct, - parsedAnnotations = emptyMap() // Reset on new image + parsedAnnotations = emptyMap(), // Reset on new image + pendingImagePlaceholderId = null, + selectedImagesByPlaceholderId = emptyMap() ) } } catch (e: Exception) { @@ -166,7 +180,7 @@ class ComputerVisionViewModel( ExifInterface.ORIENTATION_NORMAL ) } ?: ExifInterface.ORIENTATION_NORMAL - } catch (e: Exception) { + } catch (_: Exception) { ExifInterface.ORIENTATION_NORMAL } @@ -187,7 +201,7 @@ class ComputerVisionViewModel( bitmap.recycle() } rotatedBitmap - } catch (e: OutOfMemoryError) { + } catch (_: OutOfMemoryError) { bitmap } } @@ -264,7 +278,9 @@ class ComputerVisionViewModel( it.copy( detections = canvasDetections, parsedAnnotations = annotationMap, - currentOperation = CvOperation.Idle + currentOperation = CvOperation.Idle, + pendingImagePlaceholderId = null, + selectedImagesByPlaceholderId = emptyMap(), ) } } @@ -350,6 +366,8 @@ class ComputerVisionViewModel( return repository.generateXml( detections = state.detections, annotations = state.parsedAnnotations, + selectedImagesByPlaceholderId = state.selectedImagesByPlaceholderId + .mapValues { it.value.drawableReference }, sourceImageWidth = bitmap.width, sourceImageHeight = bitmap.height, targetDpWidth = TARGET_DP_WIDTH, @@ -374,6 +392,71 @@ class ComputerVisionViewModel( } } + private fun handleImagePlaceholderTap(imageX: Float, imageY: Float) { + val placeholder = findImagePlaceholderAt(imageX, imageY) ?: return + val placeholderId = resolvePlaceholderId(placeholder) + + _uiState.update { it.copy(pendingImagePlaceholderId = placeholderId) } + viewModelScope.launch { + _uiEffect.send(ComputerVisionEffect.OpenPlaceholderImagePicker) + } + } + + private fun handlePlaceholderImageSelected(uri: Uri) { + val state = _uiState.value + val placeholderId = state.pendingImagePlaceholderId ?: return + + viewModelScope.launch { + drawableImportHelper.importDrawable( + sourceUri = uri, + layoutFilePath = state.layoutFilePath, + fallbackName = "imported_image_$placeholderId" + ).onSuccess { importedDrawable -> + _uiState.update { currentState -> + currentState.copy( + pendingImagePlaceholderId = null, + selectedImagesByPlaceholderId = currentState.selectedImagesByPlaceholderId + + ( + placeholderId to SelectedImportedImage( + resourceName = importedDrawable.resourceName, + drawableReference = importedDrawable.drawableReference + ) + ) + ) + } + + _uiEffect.send( + ComputerVisionEffect.ShowToast(R.string.msg_placeholder_image_selected) + ) + }.onFailure { exception -> + _uiState.update { + it.copy(pendingImagePlaceholderId = null) + } + + _uiEffect.send( + ComputerVisionEffect.ShowError( + "Image import failed: ${exception.message}" + ) + ) + } + } + } + + private fun resolvePlaceholderId(detection: DetectionResult): String { + val index = _uiState.value.detections.getSortedPlaceholders().indexOf(detection) + return "ph_${index.coerceAtLeast(0)}" + } + + fun isImagePlaceholderAt(imageX: Float, imageY: Float): Boolean { + return findImagePlaceholderAt(imageX, imageY) != null + } + + private fun findImagePlaceholderAt(imageX: Float, imageY: Float): DetectionResult? { + return _uiState.value.detections + .getSortedPlaceholders() + .firstOrNull { it.boundingBox.contains(imageX, imageY) } + } + override fun onCleared() { super.onCleared() repository.releaseResources() diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt new file mode 100644 index 0000000000..2f0d18949b --- /dev/null +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/PlaceholderUtils.kt @@ -0,0 +1,16 @@ +package org.appdevforall.codeonthego.computervision.utils + +import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox + +const val IMAGE_PLACEHOLDER_LABEL = "image_placeholder" + +fun List.getSortedPlaceholders(): List { + return this.filter { it.label == IMAGE_PLACEHOLDER_LABEL } + .sortedWith(compareBy({ it.boundingBox.top }, { it.boundingBox.left })) +} + +fun List.getSortedScaledPlaceholders(): List { + return this.filter { it.label == IMAGE_PLACEHOLDER_LABEL } + .sortedWith(compareBy({ it.y }, { it.x })) +} diff --git a/cv-image-to-xml/src/main/res/values/strings.xml b/cv-image-to-xml/src/main/res/values/strings.xml index d61a78061e..e1f2f0ec1d 100644 --- a/cv-image-to-xml/src/main/res/values/strings.xml +++ b/cv-image-to-xml/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Saved to Downloads/%s Error saving file: %s Share XML Layout + Image selected for placeholder. layout Image placeholder for object detection From 172500a3cafaec0edb38046a0ca5b4d0e7d9d647 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 29 Apr 2026 09:35:19 -0500 Subject: [PATCH 2/4] refactor: use AttributeKey xmlName for background attrs --- .../computervision/domain/grammar/WidgetGrammar.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 fff8c7a0da..af938cf507 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 @@ -63,7 +63,9 @@ object ImageViewGrammar : LayoutGrammar { override val attributes = super.attributes + mapOf( AttributeKey.SRC.xmlName to PassThroughValidator, - AttributeKey.LAYOUT_GRAVITY.xmlName to CategoricalValidator(gravityValues) + AttributeKey.LAYOUT_GRAVITY.xmlName to CategoricalValidator(gravityValues), + AttributeKey.BACKGROUND.xmlName to PassThroughValidator, + AttributeKey.BACKGROUND_TINT.xmlName to PassThroughValidator ) } From 071582eb66e39a3f827871a69487acb562f89bc1 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 29 Apr 2026 14:22:54 -0500 Subject: [PATCH 3/4] fix: avoid TOCTOU race condition and refactor extension detection --- .../data/repository/DrawableImportHelper.kt | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt index fd78361327..a48a0849dd 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/repository/DrawableImportHelper.kt @@ -26,7 +26,7 @@ class DrawableImportHelper( "Could not create drawable directory: ${drawableDir.absolutePath}" } - val extension = resolveSupportedExtension(sourceUri) + val extension = resolveSupportedExtension(sourceUri, fallbackName) val baseName = sanitizeResourceName(resolveDisplayName(sourceUri) ?: fallbackName) val destinationFile = resolveAvailableFile(drawableDir, baseName, extension) @@ -58,12 +58,22 @@ class DrawableImportHelper( } } - private fun resolveSupportedExtension(uri: Uri): String { - val displayName = resolveDisplayName(uri) - val extension = displayName - ?.substringAfterLast('.', missingDelimiterValue = "") - ?.lowercase(Locale.US) - .orEmpty() + private fun resolveSupportedExtension(uri: Uri, fallbackName: String): String { + val mimeType = contentResolver.getType(uri)?.lowercase(Locale.US) + var extension = when (mimeType) { + "image/png" -> "png" + "image/jpeg", "image/jpg" -> "jpg" + "image/webp" -> "webp" + else -> null + } + + if (extension == null) { + val nameToUse = resolveDisplayName(uri) ?: fallbackName + extension = nameToUse + .substringAfterLast('.', missingDelimiterValue = "") + .lowercase(Locale.US) + .takeIf { it.isNotBlank() } + } return when (extension) { "png", "jpg", "jpeg", "webp" -> extension @@ -96,7 +106,7 @@ class DrawableImportHelper( var candidate = File(drawableDir, "$baseName.$extension") var index = 1 - while (candidate.exists()) { + while (!candidate.createNewFile()) { candidate = File(drawableDir, "${baseName}_$index.$extension") index++ } From d37786617d8590357ffa2324c00ec57cbfe729ab Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 5 May 2026 12:20:26 -0500 Subject: [PATCH 4/4] fix: add support for `selectedImageOverrides` in `LayoutRenderer` and `WidgetFactory` --- .../computervision/domain/xml/LayoutRenderer.kt | 4 ++-- .../computervision/domain/xml/WidgetFactory.kt | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) 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 d9ebf0a639..35b558c441 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 @@ -5,8 +5,8 @@ import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox class LayoutRenderer( private val context: XmlContext, - annotations: Map - private val selectedImageOverrides: Map = emptyMap() + annotations: Map, + selectedImageOverrides: Map = emptyMap() ) { private val widgetFactory = WidgetFactory(context, annotations, selectedImageOverrides) diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt index 5e85c6d945..02ef7c5cd0 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/WidgetFactory.kt @@ -6,7 +6,8 @@ import org.appdevforall.codeonthego.computervision.domain.FuzzyAttributeParser class WidgetFactory( private val context: XmlContext, - private val annotations: Map + private val annotations: Map, + private val selectedImageOverrides: Map = emptyMap() ) { private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$") private val radioChildGroupIdPatterns = listOf( @@ -109,7 +110,13 @@ class WidgetFactory( parsedAttrsOverride: Map? = null ): AndroidWidget { val tag = AndroidWidget.getTagFor(box.label) - val parsedAttrs = parsedAttrsOverride ?: FuzzyAttributeParser.parse(annotations[box], tag) + val parsedAttrs = parsedAttrsOverride?.toMutableMap() + ?: FuzzyAttributeParser.parse(annotations[box], tag).toMutableMap() + + selectedImageOverrides[box]?.let { drawableReference -> + parsedAttrs["android:src"] = drawableReference + } + return AndroidWidget.create(box, parsedAttrs).apply { this.idOverride = idOverride this.extraAttrs = extraAttrs