/* * Copyright © 2013 – 2015 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 com.etesync.syncadapter.resource import android.content.ContentProviderOperation import android.content.ContentValues import android.net.Uri import android.os.Build import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.RawContacts.Data import android.text.TextUtils import at.bitfire.vcard4android.AndroidAddressBook import at.bitfire.vcard4android.AndroidContact import at.bitfire.vcard4android.AndroidContactFactory import at.bitfire.vcard4android.BatchOperation import at.bitfire.vcard4android.CachedGroupMembership import at.bitfire.vcard4android.Contact import at.bitfire.vcard4android.ContactsStorageException import at.bitfire.vcard4android.GroupMethod.GROUP_VCARDS import com.etesync.syncadapter.Constants import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.UnknownProperties import ezvcard.Ezvcard import ezvcard.VCardVersion import java.io.ByteArrayOutputStream import java.io.FileNotFoundException import java.util.* import java.util.logging.Level class LocalContact : AndroidContact, LocalAddress { companion object { init { Contact.productID = Constants.PRODID_BASE + " ez-vcard/" + Ezvcard.VERSION } internal const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3 internal val HASH_HACK = Build.VERSION_CODES.N <= Build.VERSION.SDK_INT && Build.VERSION.SDK_INT < Build.VERSION_CODES.O } private var saveAsDirty = false // When true, the resource will be saved as dirty internal val cachedGroupMemberships: MutableSet = HashSet() internal val groupMemberships: MutableSet = HashSet() override// The same now val uuid: String? get() = contact?.uid override val isLocalOnly: Boolean get() = TextUtils.isEmpty(eTag) override val content: String get() { val contact: Contact contact = this.contact!! Logger.log.log(Level.FINE, "Preparing upload of VCard $uuid", contact) val os = ByteArrayOutputStream() contact.write(VCardVersion.V4_0, GROUP_VCARDS, os) return os.toString() } constructor(addressBook: AndroidAddressBook, values: ContentValues) : super(addressBook, values) {} constructor(addressBook: AndroidAddressBook, contact: Contact, uuid: String?, eTag: String?) : super(addressBook, contact, uuid, eTag) {} fun resetDirty() { val values = ContentValues(1) values.put(ContactsContract.RawContacts.DIRTY, 0) addressBook.provider?.update(rawContactSyncURI(), values, null, null) } override fun resetDeleted() { val values = ContentValues(1) values.put(ContactsContract.RawContacts.DELETED, 0) addressBook.provider?.update(rawContactSyncURI(), values, null, null) } override fun clearDirty(eTag: String?) { val values = ContentValues(3) if (eTag != null) { values.put(AndroidContact.COLUMN_ETAG, eTag) } values.put(ContactsContract.RawContacts.DIRTY, 0) if (LocalContact.HASH_HACK) { // workaround for Android 7 which sets DIRTY flag when only meta-data is changed val hashCode = dataHashCode() values.put(COLUMN_HASHCODE, hashCode) Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode") } addressBook.provider?.update(rawContactSyncURI(), values, null, null) this.eTag = eTag } override fun legacyPrepareForUpload(fileName_: String?) { val uid = UUID.randomUUID().toString() val values = ContentValues(2) val fileName = fileName_ ?: uid values.put(AndroidContact.COLUMN_FILENAME, fileName) values.put(AndroidContact.COLUMN_UID, uid) addressBook.provider?.update(rawContactSyncURI(), values, null, null) this.fileName = fileName } override fun prepareForUpload(fileName: String, uid: String) { val values = ContentValues(2) values.put(AndroidContact.COLUMN_FILENAME, fileName) values.put(AndroidContact.COLUMN_UID, uid) addressBook.provider?.update(rawContactSyncURI(), values, null, null) contact?.uid = uid this.fileName = fileName } override fun populateData(mimeType: String, row: ContentValues) { when (mimeType) { CachedGroupMembership.CONTENT_ITEM_TYPE -> cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID)) GroupMembership.CONTENT_ITEM_TYPE -> groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID)) UnknownProperties.CONTENT_ITEM_TYPE -> contact?.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES) } } override fun insertDataRows(batch: BatchOperation) { super.insertDataRows(batch) if (contact?.unknownProperties != null) { var builder = BatchOperation.CpoBuilder.newInsert(dataSyncURI()) if (id == null) { builder = builder.withValue(UnknownProperties.RAW_CONTACT_ID, 0) } else { builder = builder.withValue(UnknownProperties.RAW_CONTACT_ID, id) } builder.withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE) .withValue(UnknownProperties.UNKNOWN_PROPERTIES, contact?.unknownProperties) batch.enqueue(builder) } } fun updateAsDirty(contact: Contact): Uri { saveAsDirty = true return this.update(contact) } fun createAsDirty(): Uri { saveAsDirty = true return this.add() } override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) { super.buildContact(builder, update) builder.withValue(ContactsContract.RawContacts.DIRTY, if (saveAsDirty) 1 else 0) } /** * Calculates a hash code from the contact's data (VCard) and group memberships. * Attention: re-reads {@link #contact} from the database, discarding all changes in memory * @return hash code of contact data (including group memberships) */ internal fun dataHashCode(): Int { if (!LocalContact.HASH_HACK) throw IllegalStateException("dataHashCode() should not be called on Android != 7") // reset contact so that getContact() reads from database contact = null // groupMemberships is filled by getContact() val dataHash = contact!!.hashCode() val groupHash = groupMemberships.hashCode() Logger.log.finest("Calculated data hash = $dataHash, group memberships hash = $groupHash") return dataHash xor groupHash } fun updateHashCode(batch: BatchOperation?) { if (!LocalContact.HASH_HACK) throw IllegalStateException("updateHashCode() should not be called on Android != 7") val values = ContentValues(1) val hashCode = dataHashCode() Logger.log.fine("Storing contact hash = $hashCode") values.put(COLUMN_HASHCODE, hashCode) if (batch == null) addressBook.provider!!.update(rawContactSyncURI(), values, null, null) else { val builder = BatchOperation.CpoBuilder .newUpdate(rawContactSyncURI()) .withValue(COLUMN_HASHCODE, hashCode) batch.enqueue(builder) } } fun getLastHashCode(): Int { if (!LocalContact.HASH_HACK) throw IllegalStateException("getLastHashCode() should not be called on Android != 7") addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c -> if (c.moveToNext() && !c.isNull(0)) return c.getInt(0) } return 0 } fun addToGroup(batch: BatchOperation, groupID: Long) { batch.enqueue(BatchOperation.CpoBuilder.newInsert(dataSyncURI()) .withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE) .withValue(GroupMembership.RAW_CONTACT_ID, id) .withValue(GroupMembership.GROUP_ROW_ID, groupID) ) groupMemberships.add(groupID) batch.enqueue(BatchOperation.CpoBuilder.newInsert(dataSyncURI()) .withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) .withValue(CachedGroupMembership.RAW_CONTACT_ID, id) .withValue(CachedGroupMembership.GROUP_ID, groupID) ) cachedGroupMemberships.add(groupID) } fun removeGroupMemberships(batch: BatchOperation) { batch.enqueue(BatchOperation.CpoBuilder.newDelete(dataSyncURI()) .withSelection( Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + " IN (?,?)", arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE) ) ) groupMemberships.clear() cachedGroupMemberships.clear() } /** * Returns the IDs of all groups the contact was member of (cached memberships). * Cached memberships are kept in sync with memberships by DAVdroid and are used to determine * whether a membership has been deleted/added when a raw contact is dirty. * @return set of [GroupMembership.GROUP_ROW_ID] (may be empty) * @throws ContactsStorageException on contact provider errors * @throws FileNotFoundException if the current contact can't be found */ fun getCachedGroupMemberships(): Set { contact return cachedGroupMemberships } /** * Returns the IDs of all groups the contact is member of. * @return set of [GroupMembership.GROUP_ROW_ID]s (may be empty) * @throws ContactsStorageException on contact provider errors * @throws FileNotFoundException if the current contact can't be found */ fun getGroupMemberships(): Set { contact return groupMemberships } // factory object Factory: AndroidContactFactory { override fun fromProvider(addressBook: AndroidAddressBook, values: ContentValues) = LocalContact(addressBook, values) } }