Commit f0e1e030 authored by Ricki Hirner's avatar Ricki Hirner

New sync logic with XML streaming

* refactored dav4android to use XML streaming
* refactored collection detection
* refactored sync logic
parent 23589969
Pipeline #25590277 passed with stages
in 6 minutes and 38 seconds
......@@ -18,7 +18,7 @@ android {
defaultConfig {
applicationId "at.bitfire.davdroid"
versionCode 233
versionCode 235
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
buildConfigField "boolean", "customCerts", "true"
......@@ -33,7 +33,7 @@ android {
productFlavors {
standard {
versionName "1.11.5-ose"
versionName "1.12-beta1-ose"
buildConfigField "boolean", "customCerts", "true"
}
......
......@@ -55,14 +55,15 @@ class CollectionInfoTest {
"</response>" +
"</multistatus>"))
var info: CollectionInfo? = null
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME).use {
val info = CollectionInfo(it)
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type)
assertFalse(info.readOnly)
assertEquals("My Contacts", info.displayName)
assertEquals("My Contacts Description", info.description)
.propfind(0, ResourceType.NAME) { response, _ ->
info = CollectionInfo(response)
}
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info?.type)
assertFalse(info!!.readOnly)
assertEquals("My Contacts", info?.displayName)
assertEquals("My Contacts Description", info?.description)
// read-only calendar, no display name
server.enqueue(MockResponse()
......@@ -80,18 +81,19 @@ class CollectionInfoTest {
"</response>" +
"</multistatus>"))
info = null
DavResource(httpClient.okHttpClient, server.url("/"))
.propfind(0, ResourceType.NAME).use {
val info = CollectionInfo(it)
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
assertTrue(info.readOnly)
assertNull(info.displayName)
assertEquals("My Calendar", info.description)
assertEquals(0xFFFF0000.toInt(), info.color)
assertEquals("tzdata", info.timeZone)
assertTrue(info.supportsVEVENT)
assertTrue(info.supportsVTODO)
.propfind(0, ResourceType.NAME) { response, _ ->
info = CollectionInfo(response)
}
assertEquals(CollectionInfo.Type.CALENDAR, info?.type)
assertTrue(info!!.readOnly)
assertNull(info?.displayName)
assertEquals("My Calendar", info?.description)
assertEquals(0xFFFF0000.toInt(), info?.color)
assertEquals("tzdata", info?.timeZone)
assertTrue(info!!.supportsVEVENT)
assertTrue(info!!.supportsVTODO)
}
@Test
......
......@@ -69,26 +69,24 @@ class DavResourceFinderTest {
@SmallTest
fun testRememberIfAddressBookOrHomeset() {
// recognize home set
var info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
.propfind(0, AddressbookHomeSet.NAME).use {
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(it, info)
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
}
.propfind(0, AddressbookHomeSet.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(0, info.collections.size)
assertEquals(1, info.homeSets.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
// recognize address book
info = ServiceInfo()
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
.propfind(0, ResourceType.NAME).use {
ServiceInfo().let { info ->
finder.rememberIfAddressBookOrHomeset(it, info)
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
}
.propfind(0, ResourceType.NAME) { response, _ ->
finder.scanCardDavResponse(response, info)
}
assertEquals(1, info.collections.size)
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
assertEquals(0, info.homeSets.size)
}
@Test
......
/*
* 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.davdroid
import at.bitfire.davdroid.resource.LocalResource
import okhttp3.HttpUrl
class ExceptionInfo(
val exception: Throwable,
val context: Any
): Exception()
\ No newline at end of file
......@@ -48,7 +48,7 @@ class HttpClient private constructor(
/** [OkHttpClient] singleton to build all clients from */
val sharedClient = OkHttpClient.Builder()
// set timeouts
.connectTimeout(30, TimeUnit.SECONDS)
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
......
......@@ -11,7 +11,7 @@ package at.bitfire.davdroid.model
import android.content.ContentValues
import android.os.Parcel
import android.os.Parcelable
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.Response
import at.bitfire.dav4android.UrlUtils
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.model.ServiceDB.Collections
......@@ -58,7 +58,7 @@ data class CollectionInfo(
WEBCAL // iCalendar subscription
}
constructor(dav: DavResponse): this(UrlUtils.withTrailingSlash(dav.url)) {
constructor(dav: Response): this(UrlUtils.withTrailingSlash(dav.href)) {
dav[ResourceType::class.java]?.let { type ->
when {
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
......
......@@ -45,8 +45,8 @@ data class SyncState(
}
}
fun fromSyncToken(token: SyncToken) =
token.token?.let { SyncState(Type.SYNC_TOKEN, it) }
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
}
......
......@@ -13,6 +13,7 @@ import android.accounts.AccountManager
import android.content.*
import android.content.pm.PackageManager
import android.database.DatabaseUtils
import android.os.Build
import android.os.Bundle
import android.provider.ContactsContract
import android.support.v4.content.ContextCompat
......@@ -28,14 +29,14 @@ import at.bitfire.davdroid.ui.AccountActivity
import okhttp3.HttpUrl
import java.util.logging.Level
class AddressBooksSyncAdapterService: SyncAdapterService() {
class AddressBooksSyncAdapterService : SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
class AddressBooksSyncAdapter(
context: Context
): SyncAdapter(context) {
) : SyncAdapter(context) {
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
......@@ -58,7 +59,7 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
} catch(e: Exception) {
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't sync address books", e)
}
......@@ -71,8 +72,8 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
......@@ -83,7 +84,7 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues(cursor.columnCount)
DatabaseUtils.cursorRowToContentValues(cursor, values)
......@@ -114,36 +115,43 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
}
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return
}
try {
if (contactsProvider == null) {
Logger.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return
}
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = HttpUrl.parse(addressBook.url)!!
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch(e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
// delete/update local address books
for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) {
val url = HttpUrl.parse(addressBook.url)!!
val info = remote[url]
if (info == null) {
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
Logger.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remote -= url
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local address books
for ((_, info) in remote) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, contactsProvider, account, info)
// create new local address books
for ((_, info) in remote) {
Logger.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, contactsProvider, account, info)
}
} finally {
if (Build.VERSION.SDK_INT >= 24)
contactsProvider?.close()
else
contactsProvider?.release()
}
}
}
......
......@@ -14,11 +14,14 @@ import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.DavResponseCallback
import at.bitfire.dav4android.Response
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.SyncState
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
......@@ -45,17 +48,9 @@ class CalendarSyncManager(
authority: String,
syncResult: SyncResult,
localCalendar: LocalCalendar
): BaseDavSyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
companion object {
const val MULTIGET_MAX_RESOURCES = 30
}
): SyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
override fun prepare(): Boolean {
if (!super.prepare())
return false
collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
......@@ -65,16 +60,21 @@ class CalendarSyncManager(
return true
}
override fun queryCapabilities() =
override fun queryCapabilities(): SyncState? =
useRemoteCollection {
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME).use { dav ->
dav[SupportedReportSet::class.java]?.let {
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
var syncState: SyncState? = null
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[SupportedReportSet::class.java]?.let {
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState(dav)
syncState = syncState(response)
}
}
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState
}
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
......@@ -95,7 +95,7 @@ class CalendarSyncManager(
)
}
override fun listAllRemote(): Map<String, DavResponse> {
override fun listAllRemote(callback: DavResponseCallback) {
// calculate time range limits
var limitStart: Date? = null
accountSettings.getTimeRangePastDays()?.let { pastDays ->
......@@ -105,68 +105,46 @@ class CalendarSyncManager(
}
return useRemoteCollection { remote ->
// fetch list of remote VEVENTs and build hash table to index file name
Logger.log.info("Querying events since $limitStart")
remote.calendarQuery("VEVENT", limitStart, null).use { dav ->
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
for (iCal in dav.members) {
val fileName = iCal.fileName()
Logger.log.fine("Found remote VEVENT: $fileName")
result[fileName] = iCal
}
result
}
remote.calendarQuery("VEVENT", limitStart, null, callback)
}
}
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted)
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local event $name")
useLocal(it) { local -> local.delete() }
syncResult.stats.numDeletes++
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch")
if (bunch.size == 1) {
val remote = bunch.first()
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response ->
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
?: throw DavException("Received CalDAV GET response without ETag")
response.body()!!.use {
processVEvent(resource.fileName(), eTag, it.charStream())
}
}
}
} else
// multiple iCalendars, use calendar-multi-get
useRemoteCollection {
it.multiget(bunch) { response, _ ->
useRemote(response) {
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val toDownload = changes.updated.map { it.url }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, bunch.first())) {
it.get(DavCalendar.MIME_ICALENDAR.toString()).use { dav ->
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = dav[GetETag::class.java]?.eTag
?: throw DavException("Received CalDAV GET response without ETag for ${dav.url}")
val calendarData = response[CalendarData::class.java]
val iCal = calendarData?.iCalendar
?: throw DavException("Received multi-get response without address data")
dav.body?.charStream()?.use { reader ->
processVEvent(dav.fileName(), eTag, reader)
}
}
}
else {
// multiple contacts, use multi-get
useRemoteCollection {
it.multiget(bunch).use { dav ->
// process multiget results
for (remote in dav.members)
useRemote(remote) {
val eTag = remote[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote[CalendarData::class.java]
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
}
processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal))
}
}
}
}
abortIfCancelled()
}
override fun postProcess() {
}
......
......@@ -16,10 +16,12 @@ import android.provider.ContactsContract.Groups
import android.support.v4.app.NotificationCompat
import at.bitfire.dav4android.DavAddressBook
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.DavResponse
import at.bitfire.dav4android.DavResponseCallback
import at.bitfire.dav4android.Response
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
......@@ -35,7 +37,6 @@ import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import java.io.*
import java.util.*
import java.util.logging.Level
/**
......@@ -83,11 +84,9 @@ class ContactsSyncManager(
syncResult: SyncResult,
val provider: ContentProviderClient,
localAddressBook: LocalAddressBook
): BaseDavSyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
): SyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
companion object {
private const val MULTIGET_MAX_RESOURCES = 10
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
}
......@@ -97,11 +96,13 @@ class ContactsSyncManager(
private var hasVCard4 = false
private val groupMethod = accountSettings.getGroupMethod()
/**
* Used to download images which are referenced by URL
*/
private lateinit var resourceDownloader: ResourceDownloader
override fun prepare(): Boolean {
if (!super.prepare())
return false
override fun prepare(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localCollection.verifyDirty()
......@@ -115,6 +116,8 @@ class ContactsSyncManager(
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
resourceDownloader = ResourceDownloader(davCollection.location)
return true
}
......@@ -124,19 +127,25 @@ class ContactsSyncManager(
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
return useRemoteCollection {
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME).use { dav ->
dav[SupportedAddressData::class.java]?.let {
hasVCard4 = it.hasVCard4()
}
Logger.log.info("Server supports vCard/4: $hasVCard4")
var syncState: SyncState? = null
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation ->
if (relation == Response.HrefRelation.SELF) {
response[SupportedAddressData::class.java]?.let {
hasVCard4 = it.hasVCard4()
}
dav[SupportedReportSet::class.java]?.let {
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
response[SupportedReportSet::class.java]?.let {
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
}
syncState(dav)
syncState = syncState(response)
}
}
Logger.log.info("Server supports vCard/4: $hasVCard4")
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
syncState
}
}
......@@ -282,79 +291,43 @@ class ContactsSyncManager(
)
}
override fun listAllRemote() = useRemoteCollection {
// fetch list of remote VCards and build hash table to index file name
it.propfind(1, ResourceType.NAME, GetETag.NAME).use { dav ->
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
for (vCard in dav.members) {
// ignore member collections
var ignore = false
vCard[ResourceType::class.java]?.let { type ->
if (type.types.contains(ResourceType.COLLECTION))
ignore = true
}
if (ignore)
continue
val fileName = vCard.fileName()
Logger.log.fine("Found remote VCard: $fileName")
result[fileName] = vCard
}
result
}
}
override fun processRemoteChanges(changes: RemoteChanges) {
for (name in changes.deleted)
localCollection.findByName(name)?.let {
Logger.log.info("Deleting local address $name")
useLocal(it) { it.delete() }
syncResult.stats.numDeletes++
override fun listAllRemote(callback: DavResponseCallback) =
useRemoteCollection {
it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback)
}
val toDownload = changes.updated.map { it.url }
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
// prepare downloader which may be used to download external resource like contact photos
val downloader = ResourceDownloader(collectionURL)
// download new/updated VCards from server
for (bunch in toDownload.chunked(CalendarSyncManager.MULTIGET_MAX_RESOURCES)) {
if (bunch.size == 1)
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, bunch.first())) {
it.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5").use { dav ->
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = dav[GetETag::class.java]?.eTag
?: throw DavException("Received CardDAV GET response without ETag for ${dav.url}")
dav.body?.charStream()?.use { reader ->
processVCard(dav.fileName(), eTag, reader, downloader)
}
override fun downloadRemote(bunch: List<HttpUrl>) {
Logger.log.info("Downloading ${bunch.size} vCards: $bunch")
if (bunch.size == 1) {
val remote = bunch.first()
// only one contact, use GET
useRemote(DavResource(httpClient.okHttpClient, remote)) { resource ->
resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response ->
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = response.header("ETag")?.let { GetETag(it).eTag }
?: throw DavException("Received CardDAV GET response without ETag")
response.body()!!.use {
processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader)
}
}
else {
// multiple contacts, use multi-get
useRemoteCollection {
it.multiget(bunch, hasVCard4).use { dav ->
// process multi-get results
for (remote in dav.members)
useRemote(remote) {
val eTag = remote[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val addressData = remote[AddressData::class.java]
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
}
}
} else
// multiple vCards, use addressbook-multi-get
useRemoteCollection {
it.multiget(bunch, hasVCard4) { response, _ ->
useRemote(response) {
val eTag = response[GetETag::class.java]?.eTag
?: throw DavException("Received multi-get response without ETag")
val addressData = response[AddressData::class.java]
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader)