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}" }
|
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(
|
fun createContact(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
serverUrl: String,
|
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/"))))
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-2
@@ -74,6 +74,18 @@ class ContactsRepository(context: Context) {
|
|||||||
) {
|
) {
|
||||||
return dao.getAll(key).map { it.toItem() }
|
return dao.getAll(key).map { it.toItem() }
|
||||||
}
|
}
|
||||||
|
// Дешёвая проверка CTag: если адресные книги не менялись (и бэкфиллы сделаны) —
|
||||||
|
// полный PROPFIND всех vCard не нужен, отдаём кэш. Это ускоряет pull-to-refresh.
|
||||||
|
val needsBackfill = needsPhotoBackfill || needsDetailsBackfill
|
||||||
|
val client = authedClient(session)
|
||||||
|
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
|
||||||
|
val currentSig = CardDavClient.collectionSignature(client, session.serverUrl, userId)
|
||||||
|
if (!needsBackfill && currentSig != null &&
|
||||||
|
currentSig == prefs.getString(ctagKey(key), null)
|
||||||
|
) {
|
||||||
|
prefs.edit().putLong(lastSyncKey(key), System.currentTimeMillis()).apply()
|
||||||
|
return dao.getAll(key).map { it.toItem() }
|
||||||
|
}
|
||||||
val remote = fetchRemoteContacts(session)
|
val remote = fetchRemoteContacts(session)
|
||||||
val entities = remote.map { contact ->
|
val entities = remote.map { contact ->
|
||||||
ContactEntity(
|
ContactEntity(
|
||||||
@@ -95,11 +107,12 @@ class ContactsRepository(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
dao.replaceAll(key, entities)
|
dao.replaceAll(key, entities)
|
||||||
prefs.edit()
|
val editor = prefs.edit()
|
||||||
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
.putLong(lastSyncKey(key), System.currentTimeMillis())
|
||||||
.putBoolean(photoSyncDoneKey(key), true)
|
.putBoolean(photoSyncDoneKey(key), true)
|
||||||
.putBoolean(detailsSyncDoneKey(key), true)
|
.putBoolean(detailsSyncDoneKey(key), true)
|
||||||
.apply()
|
if (currentSig != null) editor.putString(ctagKey(key), currentSig) else editor.remove(ctagKey(key))
|
||||||
|
editor.apply()
|
||||||
return entities.map { it.toItem() }
|
return entities.map { it.toItem() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +203,8 @@ class ContactsRepository(context: Context) {
|
|||||||
|
|
||||||
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
|
||||||
|
|
||||||
|
private fun ctagKey(accountKey: String) = "ctag_$accountKey"
|
||||||
|
|
||||||
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
|
||||||
|
|
||||||
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
|
||||||
|
|||||||
Reference in New Issue
Block a user