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 href: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
|
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
|
||||||
|
// меняется при любом изменении в календаре → ключ инкрементального кэша событий.
|
||||||
|
val ctag: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DavEvent(
|
data class DavEvent(
|
||||||
@@ -103,7 +106,7 @@ object CalDavClient {
|
|||||||
val body = """
|
val body = """
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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: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>
|
</d:propfind>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val xml = propfind(client, baseUrl, depth = 1, body)
|
val xml = propfind(client, baseUrl, depth = 1, body)
|
||||||
@@ -129,7 +132,15 @@ object CalDavClient {
|
|||||||
calendar: DavCalendar,
|
calendar: DavCalendar,
|
||||||
rangeStart: Instant,
|
rangeStart: Instant,
|
||||||
rangeEnd: 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 startStr = formatCalDavTime(rangeStart)
|
||||||
val endStr = formatCalDavTime(rangeEnd)
|
val endStr = formatCalDavTime(rangeEnd)
|
||||||
val body = """
|
val body = """
|
||||||
@@ -146,10 +157,13 @@ object CalDavClient {
|
|||||||
</c:calendar-query>
|
</c:calendar-query>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val href = calendar.href.trimEnd('/') + "/"
|
val href = calendar.href.trimEnd('/') + "/"
|
||||||
val xml = report(client, href, body)
|
return report(client, href, body)
|
||||||
return parseCalendarQueryResponses(xml, calendar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Парсинг REPORT-ответа (в т.ч. взятого из кэша). */
|
||||||
|
fun parseEventsXml(xml: String, calendar: DavCalendar): List<DavEvent> =
|
||||||
|
parseCalendarQueryResponses(xml, calendar)
|
||||||
|
|
||||||
fun createEvent(
|
fun createEvent(
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
calendar: DavCalendar,
|
calendar: DavCalendar,
|
||||||
@@ -782,6 +796,7 @@ object CalDavClient {
|
|||||||
var href = ""
|
var href = ""
|
||||||
var displayName = ""
|
var displayName = ""
|
||||||
var calendarColor: String? = null
|
var calendarColor: String? = null
|
||||||
|
var ctag = ""
|
||||||
var isCollection = false
|
var isCollection = false
|
||||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
when (parser.eventType) {
|
when (parser.eventType) {
|
||||||
@@ -791,11 +806,13 @@ object CalDavClient {
|
|||||||
href = ""
|
href = ""
|
||||||
displayName = ""
|
displayName = ""
|
||||||
calendarColor = null
|
calendarColor = null
|
||||||
|
ctag = ""
|
||||||
isCollection = false
|
isCollection = false
|
||||||
}
|
}
|
||||||
"collection" -> if (inResponse) isCollection = true
|
"collection" -> if (inResponse) isCollection = true
|
||||||
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
"displayname" -> if (inResponse) displayName = parser.readText().trim()
|
||||||
"calendar-color" -> if (inResponse) calendarColor = 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()
|
"href" -> if (inResponse) href = parser.readText().trim()
|
||||||
}
|
}
|
||||||
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
XmlPullParser.END_TAG -> if (parser.localTag() == "response" && inResponse) {
|
||||||
@@ -809,7 +826,7 @@ object CalDavClient {
|
|||||||
val name = displayName.ifBlank {
|
val name = displayName.ifBlank {
|
||||||
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
|
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
|
inResponse = false
|
||||||
|
|||||||
@@ -38,4 +38,5 @@ dependencies {
|
|||||||
implementation libs.core.ktx
|
implementation libs.core.ktx
|
||||||
implementation libs.coil.compose
|
implementation libs.coil.compose
|
||||||
implementation libs.coil.svg
|
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(
|
class CalendarRepository(
|
||||||
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
private val apiClient: CalendarApiClient = CalendarApiClient(),
|
||||||
|
// Кэш событий по CTag (null — без кэша, поведение как раньше)
|
||||||
|
private val eventsCache: CalendarEventsCache? = null,
|
||||||
) {
|
) {
|
||||||
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ class CalendarRepository(
|
|||||||
val ctx = openDavContext(session)
|
val ctx = openDavContext(session)
|
||||||
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
|
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
|
||||||
val endInstant = rangeEnd.plusDays(1).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> {
|
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
|
||||||
@@ -210,13 +212,25 @@ class CalendarRepository(
|
|||||||
rangeStart: Instant,
|
rangeStart: Instant,
|
||||||
rangeEnd: Instant,
|
rangeEnd: Instant,
|
||||||
visibleHrefs: Set<String>,
|
visibleHrefs: Set<String>,
|
||||||
|
accountKey: String,
|
||||||
): List<CalendarEventItem> {
|
): List<CalendarEventItem> {
|
||||||
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
|
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>()
|
val events = mutableListOf<DavEvent>()
|
||||||
var successCount = 0
|
var successCount = 0
|
||||||
var lastError: String? = null
|
var lastError: String? = null
|
||||||
for (cal in calendars.take(12)) {
|
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 }
|
.onSuccess { successCount++; events += it }
|
||||||
.onFailure { lastError = it.message }
|
.onFailure { lastError = it.message }
|
||||||
}
|
}
|
||||||
@@ -226,6 +240,9 @@ class CalendarRepository(
|
|||||||
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
|
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(
|
private fun DavEvent.toItem() = CalendarEventItem(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
href = href,
|
href = href,
|
||||||
|
|||||||
+3
-1
@@ -62,7 +62,9 @@ data class CalendarUiState(
|
|||||||
|
|
||||||
class CalendarViewModel(
|
class CalendarViewModel(
|
||||||
context: Context,
|
context: Context,
|
||||||
private val repository: CalendarRepository = CalendarRepository(),
|
private val repository: CalendarRepository = CalendarRepository(
|
||||||
|
eventsCache = CalendarEventsCache(context.applicationContext),
|
||||||
|
),
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val deviceClient = DeviceCalendarClient(appContext)
|
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