Этап 1: баги данных (test-first) — ICS-парсер, QR-пароль, курсор почты

Заведена тестовая инфраструктура (core:network junit) + 12 unit-тестов. Починено:
- ICS parseIcsInstant: суффикс Z → UTC (был локальным!), TZID учитывается, floating→локаль
- parseProps сохраняет параметры: несколько ATTENDEE не схлопываются, CN/PARTSTAT/ROLE
  читаются (была порча участников при редактировании события → PUT на сервер)
- unescape single-pass (последовательные replace ломались на экранированном бэкслеше)
- CalDavClient.parseIcsInstant делегирует в CalendarIcs — устранён рассинхрон календарь↔задачи
- LoginFlowClient: маркер-парсер вместо split('&') — QR-пароль с &/: не теряется
- MailRepository/ViewModel/Models/Cache/Screen: курсор пагинации Int→Long (обрезка ~2038)

Проверено: 12 тестов зелёные (CalendarIcsTest 8 + LoginFlowClientTest 4) + assembleRelease OK.
This commit is contained in:
b-dev-mobile
2026-07-07 22:21:37 +00:00
parent cef526e778
commit b90107a7fc
12 changed files with 274 additions and 86 deletions
@@ -581,25 +581,9 @@ object CalDavClient {
)
}
fun parseIcsInstant(raw: String): Instant? {
val value = raw.trim()
if (value.isBlank()) return null
return runCatching {
when {
value.contains('T') -> {
val clean = value.replace("Z", "", ignoreCase = true).take(15)
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
.toInstant(ZoneOffset.UTC)
}
value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
}
else -> null
}
}.getOrNull()
}
// Единый парсер дат — в CalendarIcs (учитывает Z/TZID/floating). Раньше здесь была
// расходящаяся копия (трактовала всё как UTC) — источник рассинхрона календаря и задач.
fun parseIcsInstant(raw: String): Instant? = CalendarIcs.parseIcsInstant(raw)
private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant)
@@ -44,7 +44,10 @@ data class CalendarEventData(
object CalendarIcs {
private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE)
private val linePattern = Pattern.compile("^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$", Pattern.MULTILINE)
/** Одно ICS-свойство: имя, параметры (TZID/CN/PARTSTAT…) и значение. Параметры НЕ теряем —
* раньше их срезал regex, из-за чего пропадал TZID и схлопывались ATTENDEE. */
private data class IcsProp(val name: String, val params: Map<String, String>, val value: String)
fun parseAll(ics: String): List<CalendarEventData> {
val unfolded = unfold(ics)
@@ -70,23 +73,22 @@ object CalendarIcs {
}
private fun parseVEventBlock(block: String): CalendarEventData? {
val lines = parseLines(block)
val props = parseProps(block)
// первое значение на имя — для простых одиночных свойств (SUMMARY/UID/…)
val lines = props.associate { it.name to it.value }
val uid = lines["UID"]?.trim().orEmpty()
if (uid.isBlank()) return null
val dtStartRaw = lines["DTSTART"].orEmpty()
val start = parseIcsInstant(dtStartRaw) ?: return null
val allDay = !dtStartRaw.contains('T')
val endRaw = lines["DTEND"]
val end = if (endRaw != null) {
parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600)
} else {
start.plusSeconds(if (allDay) 86400 else 3600)
}
val attendees = lines.entries
.filter { it.key.startsWith("ATTENDEE") }
.mapNotNull { parseAttendeeLine(it.key, it.value) }
val organizer = lines["ORGANIZER"].orEmpty()
val (orgEmail, orgName) = parseOrganizer(organizer)
val dtStart = props.firstOrNull { it.name == "DTSTART" } ?: return null
val start = parseIcsInstant(dtStart.value, dtStart.params["TZID"]) ?: return null
val allDay = dtStart.params["VALUE"].equals("DATE", ignoreCase = true) || !dtStart.value.contains('T')
val dtEnd = props.firstOrNull { it.name == "DTEND" }
val end = dtEnd?.let { parseIcsInstant(it.value, it.params["TZID"]) }
?: start.plusSeconds(if (allDay) 86400 else 3600)
// каждый ATTENDEE — со своими параметрами (CN/PARTSTAT/ROLE), не схлопываем
val attendees = props.filter { it.name == "ATTENDEE" }.mapNotNull { parseAttendee(it) }
val organizerProp = props.firstOrNull { it.name == "ORGANIZER" }
val orgEmail = organizerProp?.value?.substringAfter("mailto:", organizerProp.value)?.trim().orEmpty()
val orgName = organizerProp?.params?.get("CN")?.let(::unescape).orEmpty()
val alarms = parseAlarms(block)
val conference = lines.entries
.firstOrNull { it.key.startsWith("CONFERENCE") }
@@ -173,52 +175,71 @@ object CalendarIcs {
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile"
fun parseIcsInstant(raw: String): Instant? {
/**
* ICS date-time → Instant с учётом зоны:
* - суффикс `Z` → UTC (раньше срезался и трактовался как локальное — баг);
* - параметр TZID → указанная зона;
* - иначе (floating) → локальная зона устройства;
* - только дата (VALUE=DATE, 8 цифр) → начало дня в зоне (TZID или локальной).
*/
fun parseIcsInstant(raw: String, tzId: String? = null): Instant? {
val value = raw.trim()
if (value.isBlank()) return null
val paramZone = tzId?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return runCatching {
when {
value.contains('T') -> {
val clean = value.replace("Z", "", ignoreCase = true).take(15)
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
.atZone(ZoneId.systemDefault()).toInstant()
val hasZ = value.endsWith("Z", ignoreCase = true)
val clean = value.trimEnd('Z', 'z').take(15)
val ldt = LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"))
val zone = when {
hasZ -> ZoneOffset.UTC
else -> paramZone ?: ZoneId.systemDefault()
}
ldt.atZone(zone).toInstant()
}
value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneId.systemDefault()).toInstant()
.atStartOfDay(paramZone ?: ZoneId.systemDefault()).toInstant()
}
else -> null
}
}.getOrNull()
}
private fun parseLines(block: String): Map<String, String> {
val map = mutableMapOf<String, String>()
unfold(block).lineSequence().forEach { line ->
val m = linePattern.matcher(line.trim())
if (m.find()) {
val key = m.group(1)?.uppercase().orEmpty()
val value = m.group(2).orEmpty()
map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value
}
/** Разбор ICS-строк в свойства с параметрами. Сворачивание строк уже снято в [unfold]. */
private fun parseProps(block: String): List<IcsProp> {
val out = mutableListOf<IcsProp>()
unfold(block).lineSequence().forEach { raw ->
val line = raw.trim()
val colon = line.indexOf(':')
if (colon <= 0) return@forEach
val head = line.substring(0, colon) // NAME;PARAM=VAL;PARAM2=VAL
val value = line.substring(colon + 1)
val parts = head.split(';')
val name = parts[0].uppercase()
val params = parts.drop(1).mapNotNull { p ->
val eq = p.indexOf('=')
if (eq <= 0) null else p.substring(0, eq).uppercase() to p.substring(eq + 1).trim('"')
}.toMap()
out += IcsProp(name, params, value)
}
return map
return out
}
private fun parseAttendeeLine(key: String, value: String): CalendarAttendeeData? {
val email = value.substringAfter("mailto:", value).trim()
private fun parseLines(block: String): Map<String, String> =
parseProps(block).associate { it.name to it.value }
private fun parseAttendee(prop: IcsProp): CalendarAttendeeData? {
val email = prop.value.substringAfter("mailto:", prop.value).trim()
if (email.isBlank()) return null
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape)
val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION"
val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT"
val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true)
return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp)
}
private fun parseOrganizer(value: String): Pair<String, String> {
val email = value.substringAfter("mailto:", value).trim()
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(value)?.groupValues?.get(1)?.let(::unescape).orEmpty()
return email to cn
return CalendarAttendeeData(
email = email,
displayName = prop.params["CN"]?.let(::unescape).orEmpty(),
partStat = prop.params["PARTSTAT"] ?: "NEEDS-ACTION",
role = prop.params["ROLE"] ?: "REQ-PARTICIPANT",
rsvp = !prop.params["RSVP"].equals("FALSE", ignoreCase = true),
)
}
private fun parseAlarms(block: String): List<CalendarAlarmData> {
@@ -265,8 +286,29 @@ object CalendarIcs {
private fun escape(text: String): String =
text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
private fun unescape(text: String): String =
text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\")
/** Single-pass: последовательные replace ломались на экранированном бэкслеше (`\\n` → перенос). */
private fun unescape(text: String): String {
if (text.indexOf('\\') < 0) return text
val sb = StringBuilder(text.length)
var i = 0
while (i < text.length) {
val c = text[i]
if (c == '\\' && i + 1 < text.length) {
when (val n = text[i + 1]) {
'n', 'N' -> sb.append('\n')
',' -> sb.append(',')
';' -> sb.append(';')
'\\' -> sb.append('\\')
else -> sb.append(n)
}
i += 2
} else {
sb.append(c)
i++
}
}
return sb.toString()
}
private fun formatUtc(instant: Instant): String =
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
@@ -187,25 +187,40 @@ object LoginFlowClient {
}
}
private data class CredentialParams(
internal data class CredentialParams(
val server: String,
val user: String,
val password: String,
)
private fun parseCredentialParams(params: String): CredentialParams? {
val values = params.split('&')
if (values.isEmpty() || values.size > 3) return null
var server = ""
var user = ""
var password = ""
values.forEach { value ->
when {
value.startsWith("user:") -> user = decode(value.removePrefix("user:"))
value.startsWith("server:") -> server = decode(value.removePrefix("server:"))
value.startsWith("password:") -> password = decode(value.removePrefix("password:"))
/**
* Разбор `server:...&user:...&password:...` (формат nc-login). Значения НЕ split('&'):
* пароль может содержать `&` и `:`, а раньше `split('&')`+`size>3` его резал/ронял вход.
* Ищем маркеры `key:` (в начале или после `&`) и берём значение до следующего маркера.
*/
internal fun parseCredentialParams(params: String): CredentialParams? {
val keys = listOf("server", "user", "password")
data class Marker(val key: String, val at: Int, val valueAt: Int)
val markers = mutableListOf<Marker>()
for (key in keys) {
if (params.startsWith("$key:")) markers += Marker(key, 0, key.length + 1)
var idx = params.indexOf("&$key:")
while (idx >= 0) {
markers += Marker(key, idx, idx + 1 + key.length + 1)
idx = params.indexOf("&$key:", idx + 1)
}
}
if (markers.isEmpty()) return null
markers.sortBy { it.at }
val map = mutableMapOf<String, String>()
markers.forEachIndexed { i, m ->
val end = if (i + 1 < markers.size) markers[i + 1].at else params.length
// первое вхождение ключа выигрывает (не перезатираем более поздним мусором)
map.putIfAbsent(m.key, decode(params.substring(m.valueAt, end)))
}
val server = map["server"].orEmpty()
val user = map["user"].orEmpty()
val password = map["password"].orEmpty()
if (server.isBlank() || user.isBlank() || password.isBlank()) return null
return CredentialParams(server, user, password)
}