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"