Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit de57ca7

Browse files
authored
Extract vCard parsing and generating (#418)
* Add VCardParser and VCardGenerator for centralized vCard handling * Introduce VCardParser for parsing vCard data with custom scribes * Introduce VCardGenerator for writing vCards with configurable options * Update Contact and ContactWriter to use new utilities * Add VCardGeneratorTest, VCardParserTest, and VCardGeneratorOutputTest * Introduce comprehensive tests for VCardGenerator with vCard 3.0/4.0 support * Add VCardParser tests for parsing vCard 3.0/4.0 with custom properties * Include debug output test for VCardGenerator * Replace OutputStream with Writer in Contact writeVCard API * Update ContactTest and ContactWriterTest to use StringWriter/Reader * Adapt tests * Clean up tests * Replace ByteArrayOutputStream with StringWriter in AndroidContactTest * Update VCardParser and VCardGenerator documentation * Clarify default vCard version and custom scribes in VCardParser * Add note about Writer not being flushed in VCardGenerator
1 parent 432bdeb commit de57ca7

10 files changed

Lines changed: 366 additions & 85 deletions

File tree

lib/src/androidTest/kotlin/at/bitfire/synctools/storage/contacts/AndroidContactTest.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ import org.junit.Assert.assertTrue
3131
import org.junit.BeforeClass
3232
import org.junit.ClassRule
3333
import org.junit.Test
34-
import java.io.ByteArrayOutputStream
3534
import java.io.StringReader
35+
import java.io.StringWriter
3636
import java.time.LocalDate
3737
import java.time.OffsetDateTime
3838
import java.time.ZoneOffset
@@ -124,8 +124,8 @@ class AndroidContactTest {
124124
try {
125125
val contact2 = dbContact2.getContact()
126126
assertEquals("Test", contact2.displayName)
127-
assertEquals("+12345", contact2.phoneNumbers.first.property.text)
128-
assertEquals("test@example.com", contact2.emails.first.property.value)
127+
assertEquals("+12345", contact2.phoneNumbers.first().property.text)
128+
assertEquals("test@example.com", contact2.emails.first().property.value)
129129
} finally {
130130
dbContact2.delete()
131131
}
@@ -209,9 +209,9 @@ class AndroidContactTest {
209209
*
210210
* So, ADR value components may contain DQUOTE (0x22) and don't have to be encoded as defined in RFC 6868 */
211211

212-
val os = ByteArrayOutputStream()
213-
contact.writeVCard(VCardVersion.V4_0, os, testProductId)
214-
assertTrue(os.toString().contains("ADR;LABEL=My ^'Label^'\\nLine 2:;;Street \"Address\";;;;"))
212+
val writer = StringWriter()
213+
contact.writeVCard(VCardVersion.V4_0, writer, testProductId)
214+
assertTrue(writer.toString().contains("ADR;LABEL=My ^'Label^'\\nLine 2:;;Street \"Address\";;;;"))
215215
}
216216

217217
}

lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/Contact.kt

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66

77
package at.bitfire.synctools.mapping.contacts
88

9-
import at.bitfire.synctools.vcard.property.CustomScribes.registerCustomScribes
9+
import at.bitfire.synctools.vcard.VCardParser
1010
import at.bitfire.synctools.vcard.property.XAbDate
1111
import com.google.common.base.Ascii
1212
import com.google.common.base.MoreObjects
1313
import ezvcard.VCardVersion
14-
import ezvcard.io.text.VCardReader
1514
import ezvcard.property.Address
1615
import ezvcard.property.Anniversary
1716
import ezvcard.property.Birthday
@@ -23,8 +22,8 @@ import ezvcard.property.Related
2322
import ezvcard.property.Telephone
2423
import ezvcard.property.Url
2524
import java.io.IOException
26-
import java.io.OutputStream
2725
import java.io.Reader
26+
import java.io.Writer
2827
import java.util.LinkedList
2928

3029
/**
@@ -101,25 +100,19 @@ data class Contact(
101100
* @throws IOException on I/O errors when reading the stream
102101
* @throws ezvcard.io.CannotParseException when the vCard can't be parsed
103102
*/
104-
suspend fun fromReader(reader: Reader, downloader: Downloader?): List<Contact> {
105-
// create new reader and add custom scribes
106-
val vCards = VCardReader(reader, VCardVersion.V3_0) // CardDAV requires vCard 3 or newer
107-
.registerCustomScribes()
108-
.readAll()
109-
110-
return vCards.map { vCard ->
103+
suspend fun fromReader(reader: Reader, downloader: Downloader?): List<Contact> =
104+
VCardParser().parse(reader).map { vCard ->
111105
// convert every vCard to a Contact data object
112106
ContactReader.fromVCard(vCard, downloader)
113107
}
114-
}
115108

116109
}
117110

118111

119112
@Throws(IOException::class)
120-
fun writeVCard(vCardVersion: VCardVersion, os: OutputStream, productId: String) {
113+
fun writeVCard(vCardVersion: VCardVersion, writer: Writer, productId: String) {
121114
val generator = ContactWriter(this, vCardVersion, productId)
122-
generator.writeVCard(os)
115+
generator.writeVCard(writer)
123116
}
124117

125118

lib/src/main/kotlin/at/bitfire/synctools/mapping/contacts/ContactWriter.kt

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ package at.bitfire.synctools.mapping.contacts
99
import at.bitfire.synctools.util.Utils.capitalize
1010
import at.bitfire.synctools.util.Utils.isEmpty
1111
import at.bitfire.synctools.util.Utils.trimToNull
12-
import at.bitfire.synctools.vcard.property.CustomScribes.registerCustomScribes
12+
import at.bitfire.synctools.vcard.VCardGenerator
1313
import at.bitfire.synctools.vcard.property.CustomType
1414
import at.bitfire.synctools.vcard.property.XAbDate
1515
import at.bitfire.synctools.vcard.property.XAbLabel
@@ -22,7 +22,6 @@ import at.bitfire.synctools.vcard.property.XPhoneticMiddleName
2222
import ezvcard.Ezvcard
2323
import ezvcard.VCard
2424
import ezvcard.VCardVersion
25-
import ezvcard.io.text.VCardWriter
2625
import ezvcard.parameter.ImageType
2726
import ezvcard.parameter.RelatedType
2827
import ezvcard.property.Categories
@@ -36,7 +35,7 @@ import ezvcard.property.Revision
3635
import ezvcard.property.StructuredName
3736
import ezvcard.property.Uid
3837
import ezvcard.property.VCardProperty
39-
import java.io.OutputStream
38+
import java.io.Writer
4039
import java.time.LocalDate
4140
import java.util.LinkedList
4241
import java.util.logging.Level
@@ -345,30 +344,20 @@ class ContactWriter(
345344

346345

347346
/**
348-
* Validates and writes the vCard to an output stream.
347+
* Validates and writes the contact to a writer as vCard.
349348
*
350-
* @param stream target output stream
349+
* @param writer where vCard is written to
351350
*/
352-
fun writeVCard(stream: OutputStream) {
351+
fun writeVCard(writer: Writer) {
353352
validate()
354353

355-
val writer = VCardWriter(stream, version).apply {
356-
isAddProdId = false // we handle PRODID ourselves
357-
registerCustomScribes()
358-
359-
/* include trailing semicolons for maximum compatibility
360-
Don't include trailing semicolons for groups because Apple then shows "N:Group;;;;" as "Group;;;;". */
361-
isIncludeTrailingSemicolons = !contact.group
362-
363-
// use caret encoding for parameter values (RFC 6868)
364-
isCaretEncodingEnabled = true
365-
366-
// allow properties that are not defined in this vCard version
367-
isVersionStrict = false
368-
}
369-
370-
writer.write(vCard)
371-
writer.flush()
354+
/* Normally include trailing semicolons for maximum compatibility, but don't include them
355+
for groups because Apple then shows "N:Group;;;;" as "Group;;;;". */
356+
val generator = VCardGenerator(
357+
targetVersion = version,
358+
includeTrailingSemicolons = !contact.group
359+
)
360+
generator.write(vCard, writer)
372361
}
373362

374363
private fun validate() {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.vcard
8+
9+
import at.bitfire.synctools.vcard.property.CustomScribes.registerCustomScribes
10+
import ezvcard.VCard
11+
import ezvcard.VCardVersion
12+
import ezvcard.io.text.VCardWriter
13+
import java.io.Writer
14+
import javax.annotation.WillNotClose
15+
16+
class VCardGenerator(
17+
private val targetVersion: VCardVersion,
18+
private val includeTrailingSemicolons: Boolean
19+
) {
20+
21+
/**
22+
* Writes a [VCard] to the specified [Writer] with custom configuration.
23+
*
24+
* _Note:_ This method doesn't flush the Writer.
25+
*
26+
* @param vCard The [VCard] to be written.
27+
* @param to The target [Writer] where the vCard data will be written.
28+
*/
29+
fun write(vCard: VCard, @WillNotClose to: Writer) {
30+
val writer = VCardWriter(to, targetVersion).apply {
31+
isAddProdId = false // We handle PRODID ourselves
32+
registerCustomScribes() // Handle our custom properties
33+
34+
/* We usually want to include trailing semicolons for maximum compatibility. */
35+
isIncludeTrailingSemicolons = includeTrailingSemicolons
36+
37+
// Use caret encoding for parameter values (RFC 6868)
38+
isCaretEncodingEnabled = true
39+
40+
// Allow properties that are not defined in this vCard version
41+
isVersionStrict = false
42+
}
43+
44+
// Write actual vCard
45+
writer.write(vCard)
46+
}
47+
48+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.vcard
8+
9+
import at.bitfire.synctools.vcard.property.CustomScribes.registerCustomScribes
10+
import ezvcard.VCard
11+
import ezvcard.VCardVersion
12+
import ezvcard.io.text.VCardReader
13+
import java.io.Reader
14+
import javax.annotation.WillNotClose
15+
16+
class VCardParser {
17+
18+
/**
19+
* Parses vCard data from a [Reader] into a list of [VCard] objects.
20+
*
21+
* Defaults to vCard version 3.0 and supports custom property scribes.
22+
*
23+
* @param reader The [Reader] providing the vCard data to parse. Will not be closed by this method.
24+
* @return List of parsed [VCard] objects.
25+
*/
26+
fun parse(@WillNotClose reader: Reader): List<VCard> {
27+
// By default, CardDAV assumes vCard 3
28+
val vCards = VCardReader(reader, VCardVersion.V3_0)
29+
.registerCustomScribes()
30+
.readAll()
31+
32+
return vCards
33+
}
34+
35+
}

lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactTest.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import org.junit.Assert.assertNotNull
2323
import org.junit.Assert.assertNull
2424
import org.junit.Assert.assertTrue
2525
import org.junit.Test
26-
import java.io.ByteArrayInputStream
27-
import java.io.ByteArrayOutputStream
2826
import java.io.InputStreamReader
27+
import java.io.StringReader
28+
import java.io.StringWriter
2929
import java.nio.charset.Charset
3030
import java.time.LocalDate
3131
import java.time.OffsetDateTime
@@ -40,9 +40,11 @@ class ContactTest {
4040
}
4141

4242
private suspend fun regenerate(c: Contact, vCardVersion: VCardVersion): Contact {
43-
val os = ByteArrayOutputStream()
44-
c.writeVCard(vCardVersion, os, testProductId)
45-
return Contact.fromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8), null).first()
43+
val writer = StringWriter()
44+
c.writeVCard(vCardVersion, writer, testProductId)
45+
val vCard = writer.toString()
46+
47+
return Contact.fromReader(StringReader(vCard), null).first()
4648
}
4749

4850

lib/src/test/kotlin/at/bitfire/synctools/mapping/contacts/ContactWriterTest.kt

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import at.bitfire.synctools.vcard.property.XAddressBookServerMember
1515
import at.bitfire.synctools.vcard.property.XPhoneticFirstName
1616
import at.bitfire.synctools.vcard.property.XPhoneticLastName
1717
import at.bitfire.synctools.vcard.property.XPhoneticMiddleName
18-
import ezvcard.Ezvcard
1918
import ezvcard.VCard
2019
import ezvcard.VCardVersion
2120
import ezvcard.parameter.ImageType
@@ -30,7 +29,6 @@ import ezvcard.property.Nickname
3029
import ezvcard.property.Organization
3130
import ezvcard.property.Photo
3231
import ezvcard.property.Related
33-
import ezvcard.property.Revision
3432
import ezvcard.property.StructuredName
3533
import ezvcard.property.Telephone
3634
import ezvcard.property.Url
@@ -39,11 +37,8 @@ import org.junit.Assert.assertEquals
3937
import org.junit.Assert.assertNull
4038
import org.junit.Assert.assertTrue
4139
import org.junit.Test
42-
import java.io.ByteArrayOutputStream
4340
import java.net.URI
4441
import java.time.LocalDate
45-
import java.time.ZoneOffset
46-
import java.time.ZonedDateTime
4742

4843
class ContactWriterTest {
4944

@@ -576,37 +571,6 @@ class ContactWriterTest {
576571
}
577572

578573

579-
@Test
580-
fun testWriteVCard() {
581-
val generator = ContactWriter(Contact(), VCardVersion.V4_0, testProductId)
582-
generator.vCard.revision = Revision(ZonedDateTime.of(2021, 7, 30, 1, 2, 3, 0, ZoneOffset.UTC))
583-
584-
val stream = ByteArrayOutputStream()
585-
generator.writeVCard(stream)
586-
assertEquals("BEGIN:VCARD\r\n" +
587-
"VERSION:4.0\r\n" +
588-
"PRODID:$testProductId (ez-vcard/${Ezvcard.VERSION})\r\n" +
589-
"FN:\r\n" +
590-
"REV:20210730T010203+0000\r\n" +
591-
"END:VCARD\r\n", stream.toString())
592-
}
593-
594-
@Test
595-
fun testWriteVCard_CaretEncoding() {
596-
val stream = ByteArrayOutputStream()
597-
val contact = Contact().apply {
598-
addresses += LabeledProperty(Address().apply {
599-
label = "Li^ne 1,1 - \" -"
600-
streetAddress = "Line1"
601-
country = "Line2"
602-
})
603-
}
604-
ContactWriter(contact, VCardVersion.V4_0, testProductId)
605-
.writeVCard(stream)
606-
assertTrue(stream.toString().contains("ADR;LABEL=\"Li^^ne 1,1 - ^' -\":;;Line1;;;;Line2"))
607-
}
608-
609-
610574
// helpers
611575

612576
private fun generate(version: VCardVersion = VCardVersion.V4_0, prepare: Contact.() -> Unit): VCard {

lib/src/test/kotlin/at/bitfire/synctools/vcard/EzVCardTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import ezvcard.VCard
1111
import ezvcard.VCardVersion
1212
import ezvcard.property.Address
1313
import org.junit.Assert
14+
import org.junit.Assert.assertEquals
1415
import org.junit.Test
1516

1617
class EzVCardTest {
@@ -113,8 +114,8 @@ class EzVCardTest {
113114
.version(VCardVersion.V4_0)
114115
.caretEncoding(true)
115116
.go().lines().filter { it.startsWith("ADR") }.first()
116-
//assertEquals("ADR;LABEL=\"Li^^ne 1,1^n- ^' -\":;;Line 1;;;;Line 2", str)
117-
Assert.assertEquals("ADR;LABEL=\"Li^^ne 1,1\\n- ^' -\":;;Line 1;;;;Line 2", str)
117+
// Note: newline is intentionally encoded to \n, not ^n – see https://github.com/mangstadt/ez-vcard/issues/115
118+
assertEquals("ADR;LABEL=\"Li^^ne 1,1\\n- ^' -\":;;Line 1;;;;Line 2", str)
118119
}
119120

120121
}

0 commit comments

Comments
 (0)