diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt
index 280400a..d6e908a 100644
--- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt
+++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt
@@ -22,6 +22,9 @@ data class DavCalendar(
val href: String,
val displayName: String,
val color: String? = null,
+ // CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
+ // меняется при любом изменении в календаре → ключ инкрементального кэша событий.
+ val ctag: String = "",
)
data class DavEvent(
@@ -103,7 +106,7 @@ object CalDavClient {
val body = """
-
+
""".trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body)
@@ -129,7 +132,15 @@ object CalDavClient {
calendar: DavCalendar,
rangeStart: Instant,
rangeEnd: Instant,
- ): List {
+ ): List = parseEventsXml(queryEventsRawXml(client, calendar, rangeStart, rangeEnd), calendar)
+
+ /** Сырой REPORT-ответ (для кэширования на диске с ключом по CTag). */
+ fun queryEventsRawXml(
+ client: OkHttpClient,
+ calendar: DavCalendar,
+ rangeStart: Instant,
+ rangeEnd: Instant,
+ ): String {
val startStr = formatCalDavTime(rangeStart)
val endStr = formatCalDavTime(rangeEnd)
val body = """
@@ -146,10 +157,13 @@ object CalDavClient {
""".trimIndent()
val href = calendar.href.trimEnd('/') + "/"
- val xml = report(client, href, body)
- return parseCalendarQueryResponses(xml, calendar)
+ return report(client, href, body)
}
+ /** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
+ fun parseEventsXml(xml: String, calendar: DavCalendar): List =
+ parseCalendarQueryResponses(xml, calendar)
+
fun createEvent(
client: OkHttpClient,
calendar: DavCalendar,
@@ -782,6 +796,7 @@ object CalDavClient {
var href = ""
var displayName = ""
var calendarColor: String? = null
+ var ctag = ""
var isCollection = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
@@ -791,11 +806,13 @@ object CalDavClient {
href = ""
displayName = ""
calendarColor = null
+ ctag = ""
isCollection = false
}
"collection" -> if (inResponse) isCollection = true
"displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
+ "getctag" -> if (inResponse) ctag = parser.readText().trim()
"href" -> if (inResponse) href = parser.readText().trim()
}
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
@@ -809,7 +826,7 @@ object CalDavClient {
val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
}
- out += DavCalendar(href = full, displayName = name, color = calendarColor)
+ out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag)
}
}
inResponse = false
diff --git a/feature/calendar/build.gradle b/feature/calendar/build.gradle
index 18b3dfb..a1f6104 100644
--- a/feature/calendar/build.gradle
+++ b/feature/calendar/build.gradle
@@ -38,4 +38,5 @@ dependencies {
implementation libs.core.ktx
implementation libs.coil.compose
implementation libs.coil.svg
+ testImplementation libs.junit
}
diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarEventsCache.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarEventsCache.kt
new file mode 100644
index 0000000..e82fcc2
--- /dev/null
+++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarEventsCache.kt
@@ -0,0 +1,58 @@
+package ru.forbion.f7cloud.feature.calendar
+
+import android.content.Context
+import java.io.File
+import java.security.MessageDigest
+
+/**
+ * Дисковый кэш «сырых» REPORT-ответов CalDAV (события по диапазону), ключуемый
+ * CTag'ом календаря: CTag не изменился → события читаются с диска, сеть не трогаем.
+ *
+ * Храним именно сырой XML (а не сериализованные модели) — переиспользуем боевой
+ * парсер CalDavClient и не заводим хрупкий JSON-маппинг ~20 полей события.
+ * Формат файла: первая строка — CTag на момент загрузки, дальше XML.
+ */
+class CalendarEventsCache(private val dir: File) {
+
+ constructor(context: Context) : this(File(context.cacheDir, "caldav_events"))
+
+ /** XML событий, если кэш есть и его CTag совпадает с текущим; иначе null. */
+ fun get(accountKey: String, calendarHref: String, rangeKey: String, ctag: String): String? {
+ if (ctag.isBlank()) return null
+ val f = entryFile(accountKey, calendarHref, rangeKey)
+ if (!f.isFile) return null
+ return runCatching {
+ val text = f.readText()
+ val nl = text.indexOf('\n')
+ if (nl > 0 && text.substring(0, nl) == ctag) text.substring(nl + 1) else null
+ }.getOrNull()
+ }
+
+ fun put(accountKey: String, calendarHref: String, rangeKey: String, ctag: String, xml: String) {
+ if (ctag.isBlank()) return
+ runCatching {
+ dir.mkdirs()
+ entryFile(accountKey, calendarHref, rangeKey).writeText("$ctag\n$xml")
+ prune()
+ }
+ }
+
+ /** Чистка устаревших записей (диапазоны уезжают со временем — файлы копятся). */
+ private fun prune(maxAgeMillis: Long = MAX_AGE_MILLIS) {
+ val now = System.currentTimeMillis()
+ dir.listFiles()?.forEach { f ->
+ if (now - f.lastModified() > maxAgeMillis) f.delete()
+ }
+ }
+
+ private fun entryFile(accountKey: String, calendarHref: String, rangeKey: String): File =
+ File(dir, md5("$accountKey|$calendarHref|$rangeKey") + ".xml")
+
+ private fun md5(s: String): String =
+ MessageDigest.getInstance("MD5").digest(s.toByteArray())
+ .joinToString("") { "%02x".format(it) }
+
+ private companion object {
+ const val MAX_AGE_MILLIS = 30L * 24 * 3600 * 1000 // 30 дней
+ }
+}
diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt
index 1988f79..4031521 100644
--- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt
+++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarRepository.kt
@@ -22,6 +22,8 @@ import java.time.ZoneOffset
class CalendarRepository(
private val apiClient: CalendarApiClient = CalendarApiClient(),
+ // Кэш событий по CTag (null — без кэша, поведение как раньше)
+ private val eventsCache: CalendarEventsCache? = null,
) {
fun listCalendars(session: AuthSession): List = openDavContext(session).calendars
@@ -38,7 +40,7 @@ class CalendarRepository(
val ctx = openDavContext(session)
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
- return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
+ return fetchEvents(ctx, startInstant, endInstant, visibleHrefs, accountKey(session))
}
fun loadUnscheduledTasks(session: AuthSession): List {
@@ -210,13 +212,25 @@ class CalendarRepository(
rangeStart: Instant,
rangeEnd: Instant,
visibleHrefs: Set,
+ accountKey: String,
): List {
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
+ val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
val events = mutableListOf()
var successCount = 0
var lastError: String? = null
for (cal in calendars.take(12)) {
- runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
+ runCatching {
+ // CTag календаря не менялся → сырой REPORT-ответ берём с диска, сеть не трогаем
+ val cachedXml = eventsCache?.get(accountKey, cal.href, rangeKey, cal.ctag)
+ if (cachedXml != null) {
+ CalDavClient.parseEventsXml(cachedXml, cal)
+ } else {
+ val xml = CalDavClient.queryEventsRawXml(ctx.client, cal, rangeStart, rangeEnd)
+ eventsCache?.put(accountKey, cal.href, rangeKey, cal.ctag, xml)
+ CalDavClient.parseEventsXml(xml, cal)
+ }
+ }
.onSuccess { successCount++; events += it }
.onFailure { lastError = it.message }
}
@@ -226,6 +240,9 @@ class CalendarRepository(
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
}
+ private fun accountKey(session: AuthSession): String =
+ "${session.serverUrl}|${session.username}"
+
private fun DavEvent.toItem() = CalendarEventItem(
uid = uid,
href = href,
diff --git a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt
index 1420153..0165334 100644
--- a/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt
+++ b/feature/calendar/src/main/java/ru/forbion/f7cloud/feature/calendar/CalendarViewModel.kt
@@ -62,7 +62,9 @@ data class CalendarUiState(
class CalendarViewModel(
context: Context,
- private val repository: CalendarRepository = CalendarRepository(),
+ private val repository: CalendarRepository = CalendarRepository(
+ eventsCache = CalendarEventsCache(context.applicationContext),
+ ),
) : ViewModel() {
private val appContext = context.applicationContext
private val deviceClient = DeviceCalendarClient(appContext)
diff --git a/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarEventsCacheTest.kt b/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarEventsCacheTest.kt
new file mode 100644
index 0000000..76d700a
--- /dev/null
+++ b/feature/calendar/src/test/java/ru/forbion/f7cloud/feature/calendar/CalendarEventsCacheTest.kt
@@ -0,0 +1,54 @@
+package ru.forbion.f7cloud.feature.calendar
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+/**
+ * Кэш «сырых» REPORT-ответов CalDAV: CTag совпал → XML с диска; изменился/пусто → null.
+ */
+class CalendarEventsCacheTest {
+
+ @get:Rule
+ val tmp = TemporaryFolder()
+
+ private fun cache() = CalendarEventsCache(tmp.newFolder("caldav"))
+
+ @Test fun hit_when_ctag_matches() {
+ val c = cache()
+ c.put("acc", "/cal/personal/", "r1", "ctag-1", "events")
+ assertEquals("events", c.get("acc", "/cal/personal/", "r1", "ctag-1"))
+ }
+
+ @Test fun miss_when_ctag_changed() {
+ val c = cache()
+ c.put("acc", "/cal/personal/", "r1", "ctag-1", "")
+ assertNull(c.get("acc", "/cal/personal/", "r1", "ctag-2"))
+ }
+
+ @Test fun miss_on_blank_ctag_and_unknown_range() {
+ val c = cache()
+ c.put("acc", "/cal/personal/", "r1", "", "") // пустой ctag не кэшируется
+ assertNull(c.get("acc", "/cal/personal/", "r1", ""))
+ assertNull(c.get("acc", "/cal/personal/", "other-range", "ctag-1"))
+ }
+
+ @Test fun entries_are_isolated_by_account_calendar_range() {
+ val c = cache()
+ c.put("acc1", "/cal/a/", "r1", "t", "")
+ c.put("acc2", "/cal/a/", "r1", "t", "")
+ c.put("acc1", "/cal/b/", "r1", "t", "")
+ assertEquals("", c.get("acc1", "/cal/a/", "r1", "t"))
+ assertEquals("", c.get("acc2", "/cal/a/", "r1", "t"))
+ assertEquals("", c.get("acc1", "/cal/b/", "r1", "t"))
+ }
+
+ @Test fun multiline_xml_preserved() {
+ val c = cache()
+ val xml = "\n \n /e1.ics\n \n"
+ c.put("acc", "/cal/", "r", "ct", xml)
+ assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
+ }
+}