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
79 changes: 79 additions & 0 deletions .github/workflows/play-store-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Deploy to Google Play Store

on:
workflow_dispatch:
inputs:
track:
description: 'Release track'
type: choice
options:
- internal
- alpha
- beta
- production
default: 'internal'
versionName:
description: 'Version name (e.g., 1.0.1)'
required: true
versionCode:
description: 'Version code (integer, must be higher than previous)'
required: true

permissions:
contents: read

jobs:
deploy:
name: Build and Deploy to Play Store
runs-on: ubuntu-latest
env:
JAVA_VERSION: 17
APPLICATION_ID: com.cardsnap

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Setup Java
uses: actions/setup-java@v5
with:
java-version: ${{ env.JAVA_VERSION }}
distribution: temurin
cache: gradle

- name: Setup Android SDK
uses: android-actions/setup-android@v3

- name: Grant execute permission for gradlew
run: chmod +x android/gradlew

- name: Decode keystore
run: |
echo "${{ secrets.PLAY_STORE_KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
echo "keystore decoded"

- name: Update version in build.gradle.kts
run: |
sed -i "s/versionCode = .*/versionCode = ${{ github.event.inputs.versionCode }}/" android/app/build.gradle.kts
sed -i "s/versionName = \".*\"/versionName = \"${{ github.event.inputs.versionName }}\"/" android/app/build.gradle.kts

- name: Build Release AAB
run: |
cd android
./gradlew bundleRelease --no-daemon \
-Pandroid.injected.signing.store.file=../app/release.keystore \
-Pandroid.injected.signing.store.password=${{ secrets.PLAY_STORE_KEYSTORE_PASSWORD }} \
-Pandroid.injected.signing.key.alias=${{ secrets.PLAY_STORE_KEY_ALIAS }} \
-Pandroid.injected.signing.key.password=${{ secrets.PLAY_STORE_KEY_PASSWORD }}

- name: Upload AAB to Google Play
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
packageName: ${{ env.APPLICATION_ID }}
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
track: ${{ github.event.inputs.track }}
status: completed
skipUploadApks: true
skipUploadAab: false
mappingFile: android/app/build/outputs/mapping/release/mapping.txt
12 changes: 12 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,23 @@ android {
}
}

signingConfigs {
create("release") {
storeFile = file("release.keystore")
storePassword = System.getenv("PLAY_STORE_KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("PLAY_STORE_KEY_ALIAS") ?: ""
keyPassword = System.getenv("PLAY_STORE_KEY_PASSWORD") ?: ""
}
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}

Expand Down Expand Up @@ -116,4 +126,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
Loading