contacts: инкрементальная синхронизация по CTag (Этап 2, DAV)
CardDavClient.collectionSignature() — дешёвый PROPFIND depth:1 за CTag книг (расширение CalendarServer, Nextcloud; фолбэк sync-token), без address-data. ContactsRepository.syncContacts: перед полной закачкой всех vCard сверяет подпись коллекций; не менялось → отдаём кэш (Room), не качаем. Ускоряет pull-to-refresh (раньше — полный PROPFIND ~500 vCard каждый раз). +4 unit-теста на parseCollectionSignature (regex-парсер, без Android XmlPull).
This commit is contained in:
@@ -69,6 +69,53 @@ object CardDavClient {
|
||||
return out.distinctBy { "${it.uid}|${it.email}" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Дешёвая «подпись» состояния адресных книг — CTag коллекций (расширение
|
||||
* CalendarServer, поддерживается Nextcloud), с фолбэком на sync-token.
|
||||
* Один PROPFIND depth:1 без address-data. Если подпись не изменилась с прошлой
|
||||
* синхронизации — контакты качать не нужно. null при ошибке/неподдержке → полный sync.
|
||||
*/
|
||||
fun collectionSignature(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
): String? = runCatching {
|
||||
val base = davAddressBooksBaseUrl(serverUrl, userId)
|
||||
val body = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop><cs:getctag/><d:sync-token/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, base, depth = 1, body)
|
||||
parseCollectionSignature(xml).ifBlank { null }
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Парсит PROPFIND-ответ в стабильную подпись «href=ctag» (по книгам, отсортировано).
|
||||
* Regex-парсинг (как ICS в проекте) — чистая функция, тестируется без Android XmlPull.
|
||||
*/
|
||||
internal fun parseCollectionSignature(xml: String): String {
|
||||
val entries = sortedSetOf<String>()
|
||||
for (m in responseBlockPattern.findAll(xml)) {
|
||||
val block = m.value
|
||||
val href = hrefTagPattern.find(block)?.groupValues?.get(1)?.trim().orEmpty()
|
||||
val tag = (
|
||||
getctagPattern.find(block)?.groupValues?.get(1)
|
||||
?: syncTokenPattern.find(block)?.groupValues?.get(1)
|
||||
)?.trim().orEmpty()
|
||||
if (href.isNotBlank() && tag.isNotBlank()) {
|
||||
entries += "$href=$tag"
|
||||
}
|
||||
}
|
||||
return entries.joinToString("\n")
|
||||
}
|
||||
|
||||
private val responseBlockPattern = Regex("(?is)<(?:\\w+:)?response\\b.*?</(?:\\w+:)?response>")
|
||||
private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)</(?:\\w+:)?href>")
|
||||
private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)</(?:\\w+:)?getctag>")
|
||||
private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)</(?:\\w+:)?sync-token>")
|
||||
|
||||
fun createContact(
|
||||
client: OkHttpClient,
|
||||
serverUrl: String,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package ru.forbion.f7cloud.core.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Тесты подписи коллекций CardDAV (CTag/sync-token) — основа инкрементальной
|
||||
* синхронизации: если подпись не изменилась, контакты не перекачиваются.
|
||||
*/
|
||||
class CardDavSignatureTest {
|
||||
|
||||
private val ns = """xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/""""
|
||||
|
||||
private fun body(vararg responses: String) =
|
||||
"""<?xml version="1.0"?><d:multistatus $ns>${responses.joinToString("")}</d:multistatus>"""
|
||||
|
||||
private fun resp(href: String, ctag: String? = null, token: String? = null) = """
|
||||
<d:response>
|
||||
<d:href>$href</d:href>
|
||||
<d:propstat><d:prop>
|
||||
${ctag?.let { "<cs:getctag>$it</cs:getctag>" } ?: ""}
|
||||
${token?.let { "<d:sync-token>$it</d:sync-token>" } ?: ""}
|
||||
</d:prop></d:propstat>
|
||||
</d:response>
|
||||
""".trimIndent()
|
||||
|
||||
@Test fun ctag_signature_is_stable_and_sorted() {
|
||||
val a = CardDavClient.parseCollectionSignature(
|
||||
body(resp("/dav/addressbooks/u/b1/", ctag = "111"), resp("/dav/addressbooks/u/b2/", ctag = "222")),
|
||||
)
|
||||
// порядок ответов не влияет на подпись
|
||||
val b = CardDavClient.parseCollectionSignature(
|
||||
body(resp("/dav/addressbooks/u/b2/", ctag = "222"), resp("/dav/addressbooks/u/b1/", ctag = "111")),
|
||||
)
|
||||
assertEquals(a, b)
|
||||
assertTrue(a.contains("/dav/addressbooks/u/b1/=111"))
|
||||
assertTrue(a.contains("/dav/addressbooks/u/b2/=222"))
|
||||
}
|
||||
|
||||
@Test fun ctag_change_changes_signature() {
|
||||
val before = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "111")))
|
||||
val after = CardDavClient.parseCollectionSignature(body(resp("/b1/", ctag = "999")))
|
||||
assertNotEquals(before, after)
|
||||
}
|
||||
|
||||
@Test fun sync_token_fallback_when_no_ctag() {
|
||||
val sig = CardDavClient.parseCollectionSignature(body(resp("/b1/", token = "http://sabre/sync/42")))
|
||||
assertEquals("/b1/=http://sabre/sync/42", sig)
|
||||
}
|
||||
|
||||
@Test fun empty_when_no_tags() {
|
||||
assertEquals("", CardDavClient.parseCollectionSignature(body(resp("/b1/"))))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user