From 00daa980a8f11ecefe19ac6a72a84cbbe45117dd Mon Sep 17 00:00:00 2001 From: b-dev-mobile Date: Thu, 9 Jul 2026 06:44:49 +0000 Subject: [PATCH] =?UTF-8?q?contacts:=20=D0=B8=D0=BD=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=20CTag=20(=D0=AD=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=202,=20DAV)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../f7cloud/core/network/CardDavClient.kt | 47 ++++++++++++++++ .../core/network/CardDavSignatureTest.kt | 56 +++++++++++++++++++ .../feature/contacts/ContactsRepository.kt | 19 ++++++- 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 core/network/src/test/java/ru/forbion/f7cloud/core/network/CardDavSignatureTest.kt diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt index 2a95432..80bb238 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CardDavClient.kt @@ -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 = """ + + + + + """.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() + 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.*?") + private val hrefTagPattern = Regex("(?is)<(?:\\w+:)?href>(.*?)") + private val getctagPattern = Regex("(?is)<(?:\\w+:)?getctag>(.*?)") + private val syncTokenPattern = Regex("(?is)<(?:\\w+:)?sync-token>(.*?)") + fun createContact( client: OkHttpClient, serverUrl: String, diff --git a/core/network/src/test/java/ru/forbion/f7cloud/core/network/CardDavSignatureTest.kt b/core/network/src/test/java/ru/forbion/f7cloud/core/network/CardDavSignatureTest.kt new file mode 100644 index 0000000..5603c77 --- /dev/null +++ b/core/network/src/test/java/ru/forbion/f7cloud/core/network/CardDavSignatureTest.kt @@ -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) = + """${responses.joinToString("")}""" + + private fun resp(href: String, ctag: String? = null, token: String? = null) = """ + + $href + + ${ctag?.let { "$it" } ?: ""} + ${token?.let { "$it" } ?: ""} + + + """.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/")))) + } +} diff --git a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt index 93e5f92..ba82d89 100644 --- a/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt +++ b/feature/contacts/src/main/java/ru/forbion/f7cloud/feature/contacts/ContactsRepository.kt @@ -74,6 +74,18 @@ class ContactsRepository(context: Context) { ) { 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 entities = remote.map { contact -> ContactEntity( @@ -95,11 +107,12 @@ class ContactsRepository(context: Context) { ) } dao.replaceAll(key, entities) - prefs.edit() + val editor = prefs.edit() .putLong(lastSyncKey(key), System.currentTimeMillis()) .putBoolean(photoSyncDoneKey(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() } } @@ -190,6 +203,8 @@ class ContactsRepository(context: Context) { 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 detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"