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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user