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
2 changes: 2 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,6 @@ dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.14.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("androidx.arch.core:core-testing:2.2.0")
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,15 @@ class ContactsTest {
composeRule.onNodeWithText("Back").performClick()
composeRule.onNodeWithTag("scan-screen").assertIsDisplayed()
}

@Test fun contacts_05_importButton_isVisible() {
composeRule.onNodeWithTag("contacts-button").performClick()
composeRule.onNodeWithTag("import-vcard-button").assertIsDisplayed()
}

@Test fun contacts_06_exportButton_visibleAfterImport() {
composeRule.onNodeWithTag("contacts-button").performClick()
composeRule.onNodeWithTag("import-vcard-button").assertIsDisplayed()
composeRule.onNodeWithTag("export-all-contacts-button").assertIsDisplayed()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.cardsnap.domain

import com.cardsnap.domain.model.ContactCard

sealed interface Confidence {
object HIGH : Confidence
object MEDIUM : Confidence
object LOW : Confidence
}

object ContactConfidenceScorer {

fun score(contact: ContactCard, ocrRawText: String): Confidence {
val hasRealName = contact.name.isNotBlank() && looksLikeRealName(contact.name)
val hasEmail = contact.email.isNotBlank()
val hasPhone = contact.phone.isNotBlank()
val hasCompany = contact.company.isNotBlank()
val hasTitle = contact.title.isNotBlank()
val hasMeaningfulField = hasRealName || hasEmail || hasPhone || hasCompany || hasTitle

if (isGarbageOcr(ocrRawText) && !hasMeaningfulField) return Confidence.LOW

if (hasRealName && (hasEmail || hasPhone || hasCompany)) return Confidence.HIGH

if (hasMeaningfulField) return Confidence.MEDIUM

return Confidence.LOW
}

fun isValid(contact: ContactCard, ocrRawText: String): Boolean =
score(contact, ocrRawText) == Confidence.HIGH

internal fun looksLikeRealName(name: String): Boolean {
val trimmed = name.trim()
if (trimmed.length < 2 || trimmed.length > 80) return false

val letterCount = trimmed.count { it.isLetter() }
if (letterCount < 2) return false

val alphaRatio = letterCount.toFloat() / trimmed.length
if (alphaRatio < 0.4f) return false

val words = trimmed.split(Regex("\\s+")).filter { it.isNotBlank() }
if (words.isEmpty()) return false

return true
}

internal fun isGarbageOcr(text: String): Boolean {
val trimmed = text.trim()
if (trimmed.isBlank()) return true

val words = trimmed.split(Regex("\\s+")).filter { it.isNotBlank() }
if (words.size < 2 && words.all { it.length < 3 }) return true

val symbolCount = trimmed.count { !it.isLetterOrDigit() && !it.isWhitespace() }
val symbolRatio = symbolCount.toFloat() / trimmed.length
if (symbolRatio > 0.5f) return true

val letterCount = trimmed.count { it.isLetter() }
if (letterCount < 3) return true

return false
}
}
109 changes: 109 additions & 0 deletions android/app/src/main/java/com/cardsnap/domain/ContactDeduplicator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.cardsnap.domain

import com.cardsnap.domain.model.ContactCard

object ContactDeduplicator {

/**
* Normalized Levenshtein similarity: 1.0 = identical, 0.0 = completely different.
*/
fun normalizedSimilarity(a: String, b: String): Double {
val aStr = a.trim().lowercase()
val bStr = b.trim().lowercase()
if (aStr == bStr) return 1.0
if (aStr.isEmpty() && bStr.isEmpty()) return 1.0
if (aStr.isEmpty() || bStr.isEmpty()) return 0.0
val maxLen = maxOf(aStr.length, bStr.length)
if (maxLen == 0) return 1.0
return 1.0 - levenshteinDistance(aStr, bStr).toDouble() / maxLen
}

/**
* Find exact duplicate pairs where email (non-blank) matches OR phone (non-blank) matches.
* Returns only the first matching pair per duplicate group.
* Only contacts with at least name OR email OR phone non-blank are considered.
*/
fun findExactDuplicates(contacts: List<ContactCard>): List<Pair<ContactCard, ContactCard>> {
val valid = contacts.filter { it.name.isNotBlank() || it.email.isNotBlank() || it.phone.isNotBlank() }
val result = mutableListOf<Pair<ContactCard, ContactCard>>()
val matchedIds = mutableSetOf<String>()

valid.groupBy { it.email.trim().lowercase() }
.filter { it.key.isNotBlank() && it.value.size >= 2 }
.forEach { (_, group) ->
val unmatched = group.filter { it.id !in matchedIds }
if (unmatched.size >= 2) {
result.add(Pair(unmatched[0], unmatched[1]))
matchedIds.add(unmatched[0].id)
matchedIds.add(unmatched[1].id)
}
}

valid.groupBy { it.phone.filter { c -> c.isDigit() } }
.filter { it.key.isNotBlank() && it.value.size >= 2 }
.forEach { (_, group) ->
val unmatched = group.filter { it.id !in matchedIds }
if (unmatched.size >= 2) {
result.add(Pair(unmatched[0], unmatched[1]))
matchedIds.add(unmatched[0].id)
matchedIds.add(unmatched[1].id)
}
}

return result
}

/**
* Find fuzzy duplicate pairs where names have high similarity (> 0.8)
* OR same company + similar name (> 0.5).
* Filters out pairs already found by exact matching.
* Only contacts with at least name OR email OR phone non-blank are considered.
*/
fun findFuzzyDuplicates(contacts: List<ContactCard>): List<Pair<ContactCard, ContactCard>> {
val valid = contacts.filter { it.name.isNotBlank() || it.email.isNotBlank() || it.phone.isNotBlank() }

val exactMatchedIds = findExactDuplicates(contacts)
.flatMap { listOf(it.first.id, it.second.id) }
.toSet()
val result = mutableListOf<Pair<ContactCard, ContactCard>>()
val matchedIds = exactMatchedIds.toMutableSet()

for (i in valid.indices) {
for (j in i + 1 until valid.size) {
val a = valid[i]
val b = valid[j]
if (a.id in matchedIds || b.id in matchedIds) continue

val nameSim = normalizedSimilarity(a.name, b.name)
val sameCompany = a.company.isNotBlank() &&
a.company.equals(b.company, ignoreCase = true)
val sameCompanyAndSimilarName = sameCompany && nameSim > 0.5

if (nameSim > 0.8 || sameCompanyAndSimilarName) {
result.add(Pair(a, b))
matchedIds.add(a.id)
matchedIds.add(b.id)
}
}
}

return result
}

private fun levenshteinDistance(a: String, b: String): Int {
val dp = Array(a.length + 1) { IntArray(b.length + 1) }
for (i in 0..a.length) dp[i][0] = i
for (j in 0..b.length) dp[0][j] = j
for (i in 1..a.length) {
for (j in 1..b.length) {
val cost = if (a[i - 1] == b[j - 1]) 0 else 1
dp[i][j] = minOf(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
)
}
}
return dp[a.length][b.length]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.cardsnap.domain.model

sealed interface ContactsState {
data object Loading : ContactsState

data class Success(val contacts: List<ContactCard>) : ContactsState

data class Error(val message: String) : ContactsState

data object Empty : ContactsState
}
41 changes: 41 additions & 0 deletions android/app/src/main/java/com/cardsnap/domain/model/ScanError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.cardsnap.domain.model

sealed class ScanError(
open val message: String,
open val userFacingMessage: String
) {
data object CameraPermissionDenied : ScanError(
message = "Camera permission was denied by the user",
userFacingMessage = "Camera permission is required to scan business cards. Please grant it in Settings."
)

data class OcrFailed(val cause: String) : ScanError(
message = "OCR processing failed: $cause",
userFacingMessage = "Could not read text from the image. Please try again with better lighting."
)

data class ParserFailed(val cause: String) : ScanError(
message = "Contact parsing failed: $cause",
userFacingMessage = "Could not extract contact details from the scanned text."
)

data object NoCardDetected : ScanError(
message = "No business card was detected in the image",
userFacingMessage = "No business card detected. Make sure the card is centered in the frame."
)

data class ImageProcessingFailed(val cause: String) : ScanError(
message = "Image processing failed: $cause",
userFacingMessage = "Failed to process the image. Please try again."
)

data object SaveFailed : ScanError(
message = "Failed to save the scanned contact",
userFacingMessage = "Could not save the contact. Please check your storage and try again."
)

data object Unknown : ScanError(
message = "An unknown error occurred",
userFacingMessage = "Something went wrong. Please try again."
)
}
19 changes: 19 additions & 0 deletions android/app/src/main/java/com/cardsnap/domain/model/ScanState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.cardsnap.domain.model

sealed interface ScanState {
data object Idle : ScanState

data object Processing : ScanState

data class Results(
val contact: ContactCard,
val extractedText: String,
val imageUri: String
) : ScanState

data class Saved(val contact: ContactCard) : ScanState

data class Error(val error: ScanError) : ScanState

data object Offline : ScanState
}
Loading