calendar: кэш событий по CTag (Этап 2, DAV ч.2)
listCalendars теперь забирает и cs:getctag (тот же PROPFIND — ноль лишних запросов; DavCalendar.ctag). Новый CalendarEventsCache — дисковый кэш СЫРЫХ REPORT-ответов per (аккаунт, календарь, диапазон), ключ — CTag: не менялся → события парсятся с диска (боевым парсером CalDavClient), REPORT-запросы в сеть не идут. Изменился → сеть + обновление кэша. Хранение сырого XML вместо сериализации моделей — без хрупкого JSON-маппинга ~20 полей события; чистка записей старше 30 дней. CalDavClient.queryEventsInRange разделён на queryEventsRawXml + parseEventsXml (поведение прежнее). Эффект: переключение месяцев, периодический рефреш и возврат в календарь при неизменных календарях — 1 PROPFIND вместо 1+N REPORT. +5 unit-тестов кэша (JVM, TemporaryFolder).
This commit is contained in:
@@ -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 = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/></d:prop>
|
||||
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop>
|
||||
</d:propfind>
|
||||
""".trimIndent()
|
||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||
@@ -129,7 +132,15 @@ object CalDavClient {
|
||||
calendar: DavCalendar,
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
): List<DavEvent> {
|
||||
): List<DavEvent> = 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 {
|
||||
</c:calendar-query>
|
||||
""".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<DavEvent> =
|
||||
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
|
||||
|
||||
@@ -38,4 +38,5 @@ dependencies {
|
||||
implementation libs.core.ktx
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
testImplementation libs.junit
|
||||
}
|
||||
|
||||
+58
@@ -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 дней
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -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<DavCalendar> = 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<CalendarTaskItem> {
|
||||
@@ -210,13 +212,25 @@ class CalendarRepository(
|
||||
rangeStart: Instant,
|
||||
rangeEnd: Instant,
|
||||
visibleHrefs: Set<String>,
|
||||
accountKey: String,
|
||||
): List<CalendarEventItem> {
|
||||
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
|
||||
val rangeKey = "${rangeStart.epochSecond}-${rangeEnd.epochSecond}"
|
||||
val events = mutableListOf<DavEvent>()
|
||||
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,
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+54
@@ -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", "<xml>events</xml>")
|
||||
assertEquals("<xml>events</xml>", 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", "<xml/>")
|
||||
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", "", "<xml/>") // пустой 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", "<a1/>")
|
||||
c.put("acc2", "/cal/a/", "r1", "t", "<a2/>")
|
||||
c.put("acc1", "/cal/b/", "r1", "t", "<b1/>")
|
||||
assertEquals("<a1/>", c.get("acc1", "/cal/a/", "r1", "t"))
|
||||
assertEquals("<a2/>", c.get("acc2", "/cal/a/", "r1", "t"))
|
||||
assertEquals("<b1/>", c.get("acc1", "/cal/b/", "r1", "t"))
|
||||
}
|
||||
|
||||
@Test fun multiline_xml_preserved() {
|
||||
val c = cache()
|
||||
val xml = "<multistatus>\n <response>\n <href>/e1.ics</href>\n </response>\n</multistatus>"
|
||||
c.put("acc", "/cal/", "r", "ct", xml)
|
||||
assertEquals(xml, c.get("acc", "/cal/", "r", "ct"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user