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 @@ -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,122 @@
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, fallbackName)
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, 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
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.createNewFile()) {
candidate = File(drawableDir, "${baseName}_$index.$extension")
index++
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
@@ -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(
Expand All @@ -14,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 Down Expand Up @@ -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<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 @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

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
): Pair<String, 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 @@ -5,9 +5,10 @@ import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox

class LayoutRenderer(
private val context: XmlContext,
annotations: Map<ScaledBox, String>
annotations: Map<ScaledBox, String>,
selectedImageOverrides: Map<ScaledBox, String> = 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)
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 WidgetFactory(
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 Down Expand Up @@ -109,7 +110,13 @@ class WidgetFactory(
parsedAttrsOverride: Map<String, String>? = 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
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.layoutXml, effect.stringsXml)
is ComputerVisionEffect.FileSaved -> saveXmlToFile(effect.fileName)
ComputerVisionEffect.NavigateBack -> finish()
ComputerVisionEffect.OpenPlaceholderImagePicker ->
pickPlaceholderImageLauncher.launch("image/*")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> = emptyMap() // Replaced old marginAnnotations
val parsedAnnotations: Map<String, String> = emptyMap(), // Replaced old marginAnnotations
val pendingImagePlaceholderId: String? = null,
val selectedImagesByPlaceholderId: Map<String, SelectedImportedImage> = emptyMap()
) {
val hasImage: Boolean
get() = currentBitmap != null
Expand Down Expand Up @@ -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
)
Loading
Loading