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")) + } +}