AndroidContact.kt 49 KB
Newer Older
Ricki Hirner's avatar
Ricki Hirner committed
1
/*
Ricki Hirner's avatar
Ricki Hirner committed
2
 * Copyright © Ricki Hirner (bitfire web engineering).
Ricki Hirner's avatar
Ricki Hirner committed
3 4 5 6 7 8
 * 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
 */

Ricki Hirner's avatar
Ricki Hirner committed
9
package at.bitfire.vcard4android
Ricki Hirner's avatar
Ricki Hirner committed
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26

import android.content.ContentUris
import android.content.ContentValues
import android.content.EntityIterator
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.CommonDataKinds.*
import android.provider.ContactsContract.CommonDataKinds.Email
import android.provider.ContactsContract.CommonDataKinds.Nickname
import android.provider.ContactsContract.CommonDataKinds.Note
import android.provider.ContactsContract.CommonDataKinds.Organization
import android.provider.ContactsContract.CommonDataKinds.Photo
import android.provider.ContactsContract.CommonDataKinds.StructuredName
import android.provider.ContactsContract.RawContacts
27
import android.provider.ContactsContract.RawContacts.Data
Ricki Hirner's avatar
Ricki Hirner committed
28
import androidx.annotation.CallSuper
Ricki Hirner's avatar
Ricki Hirner committed
29 30 31 32 33
import ezvcard.parameter.*
import ezvcard.property.*
import ezvcard.util.PartialDate
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
Ricki Hirner's avatar
Ricki Hirner committed
34
import org.apache.commons.lang3.builder.ToStringBuilder
35
import org.apache.commons.text.WordUtils
Ricki Hirner's avatar
Ricki Hirner committed
36 37 38 39 40 41 42
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.io.IOException
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Level
Ricki Hirner's avatar
Ricki Hirner committed
43
import kotlin.math.min
Ricki Hirner's avatar
Ricki Hirner committed
44 45

open class AndroidContact(
Ricki Hirner's avatar
Ricki Hirner committed
46
        val addressBook: AndroidAddressBook<out AndroidContact, out AndroidGroup>
Ricki Hirner's avatar
Ricki Hirner committed
47 48 49 50
) {

    companion object {

Ricki Hirner's avatar
Ricki Hirner committed
51 52 53
        const val COLUMN_FILENAME = RawContacts.SOURCE_ID
        const val COLUMN_UID = RawContacts.SYNC1
        const val COLUMN_ETAG = RawContacts.SYNC2
Ricki Hirner's avatar
Ricki Hirner committed
54

55 56
        fun labelToXName(label: String) = "x-" + label
                .replace(' ','-')
Ricki Hirner's avatar
Ricki Hirner committed
57
                .replace(Regex("[^\\p{L}\\p{Nd}\\-_]"), "")
58
                .toLowerCase()
Ricki Hirner's avatar
Ricki Hirner committed
59

Ricki Hirner's avatar
Ricki Hirner committed
60
        fun xNameToLabel(xname: String): String {
61
            // "x-my_property"
Ricki Hirner's avatar
lint  
Ricki Hirner committed
62
            var s = xname.toLowerCase(Locale.getDefault())    // 1. ensure lower case -> "x-my_property"
63
            if (s.startsWith("x-"))                    // 2. remove x- from beginning -> "my_property"
Ricki Hirner's avatar
Ricki Hirner committed
64
                s = s.substring(2)
65 66 67
            s = s   .replace('_', ' ')       // 3. replace "_" and "-" by " " -> "my property"
                    .replace('-', ' ')
            return WordUtils.capitalize(s)                   // 4. capitalize -> "My Property"
Ricki Hirner's avatar
Ricki Hirner committed
68 69
        }

Ricki Hirner's avatar
Ricki Hirner committed
70
        fun toURIScheme(s: String?) =
Ricki Hirner's avatar
Ricki Hirner committed
71 72 73 74
                // RFC 3986 3.1
                // scheme      = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
                // ALPHA       =  %x41-5A / %x61-7A   ; A-Z / a-z
                // DIGIT       =  %x30-39             ; 0-9
75
                s?.replace(Regex("^[^a-zA-Z]+"), "")?.replace(Regex("[^\\da-zA-Z+-.]"), "")
Ricki Hirner's avatar
Ricki Hirner committed
76 77 78 79

    }

    var id: Long? = null
Ricki Hirner's avatar
Ricki Hirner committed
80 81
        protected set

Ricki Hirner's avatar
Ricki Hirner committed
82
    var fileName: String? = null
Ricki Hirner's avatar
Ricki Hirner committed
83 84
        protected set

Ricki Hirner's avatar
Ricki Hirner committed
85 86
    var eTag: String? = null

Ricki Hirner's avatar
Ricki Hirner committed
87
    protected val photoMaxDimensions: Int by lazy { queryPhotoMaxDimensions() }
Ricki Hirner's avatar
Ricki Hirner committed
88 89


Ricki Hirner's avatar
Ricki Hirner committed
90 91 92 93 94
    constructor(addressBook: AndroidAddressBook<out AndroidContact, out AndroidGroup>, values: ContentValues)
            : this(addressBook) {
        this.id = values.getAsLong(RawContacts._ID)
        this.fileName = values.getAsString(COLUMN_FILENAME)
        this.eTag = values.getAsString(COLUMN_ETAG)
Ricki Hirner's avatar
Ricki Hirner committed
95 96
    }

Ricki Hirner's avatar
Ricki Hirner committed
97 98
    constructor(addressBook: AndroidAddressBook<out AndroidContact, out AndroidGroup>, contact: Contact, fileName: String?, eTag: String?)
            : this(addressBook) {
Ricki Hirner's avatar
Ricki Hirner committed
99 100 101 102 103 104
        this.contact = contact
        this.fileName = fileName
        this.eTag = eTag
    }

    var contact: Contact? = null
Ricki Hirner's avatar
Ricki Hirner committed
105 106 107 108 109 110 111 112 113 114 115 116 117 118
        /**
         * Fetches contact data from the contacts provider.
         * @throws IllegalArgumentException if contact has not been saved yet
         * @throws FileNotFoundException when the contact is not available (anymore)
         * @throws RemoteException on contact provider errors
         */
        get() {
            field?.let { return field }

            val id = requireNotNull(id)
            var iter: EntityIterator? = null
            try {
                iter = RawContacts.newEntityIterator(addressBook.provider!!.query(
                        addressBook.syncAdapterURI(ContactsContract.RawContactsEntity.CONTENT_URI),
Ricki Hirner's avatar
Ricki Hirner committed
119
                        null, RawContacts._ID + "=?", arrayOf(id.toString()), null))
Ricki Hirner's avatar
Ricki Hirner committed
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138

                if (iter.hasNext()) {
                    val e = iter.next()

                    field = Contact()
                    populateContact(e.entityValues)

                    val subValues = e.subValues
                    for (subValue in subValues) {
                        val values = subValue.values

                        // remove empty values
                        val it = values.keySet().iterator()
                        while (it.hasNext()) {
                            val obj = values[it.next()]
                            if (obj is String && obj.isEmpty())
                                it.remove()
                        }

Ricki Hirner's avatar
Ricki Hirner committed
139
                        when (val mimeType = values.getAsString(ContactsContract.RawContactsEntity.MIMETYPE)) {
Ricki Hirner's avatar
Ricki Hirner committed
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
                            StructuredName.CONTENT_ITEM_TYPE ->
                                populateStructuredName(values)
                            Phone.CONTENT_ITEM_TYPE ->
                                populatePhoneNumber(values)
                            Email.CONTENT_ITEM_TYPE ->
                                populateEmail(values)
                            Photo.CONTENT_ITEM_TYPE ->
                                populatePhoto(values)
                            Organization.CONTENT_ITEM_TYPE ->
                                populateOrganization(values)
                            Im.CONTENT_ITEM_TYPE ->
                                populateIMPP(values)
                            Nickname.CONTENT_ITEM_TYPE ->
                                populateNickname(values)
                            Note.CONTENT_ITEM_TYPE ->
                                populateNote(values)
                            StructuredPostal.CONTENT_ITEM_TYPE ->
                                populateStructuredPostal(values)
                            Website.CONTENT_ITEM_TYPE ->
                                populateWebsite(values)
                            Event.CONTENT_ITEM_TYPE ->
                                populateEvent(values)
                            Relation.CONTENT_ITEM_TYPE ->
                                populateRelation(values)
                            SipAddress.CONTENT_ITEM_TYPE ->
                                populateSipAddress(values)
                            null ->
                                Constants.log.warning("Ignoring raw contact data row without ${ContactsContract.RawContactsEntity.MIMETYPE}")
                            else ->
                                populateData(mimeType, values)
                        }
Ricki Hirner's avatar
Ricki Hirner committed
171 172
                    }

Ricki Hirner's avatar
Ricki Hirner committed
173 174 175 176 177 178
                    return field
                } else
                    throw FileNotFoundException()
            } finally {
                iter?.close()
            }
Ricki Hirner's avatar
Ricki Hirner committed
179 180
        }

Ricki Hirner's avatar
Ricki Hirner committed
181
    @CallSuper
Ricki Hirner's avatar
Ricki Hirner committed
182
    protected open fun populateContact(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
183 184 185 186 187 188
        fileName = row.getAsString(COLUMN_FILENAME)
        eTag = row.getAsString(COLUMN_ETAG)

        contact!!.uid = row.getAsString(COLUMN_UID)
    }

Ricki Hirner's avatar
Ricki Hirner committed
189
    protected open fun populateStructuredName(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
190 191 192 193 194 195 196 197 198 199 200 201 202 203
        val contact = requireNotNull(contact)
        contact.displayName = row.getAsString(StructuredName.DISPLAY_NAME)

        contact.prefix = row.getAsString(StructuredName.PREFIX)
        contact.givenName = row.getAsString(StructuredName.GIVEN_NAME)
        contact.middleName = row.getAsString(StructuredName.MIDDLE_NAME)
        contact.familyName = row.getAsString(StructuredName.FAMILY_NAME)
        contact.suffix = row.getAsString(StructuredName.SUFFIX)

        contact.phoneticGivenName = row.getAsString(StructuredName.PHONETIC_GIVEN_NAME)
        contact.phoneticMiddleName = row.getAsString(StructuredName.PHONETIC_MIDDLE_NAME)
        contact.phoneticFamilyName = row.getAsString(StructuredName.PHONETIC_FAMILY_NAME)
    }

Ricki Hirner's avatar
Ricki Hirner committed
204
    protected open fun populatePhoneNumber(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
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
        val number = Telephone(row.getAsString(Phone.NUMBER))
        val labeledNumber = LabeledProperty(number)

        when (row.getAsInteger(Phone.TYPE)) {
            Phone.TYPE_HOME ->
                number.types += TelephoneType.HOME
            Phone.TYPE_MOBILE ->
                number.types += TelephoneType.CELL
            Phone.TYPE_WORK ->
                number.types += TelephoneType.WORK
            Phone.TYPE_FAX_WORK -> {
                number.types += TelephoneType.FAX
                number.types += TelephoneType.WORK
            }
            Phone.TYPE_FAX_HOME -> {
                number.types += TelephoneType.FAX
                number.types += TelephoneType.HOME
            }
            Phone.TYPE_PAGER ->
                number.types += TelephoneType.PAGER
            Phone.TYPE_CALLBACK ->
                number.types += Contact.PHONE_TYPE_CALLBACK
            Phone.TYPE_CAR ->
                number.types += TelephoneType.CAR
            Phone.TYPE_COMPANY_MAIN ->
                number.types += Contact.PHONE_TYPE_COMPANY_MAIN
            Phone.TYPE_ISDN ->
                number.types += TelephoneType.ISDN
            Phone.TYPE_MAIN ->
                number.types += TelephoneType.VOICE
            Phone.TYPE_OTHER_FAX ->
                number.types += TelephoneType.FAX
            Phone.TYPE_RADIO ->
                number.types += Contact.PHONE_TYPE_RADIO
            Phone.TYPE_TELEX ->
                number.types += TelephoneType.TEXTPHONE
            Phone.TYPE_TTY_TDD ->
                number.types += TelephoneType.TEXT
            Phone.TYPE_WORK_MOBILE -> {
                number.types += TelephoneType.CELL
                number.types += TelephoneType.WORK
            }
            Phone.TYPE_WORK_PAGER -> {
                number.types += TelephoneType.PAGER
                number.types += TelephoneType.WORK
            }
            Phone.TYPE_ASSISTANT ->
                number.types += Contact.PHONE_TYPE_ASSISTANT
            Phone.TYPE_MMS ->
                number.types += Contact.PHONE_TYPE_MMS
            Phone.TYPE_CUSTOM -> {
Ricki Hirner's avatar
Ricki Hirner committed
256
                    row.getAsString(Phone.LABEL)?.let {
Ricki Hirner's avatar
Ricki Hirner committed
257 258 259 260 261
                        labeledNumber.label = it
                        number.types += TelephoneType.get(labelToXName(it))
                    }
                }
        }
Ricki Hirner's avatar
Ricki Hirner committed
262
        if (row.getAsInteger(Phone.IS_PRIMARY) != 0)
Ricki Hirner's avatar
Ricki Hirner committed
263 264 265 266 267
            number.pref = 1

        contact!!.phoneNumbers += labeledNumber
    }

Ricki Hirner's avatar
Ricki Hirner committed
268
    protected open fun populateEmail(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
        val email = ezvcard.property.Email(row.getAsString(Email.ADDRESS))
        val labeledEmail = LabeledProperty(email)

        when (row.getAsInteger(Email.TYPE)) {
            Email.TYPE_HOME ->
                email.types += EmailType.HOME
            Email.TYPE_WORK ->
                email.types += EmailType.WORK
            Email.TYPE_MOBILE ->
                email.types += Contact.EMAIL_TYPE_MOBILE
            Email.TYPE_CUSTOM ->
                row.getAsString(Email.LABEL)?.let {
                    labeledEmail.label = it
                    email.types += EmailType.get(labelToXName(it))
                }
        }
        if (row.getAsInteger(Email.IS_PRIMARY) != 0)
            email.pref = 1

        contact!!.emails += labeledEmail
    }

Ricki Hirner's avatar
Ricki Hirner committed
291
    protected open fun populatePhoto(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
292 293 294 295 296 297
        val contact = requireNotNull(contact)
        if (row.containsKey(Photo.PHOTO_FILE_ID)) {
            val photoUri = Uri.withAppendedPath(
                    rawContactSyncURI(),
                    RawContacts.DisplayPhoto.CONTENT_DIRECTORY)
            try {
298 299
                addressBook.provider!!.openAssetFile(photoUri, "r")?.let { afd ->
                    afd.createInputStream().use { contact.photo = IOUtils.toByteArray(it) }
Ricki Hirner's avatar
Ricki Hirner committed
300 301 302 303 304 305 306 307
                }
            } catch(e: IOException) {
                Constants.log.log(Level.WARNING, "Couldn't read local contact photo file", e)
            }
        } else
            contact.photo = row.getAsByteArray(Photo.PHOTO)
    }

Ricki Hirner's avatar
Ricki Hirner committed
308
    protected open fun populateOrganization(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
309 310 311 312
        val contact = requireNotNull(contact)
        
        val company = row.getAsString(Organization.COMPANY)
        val department = row.getAsString(Organization.DEPARTMENT)
313
        if (company != null || department != null) {
Ricki Hirner's avatar
Ricki Hirner committed
314
            val org = ezvcard.property.Organization()
315 316
            company?.let { org.values += it }
            department?.let { org.values += it }
Ricki Hirner's avatar
Ricki Hirner committed
317 318 319
            contact.organization = org
        }

320 321
        row.getAsString(Organization.TITLE)?.let { contact.jobTitle = it }
        row.getAsString(Organization.JOB_DESCRIPTION)?.let { contact.jobDescription = it }
Ricki Hirner's avatar
Ricki Hirner committed
322 323
    }

Ricki Hirner's avatar
Ricki Hirner committed
324
    protected open fun populateIMPP(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
325
        val handle = row.getAsString(Im.DATA)
326
        if (handle == null) {
Ricki Hirner's avatar
Ricki Hirner committed
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
            Constants.log.warning("Ignoring instant messenger record without handle")
            return
        }

        var impp: Impp? = null
        when (row.getAsInteger(Im.PROTOCOL)) {
            Im.PROTOCOL_AIM ->
                impp = Impp.aim(handle)
            Im.PROTOCOL_MSN ->
                impp = Impp.msn(handle)
            Im.PROTOCOL_YAHOO ->
                impp = Impp.yahoo(handle)
            Im.PROTOCOL_SKYPE ->
                impp = Impp.skype(handle)
            Im.PROTOCOL_QQ ->
                impp = Impp("qq", handle)
            Im.PROTOCOL_GOOGLE_TALK ->
                impp = Impp("google-talk", handle)
            Im.PROTOCOL_ICQ ->
                impp = Impp.icq(handle)
            Im.PROTOCOL_JABBER ->
                impp = Impp.xmpp(handle)
            Im.PROTOCOL_NETMEETING ->
                impp = Impp("netmeeting", handle)
            Im.PROTOCOL_CUSTOM ->
                try {
                    impp = Impp(toURIScheme(row.getAsString(Im.CUSTOM_PROTOCOL)), handle)
                } catch(e: IllegalArgumentException) {
                    Constants.log.warning("Messenger type/value can't be expressed as URI; ignoring")
                }
        }

        impp?.let { impp ->
            val labeledImpp = LabeledProperty(impp)

            when (row.getAsInteger(Im.TYPE)) {
                Im.TYPE_HOME ->
                    impp.types += ImppType.HOME
                Im.TYPE_WORK ->
                    impp.types += ImppType.WORK
                Im.TYPE_CUSTOM ->
                    row.getAsString(Im.LABEL)?.let {
                        labeledImpp.label = it
                        impp.types.add(ImppType.get(labelToXName(it)))
                    }
            }

            contact!!.impps += labeledImpp
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
378
    protected open fun populateNickname(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399
        row.getAsString(Nickname.NAME)?.let { name ->
            val nick = ezvcard.property.Nickname()
            nick.values += name

            when (row.getAsInteger(Nickname.TYPE)) {
                Nickname.TYPE_MAIDEN_NAME ->
                    nick.type = Contact.NICKNAME_TYPE_MAIDEN_NAME
                Nickname.TYPE_SHORT_NAME ->
                    nick.type = Contact.NICKNAME_TYPE_SHORT_NAME
                Nickname.TYPE_INITIALS ->
                    nick.type = Contact.NICKNAME_TYPE_INITIALS
                Nickname.TYPE_OTHER_NAME ->
                    nick.type = Contact.NICKNAME_TYPE_OTHER_NAME
                Nickname.TYPE_CUSTOM ->
                    row.getAsString(Nickname.LABEL)?.let { nick.type = labelToXName(it) }
            }

            contact!!.nickName = nick
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
400
    protected open fun populateNote(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
401 402 403
        contact!!.note = row.getAsString(Note.NOTE)
    }

Ricki Hirner's avatar
Ricki Hirner committed
404
    protected open fun populateStructuredPostal(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
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
        val address = Address()
        val labeledAddress = LabeledProperty(address)

        address.label = row.getAsString(StructuredPostal.FORMATTED_ADDRESS)
        when (row.getAsInteger(StructuredPostal.TYPE)) {
            StructuredPostal.TYPE_HOME ->
                address.types += AddressType.HOME
            StructuredPostal.TYPE_WORK ->
                address.types += AddressType.WORK
            StructuredPostal.TYPE_CUSTOM -> {
                row.getAsString(StructuredPostal.LABEL)?.let {
                    labeledAddress.label = it
                    address.types += AddressType.get(labelToXName(it))
                }
            }
        }
        address.streetAddress = row.getAsString(StructuredPostal.STREET)
        address.poBox = row.getAsString(StructuredPostal.POBOX)
        address.extendedAddress = row.getAsString(StructuredPostal.NEIGHBORHOOD)
        address.locality = row.getAsString(StructuredPostal.CITY)
        address.region = row.getAsString(StructuredPostal.REGION)
        address.postalCode = row.getAsString(StructuredPostal.POSTCODE)
        address.country = row.getAsString(StructuredPostal.COUNTRY)
        contact!!.addresses += labeledAddress
    }

Ricki Hirner's avatar
Ricki Hirner committed
431
    protected open fun populateWebsite(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
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
        val url = Url(row.getAsString(Website.URL))
        val labeledUrl = LabeledProperty(url)

        when (row.getAsInteger(Website.TYPE)) {
            Website.TYPE_HOMEPAGE ->
                url.type = Contact.URL_TYPE_HOMEPAGE
            Website.TYPE_BLOG ->
                url.type = Contact.URL_TYPE_BLOG
            Website.TYPE_PROFILE ->
                url.type = Contact.URL_TYPE_PROFILE
            Website.TYPE_HOME ->
                url.type = "home"
            Website.TYPE_WORK ->
                url.type = "work"
            Website.TYPE_FTP ->
                url.type = Contact.URL_TYPE_FTP
            Website.TYPE_CUSTOM ->
                row.getAsString(Website.LABEL)?.let {
                    url.type = labelToXName(it)
                    labeledUrl.label = it
                }
        }
        contact!!.urls += labeledUrl
    }

Ricki Hirner's avatar
Ricki Hirner committed
457
    protected open fun populateEvent(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
458
        val dateStr = row.getAsString(Event.START_DATE)
Ricki Hirner's avatar
Ricki Hirner committed
459 460
        var full: Date? = null
        var partial: PartialDate? = null
461
        val fullFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
Ricki Hirner's avatar
Ricki Hirner committed
462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480
        try {
            full = fullFormat.parse(dateStr)
        } catch(e: ParseException) {
            try {
                partial = PartialDate.parse(dateStr)
            } catch (e: IllegalArgumentException) {
                Constants.log.log(Level.WARNING, "Couldn't parse birthday/anniversary date from database", e)
            }
        }

        if (full != null || partial != null)
            when (row.getAsInteger(Event.TYPE)) {
                Event.TYPE_ANNIVERSARY ->
                    contact!!.anniversary = if (full != null) Anniversary(full) else Anniversary(partial)
                Event.TYPE_BIRTHDAY ->
                    contact!!.birthDay = if (full != null) Birthday(full) else Birthday(partial)
            }
    }

Ricki Hirner's avatar
Ricki Hirner committed
481
    protected open fun populateRelation(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
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
        row.getAsString(Relation.NAME)?.let { name ->
            val related = Related()
            related.text = name

            when (row.getAsInteger(Relation.TYPE)) {
                Relation.TYPE_ASSISTANT,
                Relation.TYPE_MANAGER ->
                    related.types += RelatedType.CO_WORKER
                Relation.TYPE_BROTHER,
                Relation.TYPE_SISTER ->
                    related.types += RelatedType.SIBLING
                Relation.TYPE_CHILD ->
                    related.types += RelatedType.CHILD
                Relation.TYPE_FRIEND ->
                    related.types += RelatedType.FRIEND
                Relation.TYPE_FATHER,
                Relation.TYPE_MOTHER,
                Relation.TYPE_PARENT ->
                    related.types += RelatedType.PARENT
                Relation.TYPE_DOMESTIC_PARTNER,
                Relation.TYPE_PARTNER,
                Relation.TYPE_SPOUSE ->
                    related.types += RelatedType.SPOUSE
                Relation.TYPE_RELATIVE ->
                    related.types += RelatedType.KIN
                Relation.TYPE_CUSTOM ->
                    row.getAsString(Relation.LABEL)?.split(",")?.forEach {
                        related.types += RelatedType.get(it.trim())
                    }
            }

            contact!!.relations += related
        }
    }

Ricki Hirner's avatar
Ricki Hirner committed
517
    protected open fun populateSipAddress(row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
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
        try {
            val impp = Impp("sip:" + row.getAsString(SipAddress.SIP_ADDRESS))
            val labeledImpp = LabeledProperty(impp)

            when (row.getAsInteger(SipAddress.TYPE)) {
                SipAddress.TYPE_HOME ->
                    impp.types += ImppType.HOME
                SipAddress.TYPE_WORK ->
                    impp.types += ImppType.WORK
                SipAddress.TYPE_CUSTOM ->
                    row.getAsString(SipAddress.LABEL)?.let {
                        labeledImpp.label = it
                        impp.types += ImppType.get(labelToXName(it))
                    }
            }
            contact!!.impps.add(labeledImpp)
        } catch(e: IllegalArgumentException) {
            Constants.log.warning("Ignoring invalid locally stored SIP address")
        }
    }

    /**
     * Override this to handle custom data rows, for example to add additional
     * information to [contact].
     * @param mimeType    MIME type of the row
     * @param row         values of the row
     */
Ricki Hirner's avatar
Ricki Hirner committed
545
    protected open fun populateData(mimeType: String, row: ContentValues) {
Ricki Hirner's avatar
Ricki Hirner committed
546 547 548
    }


549
    fun add(): Uri {
550
        val batch = BatchOperation(addressBook.provider!!)
Ricki Hirner's avatar
Ricki Hirner committed
551

552
        val builder = BatchOperation.CpoBuilder.newInsert(addressBook.syncAdapterURI(RawContacts.CONTENT_URI))
Ricki Hirner's avatar
Ricki Hirner committed
553
        buildContact(builder, false)
554
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
555 556 557 558

        insertDataRows(batch)

        batch.commit()
559 560
        val resultUri = batch.getResult(0)?.uri ?: throw ContactsStorageException("Empty result from content provider when adding contact")
        id = ContentUris.parseId(resultUri)
Ricki Hirner's avatar
Ricki Hirner committed
561 562 563 564

        // we need a raw contact ID to insert the photo
        insertPhoto(contact!!.photo)

565
        return resultUri
Ricki Hirner's avatar
Ricki Hirner committed
566 567
    }

568
    fun update(contact: Contact): Uri {
Ricki Hirner's avatar
Ricki Hirner committed
569 570
        this.contact = contact

571
        val batch = BatchOperation(addressBook.provider!!)
572
        val uri = rawContactSyncURI()
573
        val builder = BatchOperation.CpoBuilder.newUpdate(uri)
Ricki Hirner's avatar
Ricki Hirner committed
574
        buildContact(builder, true)
575
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
576

577 578 579 580
        // Delete known data rows before adding the new ones.
        // - We don't delete group memberships.
        // - We'll only delete rows we have inserted so that unknown rows like
        //   vnd.android.cursor.item/important_people (= contact is in Samsung "edge panel") remain untouched.
581 582
        batch.enqueue(BatchOperation.CpoBuilder
                .newDelete(dataSyncURI())
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598
                .withSelection(Data.RAW_CONTACT_ID + "=? AND " +
                        Data.MIMETYPE + " IN (?,?,?,?,?,?,?,?,?,?,?,?,?)",
                        arrayOf(id.toString(),
                                StructuredName.CONTENT_ITEM_TYPE,
                                Phone.CONTENT_ITEM_TYPE,
                                Email.CONTENT_ITEM_TYPE,
                                Photo.CONTENT_ITEM_TYPE,
                                Organization.CONTENT_ITEM_TYPE,
                                Im.CONTENT_ITEM_TYPE,
                                Nickname.CONTENT_ITEM_TYPE,
                                Note.CONTENT_ITEM_TYPE,
                                StructuredPostal.CONTENT_ITEM_TYPE,
                                Website.CONTENT_ITEM_TYPE,
                                Event.CONTENT_ITEM_TYPE,
                                Relation.CONTENT_ITEM_TYPE,
                                SipAddress.CONTENT_ITEM_TYPE))
599
        )
Ricki Hirner's avatar
Ricki Hirner committed
600
        insertDataRows(batch)
601
        batch.commit()
Ricki Hirner's avatar
Ricki Hirner committed
602 603 604

        insertPhoto(contact.photo)

605
        return uri
Ricki Hirner's avatar
Ricki Hirner committed
606 607
    }

Ricki Hirner's avatar
Ricki Hirner committed
608
    fun delete() = addressBook.provider!!.delete(rawContactSyncURI(), null, null)
Ricki Hirner's avatar
Ricki Hirner committed
609 610


Ricki Hirner's avatar
Ricki Hirner committed
611
    @CallSuper
612
    protected open fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
Ricki Hirner's avatar
Ricki Hirner committed
613 614 615 616 617 618 619 620 621 622
        if (!update)
            builder	.withValue(RawContacts.ACCOUNT_NAME, addressBook.account.name)
                    .withValue(RawContacts.ACCOUNT_TYPE, addressBook.account.type)

        builder .withValue(RawContacts.DIRTY, 0)
                .withValue(RawContacts.DELETED, 0)
                .withValue(COLUMN_FILENAME, fileName)
                .withValue(COLUMN_ETAG, eTag)
                .withValue(COLUMN_UID, contact!!.uid)

623 624
        if (addressBook.readOnly)
            builder.withValue(RawContacts.RAW_CONTACT_IS_READ_ONLY, 1)
Ricki Hirner's avatar
Ricki Hirner committed
625 626 627 628 629
    }


    /**
     * Inserts the data rows for a given raw contact.
630
     * Override this (and call the super class!) to add custom data rows,
Ricki Hirner's avatar
Ricki Hirner committed
631 632
     * for example generated from some properties of [contact].
     * @param  batch    batch operation used to insert the data rows
Ricki Hirner's avatar
Ricki Hirner committed
633
     * @throws RemoteException on contact provider errors
Ricki Hirner's avatar
Ricki Hirner committed
634
     */
Ricki Hirner's avatar
Ricki Hirner committed
635
    @CallSuper
Ricki Hirner's avatar
Ricki Hirner committed
636
    protected open fun insertDataRows(batch: BatchOperation) {
Ricki Hirner's avatar
Ricki Hirner committed
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655
        val contact = requireNotNull(contact)

        insertStructuredName(batch)
        insertNickname(batch)
        insertOrganization(batch)

        contact.phoneNumbers.forEach { insertPhoneNumber(batch, it) }
        contact.emails.forEach { insertEmail(batch, it) }
        contact.impps.forEach { insertIMPP(batch, it) }     // handles SIP addresses, too
        contact.addresses.forEach { insertStructuredPostal(batch, it) }

        insertNote(batch)
        contact.urls.forEach { insertWebsite(batch, it) }
        contact.relations.forEach { insertRelation(batch, it) }

        contact.anniversary?.let { insertEvent(batch, Event.TYPE_ANNIVERSARY, it) }
        contact.birthDay?.let { insertEvent(batch, Event.TYPE_BIRTHDAY, it) }
    }

Ricki Hirner's avatar
Ricki Hirner committed
656
    protected open fun insertStructuredName(batch: BatchOperation) {
Ricki Hirner's avatar
Ricki Hirner committed
657 658 659 660 661 662 663 664
        val contact = requireNotNull(contact)
        if     (contact.displayName == null &&
                contact.prefix == null &&
                contact.givenName == null && contact.middleName == null && contact.familyName == null &&
                contact.suffix == null &&
                contact.phoneticGivenName == null && contact.phoneticMiddleName == null && contact.phoneticFamilyName == null)
            return

665 666
        val builder = insertDataBuilder(StructuredName.RAW_CONTACT_ID)
                .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
667 668 669 670 671 672 673 674 675
                .withValue(StructuredName.DISPLAY_NAME, contact.displayName)
                .withValue(StructuredName.PREFIX, contact.prefix)
                .withValue(StructuredName.GIVEN_NAME, contact.givenName)
                .withValue(StructuredName.MIDDLE_NAME, contact.middleName)
                .withValue(StructuredName.FAMILY_NAME, contact.familyName)
                .withValue(StructuredName.SUFFIX, contact.suffix)
                .withValue(StructuredName.PHONETIC_GIVEN_NAME, contact.phoneticGivenName)
                .withValue(StructuredName.PHONETIC_MIDDLE_NAME, contact.phoneticMiddleName)
                .withValue(StructuredName.PHONETIC_FAMILY_NAME, contact.phoneticFamilyName)
676
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
677 678
    }

Ricki Hirner's avatar
Ricki Hirner committed
679
    protected open fun insertPhoneNumber(batch: BatchOperation, labeledNumber: LabeledProperty<Telephone>) {
Ricki Hirner's avatar
Ricki Hirner committed
680 681 682 683 684 685 686 687 688 689 690
        val number = labeledNumber.property

        val types = number.types

        // preferred number?
        var pref: Int? = null
        try {
            pref = number.pref
        } catch(e: IllegalStateException) {
            Constants.log.log(Level.FINER, "Can't understand phone number PREF", e)
        }
Ricki Hirner's avatar
Ricki Hirner committed
691
        var isPrimary = pref != null
Ricki Hirner's avatar
Ricki Hirner committed
692
        if (types.contains(TelephoneType.PREF)) {
Ricki Hirner's avatar
Ricki Hirner committed
693
            isPrimary = true
Ricki Hirner's avatar
Ricki Hirner committed
694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741
            types -= TelephoneType.PREF
        }

        var typeCode: Int = Phone.TYPE_OTHER
        var typeLabel: String? = null
        if (labeledNumber.label != null) {
            typeCode = Phone.TYPE_CUSTOM
            typeLabel = labeledNumber.label
        } else {
            when {
                // 1 Android type <-> 2 VCard types: fax, cell, pager
                types.contains(TelephoneType.FAX) ->
                    typeCode = when {
                        types.contains(TelephoneType.HOME) -> Phone.TYPE_FAX_HOME
                        types.contains(TelephoneType.WORK) -> Phone.TYPE_FAX_WORK
                        else                               -> Phone.TYPE_OTHER_FAX
                    }
                types.contains(TelephoneType.CELL) ->
                    typeCode = if (types.contains(TelephoneType.WORK))
                        Phone.TYPE_WORK_MOBILE
                    else
                        Phone.TYPE_MOBILE
                types.contains(TelephoneType.PAGER) ->
                    typeCode = if (types.contains(TelephoneType.WORK))
                        Phone.TYPE_WORK_PAGER
                    else
                        Phone.TYPE_PAGER

                // types with 1:1 translation
                types.contains(TelephoneType.HOME) ->
                    typeCode = Phone.TYPE_HOME
                types.contains(TelephoneType.WORK) ->
                    typeCode = Phone.TYPE_WORK
                types.contains(Contact.PHONE_TYPE_CALLBACK) ->
                    typeCode = Phone.TYPE_CALLBACK
                types.contains(TelephoneType.CAR) ->
                    typeCode = Phone.TYPE_CAR
                types.contains(Contact.PHONE_TYPE_COMPANY_MAIN) ->
                    typeCode = Phone.TYPE_COMPANY_MAIN
                types.contains(TelephoneType.ISDN) ->
                    typeCode = Phone.TYPE_ISDN
                types.contains(Contact.PHONE_TYPE_RADIO) ->
                    typeCode = Phone.TYPE_RADIO
                types.contains(Contact.PHONE_TYPE_ASSISTANT) ->
                    typeCode = Phone.TYPE_ASSISTANT
                types.contains(Contact.PHONE_TYPE_MMS) ->
                    typeCode = Phone.TYPE_MMS

742
                types.contains(Contact.PHONE_TYPE_OTHER) ||
Ricki Hirner's avatar
Ricki Hirner committed
743 744 745 746 747 748 749 750 751 752 753
                types.contains(TelephoneType.VOICE) ||
                types.contains(TelephoneType.TEXT) -> {}

                types.isNotEmpty() -> {
                    val type = types.first()
                    typeCode = Phone.TYPE_CUSTOM
                    typeLabel = xNameToLabel(type.value)
                }
            }
        }

754 755
        val builder = insertDataBuilder(Phone.RAW_CONTACT_ID)
                .withValue(Phone.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
756 757 758
                .withValue(Phone.NUMBER, number.text)
                .withValue(Phone.TYPE, typeCode)
                .withValue(Phone.LABEL, typeLabel)
Ricki Hirner's avatar
Ricki Hirner committed
759 760
                .withValue(Phone.IS_PRIMARY, if (isPrimary) 1 else 0)
                .withValue(Phone.IS_SUPER_PRIMARY, if (isPrimary) 1 else 0)
761
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
762 763
    }

Ricki Hirner's avatar
Ricki Hirner committed
764
    protected open fun insertEmail(batch: BatchOperation, labeledEmail: LabeledProperty<ezvcard.property.Email>) {
Ricki Hirner's avatar
Ricki Hirner committed
765 766
        val email = labeledEmail.property

767
        // drop TYPE=internet and TYPE=x400 because Android only knows Internet email addresses
768
        // drop TYPE=other for compatibility, too (non-standard type which is only used by some clients and not useful as an explicit value)
Ricki Hirner's avatar
Ricki Hirner committed
769
        val types = email.types
770
        types.removeAll(arrayOf(EmailType.INTERNET, EmailType.X400, Contact.EMAIL_TYPE_OTHER))
Ricki Hirner's avatar
Ricki Hirner committed
771 772 773 774 775 776 777 778

        // preferred email address?
        var pref: Int? = null
        try {
            pref = email.pref
        } catch(e: IllegalStateException) {
            Constants.log.log(Level.FINER, "Can't understand email PREF", e)
        }
Ricki Hirner's avatar
Ricki Hirner committed
779
        var isPrimary = pref != null
Ricki Hirner's avatar
Ricki Hirner committed
780
        if (types.contains(EmailType.PREF)) {
Ricki Hirner's avatar
Ricki Hirner committed
781
            isPrimary = true
Ricki Hirner's avatar
Ricki Hirner committed
782 783 784
            types -= EmailType.PREF
        }

785
        var typeCode = 0
Ricki Hirner's avatar
Ricki Hirner committed
786 787 788 789 790 791 792 793 794 795 796
        var typeLabel: String? = null
        if (labeledEmail.label != null) {
            typeCode = Email.TYPE_CUSTOM
            typeLabel = labeledEmail.label
        } else {
            for (type in types)
                when (type) {
                    EmailType.HOME -> typeCode = Email.TYPE_HOME
                    EmailType.WORK -> typeCode = Email.TYPE_WORK
                    Contact.EMAIL_TYPE_MOBILE -> typeCode = Email.TYPE_MOBILE
                }
797
            if (typeCode == 0) {    // we still didn't find a known type
Ricki Hirner's avatar
Ricki Hirner committed
798 799 800 801 802 803 804 805 806
                if (email.types.isEmpty())
                    typeCode = Email.TYPE_OTHER
                else {
                    typeCode = Email.TYPE_CUSTOM
                    typeLabel = xNameToLabel(types.first().value)
                }
            }
        }

807 808
        val builder = insertDataBuilder(Email.RAW_CONTACT_ID)
                .withValue(Email.MIMETYPE, Email.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
809 810 811
                .withValue(Email.ADDRESS, email.value)
                .withValue(Email.TYPE, typeCode)
                .withValue(Email.LABEL, typeLabel)
Ricki Hirner's avatar
Ricki Hirner committed
812 813
                .withValue(Email.IS_PRIMARY, if (isPrimary) 1 else 0)
                .withValue(Phone.IS_SUPER_PRIMARY, if (isPrimary) 1 else 0)
814
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
815 816
    }

Ricki Hirner's avatar
Ricki Hirner committed
817
    protected open fun insertOrganization(batch: BatchOperation) {
Ricki Hirner's avatar
Ricki Hirner committed
818 819 820 821 822 823 824 825 826 827 828 829 830 831 832
        val contact = requireNotNull(contact)
        if (contact.organization == null && contact.jobTitle == null && contact.jobDescription == null)
            return

        var company: String? = null
        var department: String? = null
        val organization = contact.organization
        organization?.let {
            val org = it.values.iterator()
            if (org.hasNext())
                company = org.next()
            if (org.hasNext())
                department = org.next()
        }

833 834
        val builder = insertDataBuilder(Organization.RAW_CONTACT_ID)
                .withValue(Organization.MIMETYPE, Organization.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
835 836 837 838
                .withValue(Organization.COMPANY, company)
                .withValue(Organization.DEPARTMENT, department)
                .withValue(Organization.TITLE, contact.jobTitle)
                .withValue(Organization.JOB_DESCRIPTION, contact.jobDescription)
839
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
840 841
    }

Ricki Hirner's avatar
Ricki Hirner committed
842
    protected open fun insertIMPP(batch: BatchOperation, labeledImpp: LabeledProperty<Impp>) {
Ricki Hirner's avatar
Ricki Hirner committed
843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892
        val impp = labeledImpp.property

        var typeCode: Int = Im.TYPE_OTHER
        var typeLabel: String? = null
        if (labeledImpp.label != null) {
            typeCode = Im.TYPE_CUSTOM
            typeLabel = labeledImpp.label
        } else {
            for (type in impp.types)
                when (type) {
                    ImppType.HOME,
                    ImppType.PERSONAL -> typeCode = Im.TYPE_HOME
                    ImppType.WORK,
                    ImppType.BUSINESS -> typeCode = Im.TYPE_WORK
                }
            if (typeCode == Im.TYPE_OTHER && impp.types.isNotEmpty()) {
                typeCode = Im.TYPE_CUSTOM
                typeLabel = xNameToLabel(impp.types.first().value)
            }
        }

        val protocol = impp.protocol
        if (protocol == null) {
            Constants.log.warning("Ignoring IMPP address without protocol")
            return
        }

        var protocolCode = 0
        var protocolLabel: String? = null

        // SIP addresses are IMPP entries in the VCard but locally stored in SipAddress rather than Im
        var sipAddress = false

        when {
            impp.isAim -> protocolCode = Im.PROTOCOL_AIM
            impp.isMsn -> protocolCode = Im.PROTOCOL_MSN
            impp.isYahoo -> protocolCode = Im.PROTOCOL_YAHOO
            impp.isSkype -> protocolCode = Im.PROTOCOL_SKYPE
            protocol.equals("qq", true) -> protocolCode = Im.PROTOCOL_QQ
            protocol.equals("google-talk", true) -> protocolCode = Im.PROTOCOL_GOOGLE_TALK
            impp.isIcq -> protocolCode = Im.PROTOCOL_ICQ
            impp.isXmpp || protocol.equals("jabber", true) -> protocolCode = Im.PROTOCOL_JABBER
            protocol.equals("netmeeting", true) -> protocolCode = Im.PROTOCOL_NETMEETING
            protocol.equals("sip", true) -> sipAddress = true
            else -> {
                protocolCode = Im.PROTOCOL_CUSTOM
                protocolLabel = protocol
            }
        }

Ricki Hirner's avatar
Ricki Hirner committed
893
        val builder = if (sipAddress)
Ricki Hirner's avatar
Ricki Hirner committed
894
            // save as SIP address
Ricki Hirner's avatar
Ricki Hirner committed
895
            insertDataBuilder(SipAddress.RAW_CONTACT_ID)
896
                    .withValue(SipAddress.MIMETYPE, SipAddress.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
897 898 899
                    .withValue(SipAddress.DATA, impp.handle)
                    .withValue(SipAddress.TYPE, typeCode)
                    .withValue(SipAddress.LABEL, typeLabel)
Ricki Hirner's avatar
Ricki Hirner committed
900
        else
Ricki Hirner's avatar
Ricki Hirner committed
901
            // save as IM address
Ricki Hirner's avatar
Ricki Hirner committed
902
            insertDataBuilder(Im.RAW_CONTACT_ID)
903
                    .withValue(Im.MIMETYPE, Im.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
904 905 906 907 908
                    .withValue(Im.DATA, impp.handle)
                    .withValue(Im.TYPE, typeCode)
                    .withValue(Im.LABEL, typeLabel)
                    .withValue(Im.PROTOCOL, protocolCode)
                    .withValue(Im.CUSTOM_PROTOCOL, protocolLabel)
909
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
910 911
    }

Ricki Hirner's avatar
Ricki Hirner committed
912
    protected open fun insertNickname(batch: BatchOperation) {
Ricki Hirner's avatar
Ricki Hirner committed
913 914 915 916 917 918 919
        val nick = contact!!.nickName
        if (nick == null || nick.values.isEmpty())
            return

        val typeCode: Int
        var typeLabel: String? = null

920
        val type = nick.type?.toLowerCase()
Ricki Hirner's avatar
Ricki Hirner committed
921 922 923 924 925 926 927 928 929 930 931 932
        typeCode = when (type) {
            Contact.NICKNAME_TYPE_MAIDEN_NAME -> Nickname.TYPE_MAIDEN_NAME
            Contact.NICKNAME_TYPE_SHORT_NAME ->  Nickname.TYPE_SHORT_NAME
            Contact.NICKNAME_TYPE_INITIALS ->    Nickname.TYPE_INITIALS
            Contact.NICKNAME_TYPE_OTHER_NAME ->  Nickname.TYPE_OTHER_NAME
            null                             ->  Nickname.TYPE_DEFAULT
            else -> {
                typeLabel = xNameToLabel(type)
                Nickname.TYPE_CUSTOM
            }
        }

933 934
        val builder = insertDataBuilder(Nickname.RAW_CONTACT_ID)
                .withValue(Nickname.MIMETYPE, Nickname.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
935 936 937
                .withValue(Nickname.NAME, nick.values.first())
                .withValue(Nickname.TYPE, typeCode)
                .withValue(Nickname.LABEL, typeLabel)
938
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
939 940
    }

Ricki Hirner's avatar
Ricki Hirner committed
941
    protected open fun insertNote(batch: BatchOperation) {
Ricki Hirner's avatar
Ricki Hirner committed
942 943 944 945
        val contact = requireNotNull(contact)
        if (contact.note.isNullOrEmpty())
            return

946 947
        val builder = insertDataBuilder(Note.RAW_CONTACT_ID)
                .withValue(Note.MIMETYPE, Note.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
948
                .withValue(Note.NOTE, contact.note)
949
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
950 951
    }

Ricki Hirner's avatar
Ricki Hirner committed
952
    protected open fun insertStructuredPostal(batch: BatchOperation, labeledAddress: LabeledProperty<Address>) {
Ricki Hirner's avatar
Ricki Hirner committed
953 954 955 956 957 958 959 960 961 962 963 964
        val address = labeledAddress.property

        var formattedAddress = address.label
        if (formattedAddress.isNullOrEmpty()) {
            /*	no formatted address from server, built it like this:
             *
             *  street po.box (extended)
             *	postcode city
             *	region
             *	COUNTRY
             */

965 966
            val lineStreet = arrayOf(address.streetAddress, address.poBox, address.extendedAddress).filterNot { it.isNullOrEmpty() }.joinToString(" ")
            val lineLocality = arrayOf(address.postalCode, address.locality).filterNot { it.isNullOrEmpty() }.joinToString(" ")
Ricki Hirner's avatar
Ricki Hirner committed
967 968

            val lines = LinkedList<String>()
Ricki Hirner's avatar
Ricki Hirner committed
969
            if (lineStreet.isNotEmpty())
Ricki Hirner's avatar
Ricki Hirner committed
970
                lines += lineStreet
Ricki Hirner's avatar
Ricki Hirner committed
971
            if (lineLocality.isNotEmpty())
Ricki Hirner's avatar
Ricki Hirner committed
972 973 974 975
                lines += lineLocality
            if (!address.region.isNullOrEmpty())
                lines += address.region
            if (!address.country.isNullOrEmpty())
Ricki Hirner's avatar
lint  
Ricki Hirner committed
976
                lines += address.country.toUpperCase(Locale.getDefault())
Ricki Hirner's avatar
Ricki Hirner committed
977 978 979 980

            formattedAddress = lines.joinToString("\n")
        }

981
        val types = address.types
Ricki Hirner's avatar
Ricki Hirner committed
982 983 984 985 986 987
        var typeCode = StructuredPostal.TYPE_OTHER
        var typeLabel: String? = null
        if (labeledAddress.label != null) {
            typeCode = StructuredPostal.TYPE_CUSTOM
            typeLabel = labeledAddress.label
        } else {
988 989 990 991 992 993 994
            when {
                types.contains(AddressType.HOME) -> typeCode = StructuredPostal.TYPE_HOME
                types.contains(AddressType.WORK) -> typeCode = StructuredPostal.TYPE_WORK
                types.contains(Contact.ADDRESS_TYPE_OTHER) -> {}
                types.isNotEmpty() -> {
                    typeCode = StructuredPostal.TYPE_CUSTOM
                    typeLabel = xNameToLabel(address.types.first().value)
Ricki Hirner's avatar
Ricki Hirner committed
995 996 997 998
                }
            }
        }

999 1000
        val builder = insertDataBuilder(StructuredPostal.RAW_CONTACT_ID)
                .withValue(StructuredPostal.MIMETYPE, StructuredPostal.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
                .withValue(StructuredPostal.FORMATTED_ADDRESS, formattedAddress)
                .withValue(StructuredPostal.TYPE, typeCode)
                .withValue(StructuredPostal.LABEL, typeLabel)
                .withValue(StructuredPostal.STREET, address.streetAddress)
                .withValue(StructuredPostal.POBOX, address.poBox)
                .withValue(StructuredPostal.NEIGHBORHOOD, address.extendedAddress)
                .withValue(StructuredPostal.CITY, address.locality)
                .withValue(StructuredPostal.REGION, address.region)
                .withValue(StructuredPostal.POSTCODE, address.postalCode)
                .withValue(StructuredPostal.COUNTRY, address.country)
1011
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
1012 1013
    }

Ricki Hirner's avatar
Ricki Hirner committed
1014
    protected open fun insertWebsite(batch: BatchOperation, labeledUrl: LabeledProperty<Url>) {
Ricki Hirner's avatar
Ricki Hirner committed
1015 1016 1017 1018 1019 1020 1021 1022
        val url = labeledUrl.property

        val typeCode: Int
        var typeLabel: String? = null
        if (labeledUrl.label != null) {
            typeCode = Website.TYPE_CUSTOM
            typeLabel = labeledUrl.label
        } else {
1023
            val type = url.type?.toLowerCase()
Ricki Hirner's avatar
Ricki Hirner committed
1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038
            typeCode = when (type) {
                Contact.URL_TYPE_HOMEPAGE -> Website.TYPE_HOMEPAGE
                Contact.URL_TYPE_BLOG ->     Website.TYPE_BLOG
                Contact.URL_TYPE_PROFILE ->  Website.TYPE_PROFILE
                "home" ->                    Website.TYPE_HOME
                "work" ->                    Website.TYPE_WORK
                Contact.URL_TYPE_FTP ->      Website.TYPE_FTP
                null ->                      Website.TYPE_OTHER
                else -> {
                    typeLabel = xNameToLabel(type)
                    Website.TYPE_CUSTOM
                }
            }
        }

1039 1040
        val builder = insertDataBuilder(Website.RAW_CONTACT_ID)
                .withValue(Website.MIMETYPE, Website.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
1041 1042 1043
                .withValue(Website.URL, url.value)
                .withValue(Website.TYPE, typeCode)
                .withValue(Website.LABEL, typeLabel)
1044
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
1045 1046
    }

Ricki Hirner's avatar
Ricki Hirner committed
1047
    protected open fun insertEvent(batch: BatchOperation, type: Int, dateOrTime: DateOrTimeProperty) {
Ricki Hirner's avatar
Ricki Hirner committed
1048
        val dateStr: String
Ricki Hirner's avatar
Ricki Hirner committed
1049
        dateStr = when {
Ricki Hirner's avatar
Ricki Hirner committed
1050
            dateOrTime.date != null -> {
1051
                val format = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
Ricki Hirner's avatar
Ricki Hirner committed
1052
                format.format(dateOrTime.date)
Ricki Hirner's avatar
Ricki Hirner committed
1053 1054
            }
            dateOrTime.partialDate != null ->
Ricki Hirner's avatar
Ricki Hirner committed
1055
                dateOrTime.partialDate.toString()
Ricki Hirner's avatar
Ricki Hirner committed
1056 1057 1058 1059 1060 1061
            else -> {
                Constants.log.log(Level.WARNING, "Ignoring date/time without (partial) date", dateOrTime)
                return
            }
        }

1062 1063
        val builder = insertDataBuilder(Event.RAW_CONTACT_ID)
                .withValue(Event.MIMETYPE, Event.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
1064 1065
                .withValue(Event.TYPE, type)
                .withValue(Event.START_DATE, dateStr)
1066
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
1067 1068
    }

Ricki Hirner's avatar
Ricki Hirner committed
1069
    protected open fun insertRelation(batch: BatchOperation, related: Related) {
Ricki Hirner's avatar
Ricki Hirner committed
1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085
        if (related.text.isNullOrEmpty())
            return

        var typeCode = Relation.TYPE_CUSTOM

        val labels = LinkedList<String>()
        for (type in related.types)
            when (type) {
                RelatedType.CHILD  -> typeCode = Relation.TYPE_CHILD
                RelatedType.SPOUSE -> typeCode = Relation.TYPE_PARTNER
                RelatedType.FRIEND -> typeCode = Relation.TYPE_FRIEND
                RelatedType.KIN    -> typeCode = Relation.TYPE_RELATIVE
                RelatedType.PARENT -> typeCode = Relation.TYPE_PARENT
                else               -> labels += type.value
            }

1086 1087
        val builder = insertDataBuilder(Relation.RAW_CONTACT_ID)
                .withValue(Relation.MIMETYPE, Relation.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
1088 1089 1090
                .withValue(Relation.NAME, related.text)
                .withValue(Relation.TYPE, typeCode)
                .withValue(Relation.LABEL, StringUtils.trimToNull(labels.joinToString(", ")))
1091
        batch.enqueue(builder)
Ricki Hirner's avatar
Ricki Hirner committed
1092 1093
    }

Ricki Hirner's avatar
Ricki Hirner committed
1094
    protected open fun insertPhoto(orig: ByteArray?) {
Ricki Hirner's avatar
Ricki Hirner committed
1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132
        if (orig == null)
            return

        // The following approach would be correct, but it doesn't work:
        // the ContactsProvider handler will process the image in background and update
        // the raw contact with the new photo ID when it's finished, setting it to dirty again!
        // See https://code.google.com/p/android/issues/detail?id=226875

        /*Uri photoUri = addressBook.syncAdapterURI(Uri.withAppendedPath(
                ContentUris.withAppendedId(RawContacts.CONTENT_URI, id),
                RawContacts.DisplayPhoto.CONTENT_DIRECTORY));
        Constants.log.debug("Setting local photo " + photoUri);
        try {
            @Cleanup AssetFileDescriptor fd = addressBook.provider.openAssetFile(photoUri, "w");
            @Cleanup OutputStream stream = fd.createOutputStream();
            if (stream != null)
                stream.write(photo);
            else
                Constants.log.warn("Couldn't create local contact photo file");
        } catch (IOException|RemoteException e) {
            Constants.log.warn("Couldn't write local contact photo file", e);
        }*/

        fun processPhoto(): ByteArray? {
            Constants.log.fine("Processing photo")
            var bitmap = BitmapFactory.decodeByteArray(orig, 0, orig.size)
            if (bitmap == null) {
                Constants.log.warning("Image decoding failed")
                return null
            }

            val width = bitmap.width
            val height = bitmap.height
            val max = photoMaxDimensions.toFloat()

            if (width > max || height > max) {
                val scaleWidth = max/width
                val scaleHeight = max/height
Ricki Hirner's avatar
Ricki Hirner committed
1133
                val scale = min(scaleWidth, scaleHeight)
Ricki Hirner's avatar
Ricki Hirner committed
1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156
                val newWidth = (width * scale).toInt()
                val newHeight = (height * scale).toInt()

                Constants.log.fine("Resizing image from ${width}x$height to ${newWidth}x$newHeight")
                bitmap = Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)

                val baos = ByteArrayOutputStream()
                if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 97, baos)) {
                    Constants.log.warning("Couldn't generate contact image in JPEG format")
                    return orig
                }
                return baos.toByteArray()
            }

            return orig
        }

        // We have to write the photo directly into the PHOTO BLOB, which causes
        // a TransactionTooLargeException for photos > 1 MB, so let's scale them down
        processPhoto()?.let { photo ->
            Constants.log.fine("Inserting photo BLOB for raw contact $id")

            val values = ContentValues(3)
1157
            values.put(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE)
Ricki Hirner's avatar
Ricki Hirner committed
1158 1159
            values.put(Photo.RAW_CONTACT_ID, id)
            values.put(Photo.PHOTO, photo)
1160 1161 1162 1163

            if (addressBook.readOnly)
                values.put(Data.IS_READ_ONLY, 1)

Ricki Hirner's avatar
Ricki Hirner committed
1164
            try {
1165
                addressBook.provider!!.insert(dataSyncURI(), values)
Ricki Hirner's avatar
Ricki Hirner committed
1166 1167 1168 1169 1170 1171 1172
            } catch(e: RemoteException) {
                Constants.log.log(Level.WARNING, "Couldn't insert contact photo", e)
            }
        }
    }


Ricki Hirner's avatar
Ricki Hirner committed
1173 1174
    // helpers

1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187
    protected fun insertDataBuilder(rawContactKeyName: String): BatchOperation.CpoBuilder {
        val builder = BatchOperation.CpoBuilder.newInsert(dataSyncURI())
        if (id == null)
            builder.withValueBackReference(rawContactKeyName, 0)
        else
            builder.withValue(rawContactKeyName, id)

        if (addressBook.readOnly)</