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.*?(?:\\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,
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"