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 | +} |
GitRust