Этап 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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user