/* * 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.syncadapter import android.accounts.Account import android.content.* import android.os.Bundle import android.provider.ContactsContract import at.bitfire.ical4android.CalendarStorageException import at.bitfire.vcard4android.BatchOperation import at.bitfire.vcard4android.Contact import at.bitfire.vcard4android.ContactsStorageException import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.JournalEntryManager import com.etesync.journalmanager.model.SyncEntry import com.etebase.client.Item import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.Constants import com.etesync.syncadapter.HttpClient import com.etesync.syncadapter.R import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.resource.LocalAddress import com.etesync.syncadapter.resource.LocalAddressBook import com.etesync.syncadapter.resource.LocalContact import com.etesync.syncadapter.resource.LocalGroup import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Request import org.apache.commons.collections4.SetUtils import java.io.FileNotFoundException import java.io.IOException import java.io.StringReader import java.util.logging.Level /** * * Synchronization manager for CardDAV collections; handles contacts and groups. */ class ContactsSyncManager @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class, ContactsStorageException::class) constructor(context: Context, account: Account, settings: AccountSettings, extras: Bundle, authority: String, private val provider: ContentProviderClient, result: SyncResult, localAddressBook: LocalAddressBook, private val remote: HttpUrl) : SyncManager(context, account, settings, extras, authority, result, localAddressBook.url, CollectionInfo.Type.ADDRESS_BOOK, localAddressBook.mainAccount.name) { private val resourceDownloader: ResourceDownloader protected override val syncErrorTitle: String get() = context.getString(R.string.sync_error_contacts, account.name) protected override val syncSuccessfullyTitle: String get() = context.getString(R.string.sync_successfully_contacts, account.name) init { localCollection = localAddressBook resourceDownloader = ResourceDownloader(context) } override fun notificationId(): Int { return Constants.NOTIFICATION_CONTACTS_SYNC } @Throws(ContactsStorageException::class, CalendarStorageException::class) override fun prepare(): Boolean { if (!super.prepare()) return false val localAddressBook = localAddressBook() if (LocalContact.HASH_HACK) { // workaround for Android 7 which sets DIRTY flag when only meta-data is changed val reallyDirty = localAddressBook.verifyDirty() val deleted = localAddressBook.findDeleted().size if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) { Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed") return false } } if (isLegacy) { journal = JournalEntryManager(httpClient.okHttpClient, remote, localAddressBook.url) } return true } @Throws(CalendarStorageException::class, ContactsStorageException::class) override fun prepareDirty() { super.prepareDirty() val addressBook = localAddressBook() /* groups as separate VCards: there are group contacts and individual contacts */ // mark groups with changed members as dirty val batch = BatchOperation(addressBook.provider!!) for (contact in addressBook.findDirtyContacts()) { try { Logger.log.fine("Looking for changed group memberships of contact " + contact.fileName) val cachedGroups = contact.getCachedGroupMemberships() val currentGroups = contact.getGroupMemberships() for (groupID in SetUtils.disjunction(cachedGroups, currentGroups)) { Logger.log.fine("Marking group as dirty: " + groupID!!) batch.enqueue(BatchOperation.Operation( ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI, groupID))) .withValue(ContactsContract.Groups.DIRTY, 1) .withYieldAllowed(true) )) } } catch (ignored: FileNotFoundException) { } } batch.commit() } @Throws(CalendarStorageException::class, ContactsStorageException::class) override fun postProcess() { super.postProcess() /* VCard4 group handling: there are group contacts and individual contacts */ Logger.log.info("Assigning memberships of downloaded contact groups") LocalGroup.applyPendingMemberships(localAddressBook()) } // helpers private fun localAddressBook(): LocalAddressBook { return localCollection as LocalAddressBook } override fun processItem(item: Item) { val local = localCollection!!.findByFilename(item.uid) if (!item.isDeleted) { val inputReader = StringReader(String(item.content)) val contacts = Contact.fromReader(inputReader, resourceDownloader) if (contacts.size == 0) { Logger.log.warning("Received VCard without data, ignoring") return } else if (contacts.size > 1) { Logger.log.warning("Received multiple VCALs, using first one") } val contact = contacts[0] processContact(item, contact, local) } else { if (local != null) { Logger.log.info("Removing local record which has been deleted on the server") local.delete() } else { Logger.log.warning("Tried deleting a non-existent record: " + item.uid) } } } @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class) override fun processSyncEntryImpl(cEntry: SyncEntry) { val inputReader = StringReader(cEntry.content) val contacts = Contact.fromReader(inputReader, resourceDownloader) if (contacts.size == 0) { Logger.log.warning("Received VCard without data, ignoring") return } else if (contacts.size > 1) Logger.log.warning("Received multiple VCards, using first one") val contact = contacts[0] val local = localCollection!!.findByUid(contact.uid!!) if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { legacyProcessContact(contact, local) } else { if (local != null) { Logger.log.info("Removing local record which has been deleted on the server") local.delete() } else { Logger.log.warning("Tried deleting a non-existent record: " + contact.uid) } } } private fun processContact(item: Item, newData: Contact, _local: LocalAddress?): LocalAddress { var local = _local val uuid = newData.uid // update local contact, if it exists if (local != null) { Logger.log.log(Level.INFO, "Updating $uuid in local address book") if (local is LocalGroup && newData.group) { // update group val group: LocalGroup = local group.eTag = item.etag group.update(newData) syncResult.stats.numUpdates++ } else if (local is LocalContact && !newData.group) { // update contact val contact: LocalContact = local contact.eTag = item.etag contact.update(newData) syncResult.stats.numUpdates++ } else { // group has become an individual contact or vice versa try { local.delete() local = null } catch (e: CalendarStorageException) { // CalendarStorageException is not used by LocalGroup and LocalContact } } } if (local == null) { if (newData.group) { Logger.log.log(Level.INFO, "Creating local group", item.uid) val group = LocalGroup(localAddressBook(), newData, item.uid, item.etag) group.add() local = group } else { Logger.log.log(Level.INFO, "Creating local contact", item.uid) val contact = LocalContact(localAddressBook(), newData, item.uid, item.etag) contact.add() local = contact } syncResult.stats.numInserts++ } if (LocalContact.HASH_HACK && local is LocalContact) // workaround for Android 7 which sets DIRTY flag when only meta-data is changed local.updateHashCode(null) return local } @Throws(IOException::class, ContactsStorageException::class) private fun legacyProcessContact(newData: Contact, _local: LocalAddress?): LocalAddress { var local = _local val uuid = newData.uid // update local contact, if it exists if (local != null) { Logger.log.log(Level.INFO, "Updating $uuid in local address book") if (local is LocalGroup && newData.group) { // update group val group: LocalGroup = local group.eTag = uuid group.update(newData) syncResult.stats.numUpdates++ } else if (local is LocalContact && !newData.group) { // update contact val contact: LocalContact = local contact.eTag = uuid contact.update(newData) syncResult.stats.numUpdates++ } else { // group has become an individual contact or vice versa try { local.delete() local = null } catch (e: CalendarStorageException) { // CalendarStorageException is not used by LocalGroup and LocalContact } } } if (local == null) { if (newData.group) { Logger.log.log(Level.INFO, "Creating local group", newData.uid) val group = LocalGroup(localAddressBook(), newData, uuid, uuid) group.add() local = group } else { Logger.log.log(Level.INFO, "Creating local contact", newData.uid) val contact = LocalContact(localAddressBook(), newData, uuid, uuid) contact.add() local = contact } syncResult.stats.numInserts++ } if (LocalContact.HASH_HACK && local is LocalContact) // workaround for Android 7 which sets DIRTY flag when only meta-data is changed local.updateHashCode(null) return local } // downloader helper class class ResourceDownloader(internal var context: Context) : Contact.Downloader { override fun download(url: String, accepts: String): ByteArray? { val httpUrl = url.toHttpUrlOrNull() if (httpUrl == null) { Logger.log.log(Level.SEVERE, "Invalid external resource URL", url) return null } val host = httpUrl.host if (host == null) { Logger.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url) return null } val resourceClient = HttpClient.Builder(context).setForeground(false).build().okHttpClient try { val response = resourceClient.newCall(Request.Builder() .get() .url(httpUrl) .build()).execute() val body = response.body if (body != null) { return body.bytes() } } catch (e: IOException) { Logger.log.log(Level.SEVERE, "Couldn't download external resource", e) } return null } } }