feat(data): ContactRepository ImageStore et import anti-doublons

EBO <eric.bouhana@softalys.com> committé le 2026-07-22 15:26

4e5d7d6e23f8d3c7c64b66aabe1be0668cf483c1

1 parent(s)

5 fichiers modifiés +282 -0
A android/app/src/main/java/fr/ebii/card2vcf/crm/ContactNormalizer.kt
+6 -0
@@ -0,0 +1,6 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +object ContactNormalizer {
4 + fun email(raw: String): String = raw.trim().lowercase()
5 + fun phone(raw: String): String = raw.filter { it.isDigit() }
6 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ContactImageStore.kt
+30 -0
@@ -0,0 +1,30 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import android.graphics.Bitmap
5 +import java.io.File
6 +import java.io.FileOutputStream
7 +
8 +class ContactImageStore(private val context: Context) {
9 + private fun dir(id: Long): File =
10 + File(context.filesDir, "contacts/$id").also { it.mkdirs() }
11 +
12 + fun saveJpeg(contactId: Long, name: String, bitmap: Bitmap, quality: Int = 90): String {
13 + val file = File(dir(contactId), name)
14 + FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }
15 + return file.absolutePath
16 + }
17 +
18 + fun deleteAll(contactId: Long) {
19 + dir(contactId).deleteRecursively()
20 + }
21 +
22 + fun copyImage(fromPath: String?, toContactId: Long, name: String): String? {
23 + if (fromPath.isNullOrBlank()) return null
24 + val src = File(fromPath)
25 + if (!src.isFile) return null
26 + val dest = File(dir(toContactId), name)
27 + src.copyTo(dest, overwrite = true)
28 + return dest.absolutePath
29 + }
30 +}
A android/app/src/main/java/fr/ebii/card2vcf/data/ContactRepository.kt
+149 -0
@@ -0,0 +1,149 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.graphics.Bitmap
4 +import fr.ebii.card2vcf.contact.ContactCard
5 +import fr.ebii.card2vcf.crm.ContactNormalizer
6 +import kotlinx.coroutines.flow.Flow
7 +
8 +data class VcfImportResult(
9 + val imported: Int,
10 + val skippedDuplicates: Int,
11 + val errors: Int,
12 +)
13 +
14 +class ContactRepository(
15 + private val dao: CrmContactDao,
16 + private val images: ContactImageStore,
17 +) {
18 + fun observeAll(): Flow<List<CrmContactEntity>> = dao.observeAll()
19 +
20 + suspend fun getById(id: Long) = dao.getById(id)
21 +
22 + suspend fun search(query: String): List<CrmContactEntity> {
23 + val q = query.trim()
24 + if (q.isEmpty()) return dao.getAll()
25 + val token = q.split(Regex("\\s+")).joinToString(" ") { "$it*" }
26 + val ids = dao.searchIds(token)
27 + return ids.mapNotNull { dao.getById(it) }
28 + }
29 +
30 + suspend fun insertFromCard(
31 + card: ContactCard,
32 + cardBitmap: Bitmap?,
33 + profileBitmap: Bitmap?,
34 + notesOverride: String? = card.note,
35 + now: Long = System.currentTimeMillis(),
36 + ): Long {
37 + val entity = card.toEntity(notes = notesOverride, now = now)
38 + val id = dao.insert(entity)
39 + var updated = entity.copy(id = id)
40 + if (cardBitmap != null) {
41 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
42 + }
43 + if (profileBitmap != null) {
44 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
45 + }
46 + if (updated != entity.copy(id = id)) dao.update(updated)
47 + return id
48 + }
49 +
50 + suspend fun updateFromCard(
51 + id: Long,
52 + card: ContactCard,
53 + cardBitmap: Bitmap?,
54 + profileBitmap: Bitmap?,
55 + preserveNotesIfBlankDraft: Boolean = true,
56 + now: Long = System.currentTimeMillis(),
57 + ) {
58 + val existing = dao.getById(id) ?: return
59 + val notes = when {
60 + preserveNotesIfBlankDraft && card.note.isNullOrBlank() -> existing.notes
61 + else -> card.note
62 + }
63 + var updated = card.toEntity(notes = notes, now = now).copy(
64 + id = id,
65 + createdAt = existing.createdAt,
66 + cardImagePath = existing.cardImagePath,
67 + profileImagePath = existing.profileImagePath,
68 + )
69 + if (cardBitmap != null) {
70 + updated = updated.copy(cardImagePath = images.saveJpeg(id, "card.jpg", cardBitmap))
71 + }
72 + if (profileBitmap != null) {
73 + updated = updated.copy(profileImagePath = images.saveJpeg(id, "profile.jpg", profileBitmap))
74 + }
75 + dao.update(updated)
76 + }
77 +
78 + suspend fun delete(id: Long) {
79 + dao.deleteExclusionsFor(id)
80 + dao.deleteById(id)
81 + images.deleteAll(id)
82 + }
83 +
84 + suspend fun findMatchingId(card: ContactCard, among: List<CrmContactEntity>): Long? {
85 + val emails = card.emails.map(ContactNormalizer::email).filter { it.isNotEmpty() }.toSet()
86 + val phones = card.phones.map(ContactNormalizer::phone).filter { it.isNotEmpty() }.toSet()
87 + for (e in among) {
88 + val eEmails = e.emails.map(ContactNormalizer::email).toSet()
89 + val ePhones = e.phones.map(ContactNormalizer::phone).toSet()
90 + if (emails.any { it in eEmails } || phones.any { it in ePhones }) return e.id
91 + }
92 + return null
93 + }
94 +
95 + suspend fun importCards(cards: List<ContactCard>): VcfImportResult {
96 + var imported = 0
97 + var skipped = 0
98 + var errors = 0
99 + val existing = dao.getAll().toMutableList()
100 + for (card in cards) {
101 + try {
102 + if (!card.hasAnyField()) {
103 + errors++
104 + continue
105 + }
106 + if (findMatchingId(card, existing) != null) {
107 + skipped++
108 + continue
109 + }
110 + val id = insertFromCard(card, null, null)
111 + existing.add(dao.getById(id)!!)
112 + imported++
113 + } catch (_: Exception) {
114 + errors++
115 + }
116 + }
117 + return VcfImportResult(imported, skipped, errors)
118 + }
119 +}
120 +
121 +fun ContactCard.toEntity(notes: String?, now: Long) = CrmContactEntity(
122 + fullName = fullName,
123 + firstName = firstName,
124 + lastName = lastName,
125 + company = company,
126 + jobTitle = jobTitle,
127 + phones = phones,
128 + emails = emails,
129 + website = website,
130 + address = address,
131 + notes = notes,
132 + phonesText = phones.joinToString(" "),
133 + emailsText = emails.joinToString(" "),
134 + createdAt = now,
135 + updatedAt = now,
136 +)
137 +
138 +fun CrmContactEntity.toCard() = ContactCard(
139 + fullName = fullName,
140 + firstName = firstName,
141 + lastName = lastName,
142 + company = company,
143 + jobTitle = jobTitle,
144 + phones = phones,
145 + emails = emails,
146 + website = website,
147 + address = address,
148 + note = notes,
149 +)
A android/app/src/test/java/fr/ebii/card2vcf/crm/ContactNormalizerTest.kt
+16 -0
@@ -0,0 +1,16 @@
1 +package fr.ebii.card2vcf.crm
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +
6 +class ContactNormalizerTest {
7 + @Test
8 + fun emailTrimLowercase() {
9 + assertEquals("a@b.c", ContactNormalizer.email(" A@B.C "))
10 + }
11 +
12 + @Test
13 + fun phoneDigitsOnly() {
14 + assertEquals("3361234", ContactNormalizer.phone("+33 6 12-34"))
15 + }
16 +}
A android/app/src/test/java/fr/ebii/card2vcf/data/ContactRepositoryTest.kt
+81 -0
@@ -0,0 +1,81 @@
1 +package fr.ebii.card2vcf.data
2 +
3 +import android.content.Context
4 +import androidx.room.Room
5 +import androidx.test.core.app.ApplicationProvider
6 +import fr.ebii.card2vcf.contact.ContactCard
7 +import kotlinx.coroutines.runBlocking
8 +import org.junit.After
9 +import org.junit.Assert.assertEquals
10 +import org.junit.Assert.assertNull
11 +import org.junit.Assert.assertTrue
12 +import org.junit.Before
13 +import org.junit.Test
14 +import org.junit.runner.RunWith
15 +import org.robolectric.RobolectricTestRunner
16 +import org.robolectric.annotation.Config
17 +
18 +@RunWith(RobolectricTestRunner::class)
19 +@Config(sdk = [31])
20 +class ContactRepositoryTest {
21 + private lateinit var db: CrmDatabase
22 + private lateinit var dao: CrmContactDao
23 + private lateinit var repo: ContactRepository
24 +
25 + @Before
26 + fun setUp() {
27 + val ctx = ApplicationProvider.getApplicationContext<Context>()
28 + db = Room.inMemoryDatabaseBuilder(ctx, CrmDatabase::class.java)
29 + .allowMainThreadQueries()
30 + .build()
31 + dao = db.crmContactDao()
32 + repo = ContactRepository(dao, ContactImageStore(ctx))
33 + }
34 +
35 + @After
36 + fun tearDown() = db.close()
37 +
38 + @Test
39 + fun insertAndSearchNotes() = runBlocking {
40 + val id = repo.insertFromCard(
41 + ContactCard(fullName = "Ada", note = "salon Lyon 2026"),
42 + cardBitmap = null,
43 + profileBitmap = null,
44 + now = 1L,
45 + )
46 + val hits = repo.search("Lyon")
47 + assertEquals(1, hits.size)
48 + assertEquals(id, hits[0].id)
49 + }
50 +
51 + @Test
52 + fun importSkipsDuplicateEmail() = runBlocking {
53 + repo.insertFromCard(
54 + ContactCard(fullName = "Ada", emails = listOf("Ada@Example.COM")),
55 + null,
56 + null,
57 + )
58 + val result = repo.importCards(
59 + listOf(
60 + ContactCard(fullName = "Ada 2", emails = listOf(" ada@example.com ")),
61 + ContactCard(fullName = "Bob", emails = listOf("bob@ex.com")),
62 + ),
63 + )
64 + assertEquals(1, result.imported)
65 + assertEquals(1, result.skippedDuplicates)
66 + assertEquals(0, result.errors)
67 + assertEquals(2, dao.getAll().size)
68 + }
69 +
70 + @Test
71 + fun deleteRemovesRow() = runBlocking {
72 + val id = repo.insertFromCard(
73 + ContactCard(fullName = "Temp"),
74 + null,
75 + null,
76 + )
77 + repo.delete(id)
78 + assertNull(dao.getById(id))
79 + assertTrue(dao.getAll().isEmpty())
80 + }
81 +}