1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
|
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.vcard4android
import at.bitfire.vcard4android.property.*
import ezvcard.Ezvcard
import ezvcard.VCard
import ezvcard.VCardVersion
import ezvcard.io.text.VCardReader
import ezvcard.io.text.VCardWriter
import ezvcard.parameter.EmailType
import ezvcard.parameter.ImageType
import ezvcard.parameter.TelephoneType
import ezvcard.property.*
import ezvcard.util.PartialDate
import org.apache.commons.lang3.StringUtils
import org.apache.commons.lang3.builder.HashCodeBuilder
import org.apache.commons.lang3.builder.ReflectionToStringBuilder
import java.io.IOException
import java.io.OutputStream
import java.io.Reader
import java.net.URI
import java.net.URISyntaxException
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.logging.Level
class Contact {
var uid: String? = null
var group = false
/** list of UIDs of group members without urn:uuid prefix (only meaningful if [group] is true) */
val members = LinkedList<String>()
var displayName: String? = null
var prefix: String? = null
var givenName: String? = null
var middleName: String? = null
var familyName: String? = null
var suffix: String? = null
var phoneticGivenName: String? = null
var phoneticMiddleName: String? = null
var phoneticFamilyName: String? = null
/** vCard NICKNAME – Android only supports one nickname **/
var nickName: LabeledProperty<Nickname>? = null
var organization: Organization? = null
var jobTitle: String? = null // vCard TITLE
var jobDescription: String? = null // vCard ROLE
val phoneNumbers = LinkedList<LabeledProperty<Telephone>>()
val emails = LinkedList<LabeledProperty<Email>>()
val impps = LinkedList<LabeledProperty<Impp>>()
val addresses = LinkedList<LabeledProperty<Address>>()
val categories = LinkedList<String>()
val urls = LinkedList<LabeledProperty<Url>>()
val relations = LinkedList<Related>()
var note: String? = null
var anniversary: Anniversary? = null
var birthDay: Birthday? = null
val customDates = LinkedList<LabeledProperty<DateOrTimeProperty>>()
var photo: ByteArray? = null
/** unknown properties in text vCard format */
var unknownProperties: String? = null
companion object {
/** list of all custom scribes (will be registered to readers/writers) **/
val customScribes = arrayOf(
AbLabel.Scribe,
AddressBookServerKind.Scribe,
AddressBookServerMember.Scribe,
PhoneticFirstName.Scribe,
PhoneticMiddleName.Scribe,
PhoneticLastName.Scribe
)
// productID (if set) will be used to generate a PRODID property.
// You may set this statically from the calling application.
var productID: String? = null
const val PROPERTY_SIP = "X-SIP"
// TEL x-types to store Android types
val PHONE_TYPE_CALLBACK = TelephoneType.get("x-callback")!!
val PHONE_TYPE_COMPANY_MAIN = TelephoneType.get("x-company_main")!!
val PHONE_TYPE_RADIO = TelephoneType.get("x-radio")!!
val PHONE_TYPE_ASSISTANT = TelephoneType.get("X-assistant")!!
val PHONE_TYPE_MMS = TelephoneType.get("x-mms")!!
// EMAIL x-types to store Android types
val EMAIL_TYPE_MOBILE = EmailType.get("x-mobile")!!
const val NICKNAME_TYPE_MAIDEN_NAME = "x-maiden-name"
const val NICKNAME_TYPE_SHORT_NAME = "x-short-name"
const val NICKNAME_TYPE_INITIALS = "x-initials"
const val NICKNAME_TYPE_OTHER_NAME = "x-other-name"
const val URL_TYPE_HOMEPAGE = "x-homepage"
const val URL_TYPE_BLOG = "x-blog"
const val URL_TYPE_PROFILE = "x-profile"
const val URL_TYPE_FTP = "x-ftp"
const val DATE_PARAMETER_OMIT_YEAR = "X-APPLE-OMIT-YEAR"
const val DATE_PARAMETER_OMIT_YEAR_DEFAULT = 1604
/**
* Parses an InputStream that contains a vCard.
*
* @param reader reader for the input stream containing the vCard (pay attention to the charset)
* @param downloader will be used to download external resources like contact photos (may be null)
* @return list of filled Event data objects (may have size 0) – doesn't return null
* @throws IOException on I/O errors when reading the stream
* @throws ezvcard.io.CannotParseException when the vCard can't be parsed
*/
fun fromReader(reader: Reader, downloader: Downloader?): List<Contact> {
// create new vCard reader and add custom scribes to tell the reader how to read custom properties
val vCardReader = VCardReader(reader, VCardVersion.V3_0) // CardDAV requires vCard 3 or newer
for (scribe in customScribes)
vCardReader.scribeIndex.register(scribe)
val vcards = vCardReader.readAll()
val contacts = LinkedList<Contact>()
vcards?.forEach { contacts += fromVCard(it, downloader) }
return contacts
}
private fun fromVCard(vCard: VCard, downloader: Downloader?): Contact {
val c = Contact()
// get X-ABLabels
val labels = vCard.getProperties(AbLabel::class.java)
fun findAndRemoveLabel(group: String?): String? {
if (group == null)
return null
for (label in labels) {
if (label.group.equals(group, true)) {
vCard.removeProperty(label)
return label.value
}
}
return null
}
// process standard properties
val toRemove = LinkedList<VCardProperty>()
for (prop in vCard.properties) {
var remove = true
when (prop) {
is Uid -> c.uid = uriToUID(prop.value)
is Kind, is AddressBookServerKind -> {
val kindProp = prop as Kind
c.group = kindProp.isGroup
}
is Member, is AddressBookServerMember -> {
val uriProp = prop as Member
uriToUID(uriProp.uri)?.let { c.members += it }
}
is FormattedName -> c.displayName = prop.value.trim()
is PhoneticFirstName -> c.phoneticGivenName = StringUtils.trimToNull(prop.value)
is PhoneticMiddleName -> c.phoneticMiddleName = StringUtils.trimToNull(prop.value)
is PhoneticLastName -> c.phoneticFamilyName = StringUtils.trimToNull(prop.value)
is StructuredName -> {
c.prefix = StringUtils.trimToNull(prop.prefixes.joinToString(" "))
c.givenName = StringUtils.trimToNull(prop.given)
c.middleName = StringUtils.trimToNull(prop.additionalNames.joinToString(" "))
c.familyName = StringUtils.trimToNull(prop.family)
c.suffix = StringUtils.trimToNull(prop.suffixes.joinToString(" "))
}
is Nickname -> c.nickName = LabeledProperty(prop, findAndRemoveLabel(prop.group))
is Organization -> c.organization = prop
is Title -> c.jobTitle = StringUtils.trimToNull(prop.value)
is Role -> c.jobDescription = StringUtils.trimToNull(prop.value)
is Telephone -> if (!prop.text.isNullOrBlank())
c.phoneNumbers += LabeledProperty(prop, findAndRemoveLabel(prop.group))
is Email -> if (!prop.value.isNullOrBlank())
c.emails += LabeledProperty(prop, findAndRemoveLabel(prop.group))
is Impp -> c.impps += LabeledProperty(prop, findAndRemoveLabel(prop.group))
is Address -> c.addresses += LabeledProperty(prop, findAndRemoveLabel(prop.group))
is Note -> c.note = if (c.note.isNullOrEmpty()) prop.value else "${c.note}\n\n\n${prop.value}"
is Url -> c.urls += LabeledProperty(prop, findAndRemoveLabel(prop.group))
is Categories -> c.categories.addAll(prop.values)
is Birthday -> c.birthDay = checkVCard3PartialDate(prop)
is Anniversary -> c.anniversary = checkVCard3PartialDate(prop)
is Related -> StringUtils.trimToNull(prop.text)?.let { c.relations += prop }
is Photo ->
c.photo = prop.data ?: prop.url?.let { url ->
downloader?.let {
Constants.log.info("Downloading photo from $url")
it.download(url, "image/*")
}
}
// remove binary properties because of potential OutOfMemory / TransactionTooLarge exceptions
is Logo, is Sound -> { /* remove = true */ }
// remove properties that don't apply anymore
is ProductId,
is Revision,
is SortString,
is Source -> {
/* remove = true */
}
else -> remove = false // don't remove unknown properties
}
if (remove)
toRemove += prop
}
toRemove.forEach { vCard.removeProperty(it) }
// process extended properties
val extToRemove = LinkedList<RawProperty>()
for (prop in vCard.extendedProperties) {
var remove = true
when (prop.propertyName) {
PROPERTY_SIP -> c.impps += LabeledProperty(Impp("sip", prop.value), findAndRemoveLabel(prop.group))
else -> remove = false // don't remove unknown extended properties
}
if (remove)
extToRemove += prop
}
extToRemove.distinct().forEach { vCard.removeExtendedProperty(it.propertyName) }
if (c.uid == null) {
Constants.log.warning("Received vCard without UID, generating new one")
c.uid = UUID.randomUUID().toString()
}
// remove properties which
// - couldn't be parsed (and thus are treated as extended/unknown properties), and
// - must occur once max.
arrayOf("ANNIVERSARY", "BDAY", "KIND", "N", "PRODID", "REV", "UID").forEach {
vCard.removeExtendedProperty(it)
}
// store all remaining properties into unknownProperties
if (vCard.properties.isNotEmpty() || vCard.extendedProperties.isNotEmpty())
try {
val writer = Ezvcard.write(vCard)
for (scribe in customScribes) // unknwown properties may contain custom scribes like an unmatched X-ABLabel
writer.register(scribe)
c.unknownProperties = writer.go()
} catch(e: Exception) {
Constants.log.log(Level.WARNING, "Couldn't serialize unknown properties, dropping them", e)
}
return c
}
private fun uriToUID(uriString: String?): String? {
if (uriString == null)
return null
return try {
val uri = URI(uriString)
when {
uri.scheme == null ->
uri.schemeSpecificPart
uri.scheme.equals("urn", true) && uri.schemeSpecificPart.startsWith("uuid:", true) ->
uri.schemeSpecificPart.substring(5)
else ->
null
}
} catch(e: URISyntaxException) {
Constants.log.warning("Invalid URI for UID: $uriString")
uriString
}
}
private fun<T: DateOrTimeProperty> checkVCard3PartialDate(property: T): T {
property.date?.let { date ->
property.getParameter(DATE_PARAMETER_OMIT_YEAR)?.let { omitYearStr ->
try {
val omitYear = Integer.parseInt(omitYearStr)
val cal = GregorianCalendar.getInstance()
cal.time = date
if (cal.get(GregorianCalendar.YEAR) == omitYear) {
val partial = PartialDate.builder()
.date(cal.get(GregorianCalendar.DAY_OF_MONTH))
.month(cal.get(GregorianCalendar.MONTH) + 1)
.build()
property.partialDate = partial
}
} catch(e: NumberFormatException) {
Constants.log.log(Level.WARNING, "Unparseable $DATE_PARAMETER_OMIT_YEAR")
} finally {
property.removeParameter(DATE_PARAMETER_OMIT_YEAR)
}
}
}
return property
}
}
@Throws(IOException::class)
fun write(vCardVersion: VCardVersion, groupMethod: GroupMethod, os: OutputStream) {
var vCard = VCard()
try {
unknownProperties?.let { vCard = Ezvcard.parse(unknownProperties).first() }
} catch (e: Exception) {
Constants.log.fine("Couldn't parse original vCard with retained properties")
}
// UID
uid?.let { vCard.uid = Uid(it) }
// PRODID
productID?.let { vCard.setProductId(it) }
// group support
if (group && groupMethod == GroupMethod.GROUP_VCARDS) {
if (vCardVersion == VCardVersion.V4_0) {
vCard.kind = Kind.group()
members.forEach { vCard.members += Member("urn:uuid:$it") }
} else { // "vCard4 as vCard3" (Apple-style)
vCard.setProperty(AddressBookServerKind(Kind.GROUP))
members.forEach { vCard.addProperty(AddressBookServerMember("urn:uuid:$it")) }
}
}
// FN
var fn = displayName
if (fn.isNullOrEmpty())
organization?.let {
for (part in it.values) {
fn = part
if (!fn.isNullOrEmpty())
break
}
}
if (fn.isNullOrEmpty())
nickName?.let { fn = it.property.values.firstOrNull() }
if (fn.isNullOrEmpty())
emails.firstOrNull()?.let { fn = it.property.value }
if (fn.isNullOrEmpty())
phoneNumbers.firstOrNull()?.let { fn = it.property.text }
if (fn.isNullOrEmpty())
fn = uid ?: ""
vCard.setFormattedName(fn)
// N
if (prefix != null || familyName != null || middleName != null || givenName != null || suffix != null) {
val n = StructuredName()
prefix?.let { it.split(' ').forEach { singlePrefix -> n.prefixes += singlePrefix } }
n.given = givenName
middleName?.let { it.split(' ').forEach { singleName -> n.additionalNames += singleName } }
n.family = familyName
suffix?.let { it.split(' ').forEach { singleSuffix -> n.suffixes += singleSuffix } }
vCard.structuredName = n
} else if (vCardVersion == VCardVersion.V3_0) {
// (only) vCard 3 requires N [RFC 2426 3.1.2]
if (group && groupMethod == GroupMethod.GROUP_VCARDS) {
val n = StructuredName()
n.family = fn
vCard.structuredName = n
} else
vCard.structuredName = StructuredName()
}
// phonetic names
phoneticGivenName?.let { vCard.addProperty(PhoneticFirstName(it)) }
phoneticMiddleName?.let { vCard.addProperty(PhoneticMiddleName(it)) }
phoneticFamilyName?.let { vCard.addProperty(PhoneticLastName(it)) }
// ORG, TITLE, ROLE
organization?.let { vCard.organization = it }
jobTitle?.let { vCard.addTitle(it) }
jobDescription?.let { vCard.addRole(it) }
// will be used to count "itemXX." property groups
val labelIterator = AtomicInteger()
// TODO move function outside; make clear that it modifies labeledProperty.property
fun addLabel(labeledProperty: LabeledProperty<VCardProperty>) {
labeledProperty.label?.let { label ->
val group = "group${labelIterator.incrementAndGet()}"
labeledProperty.property.group = group
val abLabel = AbLabel(label)
abLabel.group = group
vCard.addProperty(abLabel)
}
}
// NICKNAME
nickName?.let { labeledNickName ->
vCard.addNickname(labeledNickName.property)
addLabel(labeledNickName)
}
// TEL
for (labeledPhone in phoneNumbers) {
vCard.addTelephoneNumber(labeledPhone.property)
addLabel(labeledPhone)
}
// EMAIL
for (labeledEmail in emails) {
vCard.addEmail(labeledEmail.property)
addLabel(labeledEmail)
}
// IMPP
for (labeledImpp in impps) {
vCard.addImpp(labeledImpp.property)
addLabel(labeledImpp)
}
// ADR
for (labeledAddress in addresses) {
val address = labeledAddress.property
vCard.addAddress(address)
addLabel(labeledAddress)
}
// NOTE
note?.let { vCard.addNote(it) }
// URL
for (labeledUrl in urls) {
val url = labeledUrl.property
vCard.addUrl(url)
addLabel(labeledUrl)
}
// CATEGORIES
if (!categories.isEmpty()) {
val cat = Categories()
cat.values.addAll(categories)
vCard.categories = cat
}
// ANNIVERSARY, BDAY
fun<T: DateOrTimeProperty> dateOrPartialDate(prop: T, generator: (Date) -> T): T? {
if (vCardVersion == VCardVersion.V4_0 || prop.date != null)
return prop
else prop.partialDate?.let { partial ->
// vCard 3: partial date with month and day, but without year
if (partial.date != null && partial.month != null) {
return if (partial.year != null)
// partial date is a complete date
prop
else {
// vCard 3: partial date with month and day, but without year
val fakeCal = GregorianCalendar.getInstance()
fakeCal.set(DATE_PARAMETER_OMIT_YEAR_DEFAULT, partial.month - 1, partial.date)
val fakeProp = generator(fakeCal.time)
fakeProp.addParameter(DATE_PARAMETER_OMIT_YEAR, DATE_PARAMETER_OMIT_YEAR_DEFAULT.toString())
fakeProp
}
}
}
return null
}
anniversary?.let { vCard.anniversary = dateOrPartialDate(it) { time -> Anniversary(time, false) } }
birthDay?.let { vCard.birthday = dateOrPartialDate(it) { time -> Birthday(time, false) } }
// RELATED
relations.forEach { vCard.addRelated(it) }
// PHOTO
photo?.let { vCard.addPhoto(Photo(photo, ImageType.JPEG)) }
// REV
vCard.revision = Revision.now()
// validate vCard and log results
val validation = vCard.validate(vCardVersion)
if (!validation.isEmpty) {
val msgs = LinkedList<String>()
for ((key, warnings) in validation)
msgs += " * " + key.javaClass.simpleName + " - " + warnings.joinToString(" | ")
Constants.log.log(Level.WARNING, "vCard validation warnings", msgs.joinToString(","))
}
// generate VCARD
val writer = Ezvcard
.write(vCard)
.version(vCardVersion)
.versionStrict(false) // allow vCard4 properties in vCard3s
.caretEncoding(true) // enable RFC 6868 support
.prodId(productID == null)
// tell the writer how to write custom properties
for (scribe in customScribes)
writer.register(scribe)
return writer .go(os)
}
private fun compareFields(): Array<Any?> = arrayOf(
uid,
group,
members,
displayName, prefix, givenName, middleName, familyName, suffix,
phoneticGivenName, phoneticMiddleName, phoneticFamilyName,
nickName,
organization, jobTitle, jobDescription,
phoneNumbers, emails, impps, addresses,
/* categories, */ urls, relations,
note, anniversary, birthDay,
photo
/* unknownProperties */
)
override fun hashCode(): Int {
val builder = HashCodeBuilder(29, 3).append(compareFields())
return builder.toHashCode()
}
override fun equals(other: Any?) =
if (other is Contact)
compareFields().contentDeepEquals(other.compareFields())
else
false
override fun toString(): String {
val builder = ReflectionToStringBuilder(this)
builder.setExcludeFieldNames("photo")
return builder.toString()
}
interface Downloader {
fun download(url: String, accepts: String): ByteArray?
}
}
|