feat(contact): VCardParser lecture vCard 3.0 et 4.0

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

ec770cc6d54843804f3753ff9223ca9e642a6181

1 parent(s)

4 fichiers modifiés +240 -0
A android/app/src/main/java/fr/ebii/card2vcf/contact/VCardParser.kt
+135 -0
@@ -0,0 +1,135 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +object VCardParser {
4 + fun parse(text: String): List<ContactCard> {
5 + val unfolded = unfold(text)
6 + val cards = mutableListOf<ContactCard>()
7 + var buf = mutableListOf<String>()
8 + for (line in unfolded.lineSequence()) {
9 + val t = line.trimEnd()
10 + when {
11 + t.equals("BEGIN:VCARD", true) -> buf = mutableListOf()
12 + t.equals("END:VCARD", true) -> cards.add(parseOne(buf))
13 + else -> if (t.isNotEmpty()) buf.add(t)
14 + }
15 + }
16 + return cards
17 + }
18 +
19 + private fun unfold(text: String): String = buildString {
20 + for (line in text.replace("\r\n", "\n").replace('\r', '\n').lineSequence()) {
21 + if (line.startsWith(" ") || line.startsWith("\t")) {
22 + append(line.drop(1))
23 + } else {
24 + if (isNotEmpty()) append('\n')
25 + append(line)
26 + }
27 + }
28 + }
29 +
30 + private fun parseOne(lines: List<String>): ContactCard {
31 + var fullName: String? = null
32 + var firstName: String? = null
33 + var lastName: String? = null
34 + var company: String? = null
35 + var jobTitle: String? = null
36 + val phones = mutableListOf<String>()
37 + val emails = mutableListOf<String>()
38 + var website: String? = null
39 + var address: String? = null
40 + var note: String? = null
41 +
42 + for (line in lines) {
43 + val colon = line.indexOf(':')
44 + if (colon < 0) continue
45 + val nameAndParams = line.substring(0, colon)
46 + val rawValue = line.substring(colon + 1)
47 + val prop = propertyName(nameAndParams)
48 + if (prop.equals("PHOTO", true) || prop.equals("VERSION", true) || prop.equals("BEGIN", true)) {
49 + continue
50 + }
51 + val value = unescape(rawValue)
52 + when {
53 + prop.equals("FN", true) -> fullName = value.takeIf { it.isNotBlank() }
54 + prop.equals("N", true) -> {
55 + val parts = splitUnescaped(rawValue)
56 + lastName = unescape(parts.getOrElse(0) { "" }).takeIf { it.isNotBlank() }
57 + firstName = unescape(parts.getOrElse(1) { "" }).takeIf { it.isNotBlank() }
58 + }
59 + prop.equals("ORG", true) -> company = value.takeIf { it.isNotBlank() }
60 + prop.equals("TITLE", true) -> jobTitle = value.takeIf { it.isNotBlank() }
61 + prop.equals("TEL", true) -> value.takeIf { it.isNotBlank() }?.let { phones.add(it) }
62 + prop.equals("EMAIL", true) -> value.takeIf { it.isNotBlank() }?.let { emails.add(it) }
63 + prop.equals("URL", true) -> website = value.takeIf { it.isNotBlank() }
64 + prop.equals("ADR", true) -> {
65 + val parts = splitUnescaped(rawValue)
66 + .map { unescape(it).trim() }
67 + .filter { it.isNotEmpty() }
68 + address = parts.joinToString(", ").takeIf { it.isNotBlank() }
69 + }
70 + prop.equals("NOTE", true) -> note = value.takeIf { it.isNotBlank() }
71 + }
72 + }
73 +
74 + return ContactCard(
75 + fullName = fullName,
76 + firstName = firstName,
77 + lastName = lastName,
78 + company = company,
79 + jobTitle = jobTitle,
80 + phones = phones,
81 + emails = emails,
82 + website = website,
83 + address = address,
84 + note = note,
85 + )
86 + }
87 +
88 + /** Property name before ':' : strip group prefix (item1.), take part before first ';'. */
89 + private fun propertyName(nameAndParams: String): String {
90 + val afterGroup = nameAndParams.substringAfterLast('.', nameAndParams)
91 + return afterGroup.substringBefore(';')
92 + }
93 +
94 + private fun unescape(value: String): String = buildString(value.length) {
95 + var i = 0
96 + while (i < value.length) {
97 + val c = value[i]
98 + if (c == '\\' && i + 1 < value.length) {
99 + when (val n = value[i + 1]) {
100 + 'n', 'N' -> append('\n')
101 + 'r', 'R' -> { /* skip CR */ }
102 + ';', ',', '\\' -> append(n)
103 + else -> append(n)
104 + }
105 + i += 2
106 + } else {
107 + append(c)
108 + i++
109 + }
110 + }
111 + }
112 +
113 + /** Split on unescaped ';' (vCard structured values). */
114 + private fun splitUnescaped(value: String): List<String> {
115 + val parts = mutableListOf<String>()
116 + val buf = StringBuilder()
117 + var i = 0
118 + while (i < value.length) {
119 + val c = value[i]
120 + if (c == '\\' && i + 1 < value.length) {
121 + buf.append(c).append(value[i + 1])
122 + i += 2
123 + } else if (c == ';') {
124 + parts.add(buf.toString())
125 + buf.clear()
126 + i++
127 + } else {
128 + buf.append(c)
129 + i++
130 + }
131 + }
132 + parts.add(buf.toString())
133 + return parts
134 + }
135 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/VCardParserTest.kt
+84 -0
@@ -0,0 +1,84 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertTrue
6 +
7 +class VCardParserTest {
8 +
9 + private fun loadFixture(name: String): String =
10 + checkNotNull(javaClass.classLoader!!.getResourceAsStream("vcf/$name"))
11 + .bufferedReader()
12 + .use { it.readText() }
13 +
14 + @Test
15 + fun parseSample3() {
16 + val cards = VCardParser.parse(loadFixture("sample3.vcf"))
17 + assertEquals(2, cards.size)
18 +
19 + val alice = cards[0]
20 + assertEquals("Alice Martin", alice.fullName)
21 + assertEquals("Alice", alice.firstName)
22 + assertEquals("Martin", alice.lastName)
23 + assertEquals("Acme", alice.company)
24 + assertEquals(listOf("alice@acme.test"), alice.emails)
25 + assertEquals(listOf("+33601020304"), alice.phones)
26 + assertEquals("salon Lyon", alice.note)
27 +
28 + val bob = cards[1]
29 + assertEquals("Bob", bob.fullName)
30 + assertEquals(listOf("bob@unique.test"), bob.emails)
31 + }
32 +
33 + @Test
34 + fun parseSample4() {
35 + val cards = VCardParser.parse(loadFixture("sample4.vcf"))
36 + assertEquals(1, cards.size)
37 + val alice = cards[0]
38 + assertEquals("Alice V4", alice.fullName)
39 + assertEquals(listOf("alice@acme.test"), alice.emails)
40 + assertEquals(listOf("+33601020304"), alice.phones)
41 + }
42 +
43 + @Test
44 + fun unfoldFoldedLine() {
45 + // Leading space/tab of continuation is the fold marker (dropped); extra space stays in value.
46 + val folded = "BEGIN:VCARD\n" +
47 + "VERSION:3.0\n" +
48 + "FN:Alice\n" +
49 + " Martin\n" +
50 + "NOTE:hello\n" +
51 + "\t world\n" +
52 + "END:VCARD"
53 + val cards = VCardParser.parse(folded)
54 + assertEquals(1, cards.size)
55 + assertEquals("Alice Martin", cards[0].fullName)
56 + assertEquals("hello world", cards[0].note)
57 + }
58 +
59 + @Test
60 + fun ignorePhoto() {
61 + val text = """
62 + BEGIN:VCARD
63 + VERSION:3.0
64 + FN:Photo Person
65 + PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQ
66 + EMAIL:photo@test.example
67 + END:VCARD
68 + """.trimIndent()
69 + val cards = VCardParser.parse(text)
70 + assertEquals(1, cards.size)
71 + assertEquals("Photo Person", cards[0].fullName)
72 + assertEquals(listOf("photo@test.example"), cards[0].emails)
73 + assertTrue(cards[0].note == null)
74 + }
75 +
76 + @Test
77 + fun exportRoundtripVersion30() {
78 + val cards = VCardParser.parse(loadFixture("sample3.vcf"))
79 + val vcf = VCardSerializer.toVCard3(cards[0])
80 + assertTrue(vcf.contains("VERSION:3.0"))
81 + assertTrue(vcf.contains("FN:Alice Martin"))
82 + assertTrue(vcf.contains("ORG:Acme"))
83 + }
84 +}
A android/app/src/test/resources/vcf/sample3.vcf
+14 -0
@@ -0,0 +1,14 @@
1 +BEGIN:VCARD
2 +VERSION:3.0
3 +FN:Alice Martin
4 +N:Martin;Alice;;;
5 +ORG:Acme
6 +EMAIL;TYPE=INTERNET:alice@acme.test
7 +TEL;TYPE=CELL:+33601020304
8 +NOTE:salon Lyon
9 +END:VCARD
10 +BEGIN:VCARD
11 +VERSION:3.0
12 +FN:Bob
13 +EMAIL:bob@unique.test
14 +END:VCARD
A android/app/src/test/resources/vcf/sample4.vcf
+7 -0
@@ -0,0 +1,7 @@
1 +BEGIN:VCARD
2 +VERSION:4.0
3 +FN:Alice V4
4 +N:V4;Alice;;;
5 +EMAIL;TYPE=work:alice@acme.test
6 +TEL;TYPE=cell:+33601020304
7 +END:VCARD