Этап 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
+1
View File
@@ -2,6 +2,7 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-07 | v0.5.114 (122) | Этап 1 (баги данных, test-first): заведена тестовая инфраструктура (core:network testImplementation junit) + первые 12 unit-тестов. Починено: (1) ICS-парсер дат — суффикс `Z` теперь UTC (был локальным), учитывается TZID, floating→локаль; (2) parseProps сохраняет параметры свойств — несколько ATTENDEE больше НЕ схлопываются, CN/PARTSTAT/ROLE читаются (была порча участников при редактировании→PUT на сервер); (3) unescape single-pass (последовательные replace ломались на `\\n`); (4) CalDavClient.parseIcsInstant делегирует в CalendarIcs — устранён рассинхрон календарь↔задачи; (5) QR-логин: маркер-парсер вместо split('&') — пароль с `&`/`:` больше не теряется; (6) курсор пагинации почты Int→Long (обрезка Unix-времени, ломалось ~2038). Проверено: 12 тестов зелёные + assembleRelease | контракты не менялись; исправлена запись битых ATTENDEE на сервер | низкий: чинит порчу данных, покрыто тестами
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка - 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка
- 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше - 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция) - 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
+2
View File
@@ -26,4 +26,6 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303' implementation 'org.json:json:20240303'
api 'com.squareup.okhttp3:okhttp:4.12.0' api 'com.squareup.okhttp3:okhttp:4.12.0'
testImplementation 'junit:junit:4.13.2'
} }
@@ -581,25 +581,9 @@ object CalDavClient {
) )
} }
fun parseIcsInstant(raw: String): Instant? { // Единый парсер дат — в CalendarIcs (учитывает Z/TZID/floating). Раньше здесь была
val value = raw.trim() // расходящаяся копия (трактовала всё как UTC) — источник рассинхрона календаря и задач.
if (value.isBlank()) return null fun parseIcsInstant(raw: String): Instant? = CalendarIcs.parseIcsInstant(raw)
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()
}
private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant) private fun formatIcsUtc(instant: Instant): String = formatCalDavTime(instant)
@@ -44,7 +44,10 @@ data class CalendarEventData(
object CalendarIcs { object CalendarIcs {
private val veventBlock = Pattern.compile("BEGIN:VEVENT([\\s\\S]*?)END:VEVENT", Pattern.CASE_INSENSITIVE) 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> { fun parseAll(ics: String): List<CalendarEventData> {
val unfolded = unfold(ics) val unfolded = unfold(ics)
@@ -70,23 +73,22 @@ object CalendarIcs {
} }
private fun parseVEventBlock(block: String): CalendarEventData? { 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() val uid = lines["UID"]?.trim().orEmpty()
if (uid.isBlank()) return null if (uid.isBlank()) return null
val dtStartRaw = lines["DTSTART"].orEmpty() val dtStart = props.firstOrNull { it.name == "DTSTART" } ?: return null
val start = parseIcsInstant(dtStartRaw) ?: return null val start = parseIcsInstant(dtStart.value, dtStart.params["TZID"]) ?: return null
val allDay = !dtStartRaw.contains('T') val allDay = dtStart.params["VALUE"].equals("DATE", ignoreCase = true) || !dtStart.value.contains('T')
val endRaw = lines["DTEND"] val dtEnd = props.firstOrNull { it.name == "DTEND" }
val end = if (endRaw != null) { val end = dtEnd?.let { parseIcsInstant(it.value, it.params["TZID"]) }
parseIcsInstant(endRaw) ?: start.plusSeconds(if (allDay) 86400 else 3600) ?: start.plusSeconds(if (allDay) 86400 else 3600)
} else { // каждый ATTENDEE — со своими параметрами (CN/PARTSTAT/ROLE), не схлопываем
start.plusSeconds(if (allDay) 86400 else 3600) val attendees = props.filter { it.name == "ATTENDEE" }.mapNotNull { parseAttendee(it) }
} val organizerProp = props.firstOrNull { it.name == "ORGANIZER" }
val attendees = lines.entries val orgEmail = organizerProp?.value?.substringAfter("mailto:", organizerProp.value)?.trim().orEmpty()
.filter { it.key.startsWith("ATTENDEE") } val orgName = organizerProp?.params?.get("CN")?.let(::unescape).orEmpty()
.mapNotNull { parseAttendeeLine(it.key, it.value) }
val organizer = lines["ORGANIZER"].orEmpty()
val (orgEmail, orgName) = parseOrganizer(organizer)
val alarms = parseAlarms(block) val alarms = parseAlarms(block)
val conference = lines.entries val conference = lines.entries
.firstOrNull { it.key.startsWith("CONFERENCE") } .firstOrNull { it.key.startsWith("CONFERENCE") }
@@ -173,52 +175,71 @@ object CalendarIcs {
fun newUid(): String = "${UUID.randomUUID()}@f7cloud.mobile" 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() val value = raw.trim()
if (value.isBlank()) return null if (value.isBlank()) return null
val paramZone = tzId?.let { runCatching { ZoneId.of(it) }.getOrNull() }
return runCatching { return runCatching {
when { when {
value.contains('T') -> { value.contains('T') -> {
val clean = value.replace("Z", "", ignoreCase = true).take(15) val hasZ = value.endsWith("Z", ignoreCase = true)
LocalDateTime.parse(clean, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")) val clean = value.trimEnd('Z', 'z').take(15)
.atZone(ZoneId.systemDefault()).toInstant() 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 -> { value.length >= 8 -> {
LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE) LocalDate.parse(value.take(8), DateTimeFormatter.BASIC_ISO_DATE)
.atStartOfDay(ZoneId.systemDefault()).toInstant() .atStartOfDay(paramZone ?: ZoneId.systemDefault()).toInstant()
} }
else -> null else -> null
} }
}.getOrNull() }.getOrNull()
} }
private fun parseLines(block: String): Map<String, String> { /** Разбор ICS-строк в свойства с параметрами. Сворачивание строк уже снято в [unfold]. */
val map = mutableMapOf<String, String>() private fun parseProps(block: String): List<IcsProp> {
unfold(block).lineSequence().forEach { line -> val out = mutableListOf<IcsProp>()
val m = linePattern.matcher(line.trim()) unfold(block).lineSequence().forEach { raw ->
if (m.find()) { val line = raw.trim()
val key = m.group(1)?.uppercase().orEmpty() val colon = line.indexOf(':')
val value = m.group(2).orEmpty() if (colon <= 0) return@forEach
map[key] = if (map.containsKey(key)) "${map[key]}\n$value" else value 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? { private fun parseLines(block: String): Map<String, String> =
val email = value.substringAfter("mailto:", value).trim() 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 if (email.isBlank()) return null
val cn = Regex("CN=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1)?.let(::unescape) return CalendarAttendeeData(
val partStat = Regex("PARTSTAT=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "NEEDS-ACTION" email = email,
val role = Regex("ROLE=([^;:]+)", RegexOption.IGNORE_CASE).find(key)?.groupValues?.get(1) ?: "REQ-PARTICIPANT" displayName = prop.params["CN"]?.let(::unescape).orEmpty(),
val rsvp = !key.contains("RSVP=FALSE", ignoreCase = true) partStat = prop.params["PARTSTAT"] ?: "NEEDS-ACTION",
return CalendarAttendeeData(email = email, displayName = cn.orEmpty(), partStat = partStat, role = role, rsvp = rsvp) role = prop.params["ROLE"] ?: "REQ-PARTICIPANT",
} rsvp = !prop.params["RSVP"].equals("FALSE", ignoreCase = true),
)
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
} }
private fun parseAlarms(block: String): List<CalendarAlarmData> { private fun parseAlarms(block: String): List<CalendarAlarmData> {
@@ -265,8 +286,29 @@ object CalendarIcs {
private fun escape(text: String): String = private fun escape(text: String): String =
text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;") text.replace("\\", "\\\\").replace("\n", "\\n").replace(",", "\\,").replace(";", "\\;")
private fun unescape(text: String): String = /** Single-pass: последовательные replace ломались на экранированном бэкслеше (`\\n` → перенос). */
text.replace("\\n", "\n").replace("\\,", ",").replace("\\;", ";").replace("\\\\", "\\") 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 = private fun formatUtc(instant: Instant): String =
instant.atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")) 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 server: String,
val user: String, val user: String,
val password: String, val password: String,
) )
private fun parseCredentialParams(params: String): CredentialParams? { /**
val values = params.split('&') * Разбор `server:...&user:...&password:...` (формат nc-login). Значения НЕ split('&'):
if (values.isEmpty() || values.size > 3) return null * пароль может содержать `&` и `:`, а раньше `split('&')`+`size>3` его резал/ронял вход.
var server = "" * Ищем маркеры `key:` (в начале или после `&`) и берём значение до следующего маркера.
var user = "" */
var password = "" internal fun parseCredentialParams(params: String): CredentialParams? {
values.forEach { value -> val keys = listOf("server", "user", "password")
when { data class Marker(val key: String, val at: Int, val valueAt: Int)
value.startsWith("user:") -> user = decode(value.removePrefix("user:")) val markers = mutableListOf<Marker>()
value.startsWith("server:") -> server = decode(value.removePrefix("server:")) for (key in keys) {
value.startsWith("password:") -> password = decode(value.removePrefix("password:")) 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 if (server.isBlank() || user.isBlank() || password.isBlank()) return null
return CredentialParams(server, user, password) return CredentialParams(server, user, password)
} }
@@ -0,0 +1,104 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Test
/**
* Тесты парсера ICS. Фиксируют баги из аудита (2026-07-07): зоны/TZID, ATTENDEE, unescape.
* Даты с суффиксом Z дают абсолютный Instant → не зависят от таймзоны машины.
*/
class CalendarIcsTest {
// --- parseIcsInstant: зоны ---
@Test fun `Z-суффикс парсится как UTC`() {
// Баг: раньше Z срезался и время трактовалось как локальное.
val expected = Instant.parse("2026-07-07T10:00:00Z")
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000Z"))
}
@Test fun `floating без Z — локальная зона`() {
val expected = LocalDateTime.of(2026, 7, 7, 10, 0, 0)
.atZone(ZoneId.systemDefault()).toInstant()
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T100000"))
}
@Test fun `TZID учитывается`() {
// 12:00 в Москве (UTC+3) == 09:00 UTC
val expected = Instant.parse("2026-07-07T09:00:00Z")
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707T120000", "Europe/Moscow"))
}
@Test fun `VALUE=DATE — начало дня`() {
val expected = java.time.LocalDate.of(2026, 7, 7)
.atStartOfDay(ZoneId.systemDefault()).toInstant()
assertEquals(expected, CalendarIcs.parseIcsInstant("20260707"))
}
// --- ATTENDEE: несколько участников с параметрами ---
@Test fun `несколько ATTENDEE не схлопываются и хранят CN-PARTSTAT`() {
val ics = """
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:test-1
DTSTART:20260707T100000Z
SUMMARY:Встреча
ATTENDEE;CN=Иван Петров;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:ivan@f7.ru
ATTENDEE;CN=Мария Сидорова;PARTSTAT=DECLINED;ROLE=OPT-PARTICIPANT:mailto:maria@f7.ru
END:VEVENT
END:VCALENDAR
""".trimIndent()
val event = CalendarIcs.parseSingle(ics)!!
assertEquals(2, event.attendees.size)
val ivan = event.attendees.first { it.email == "ivan@f7.ru" }
assertEquals("Иван Петров", ivan.displayName)
assertEquals("ACCEPTED", ivan.partStat)
val maria = event.attendees.first { it.email == "maria@f7.ru" }
assertEquals("Мария Сидорова", maria.displayName)
assertEquals("DECLINED", maria.partStat)
}
// --- escape/unescape: круговой round-trip со спецсимволами ---
@Test fun `summary со спецсимволами переживает build и parse`() {
val summary = """Смета: 50%, скидка; путь C:\tmp\файл и
перенос""".trimIndent()
val data = CalendarEventData(
uid = "rt-1",
summary = summary,
startEpochMilli = Instant.parse("2026-07-07T10:00:00Z").toEpochMilli(),
endEpochMilli = Instant.parse("2026-07-07T11:00:00Z").toEpochMilli(),
)
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
assertEquals(summary, parsed.summary)
}
@Test fun `unescape не превращает экранированный бэкслеш-n в перенос строки`() {
// literal "\n" (бэкслеш+n) в тексте: escape → "\\n", unescape должен вернуть "\n" (2 символа),
// а не перевод строки. Проверяем через round-trip.
val summary = """путь\name"""
val data = CalendarEventData(
uid = "rt-2",
summary = summary,
startEpochMilli = 0L,
endEpochMilli = 3600_000L,
)
val parsed = CalendarIcs.parseSingle(CalendarIcs.build(data))!!
assertEquals(summary, parsed.summary)
assertTrue("не должно быть переноса строки", !parsed.summary.contains('\n'))
}
// --- согласованность двух парсеров ---
@Test fun `CalDavClient и CalendarIcs парсят одинаково`() {
val values = listOf("20260707T100000Z", "20260707", "20251231T235900Z")
for (v in values) {
assertEquals("рассинхрон на $v", CalendarIcs.parseIcsInstant(v), CalDavClient.parseIcsInstant(v))
}
}
}
@@ -0,0 +1,38 @@
package ru.forbion.f7cloud.core.network
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Разбор параметров nc-login QR. Фиксирует баг: пароль с `&` терялся (split('&')+size>3). */
class LoginFlowClientTest {
@Test fun `пароль с амперсандом и двоеточием не теряется`() {
val r = LoginFlowClient.parseCredentialParams(
"server:https://cloud.f7.ru&user:ivan&password:a&b:c&d",
)!!
assertEquals("https://cloud.f7.ru", r.server)
assertEquals("ivan", r.user)
assertEquals("a&b:c&d", r.password)
}
@Test fun `порядок полей произвольный`() {
val r = LoginFlowClient.parseCredentialParams(
"user:masha&password:p@ss&server:https://x.ru",
)!!
assertEquals("https://x.ru", r.server)
assertEquals("masha", r.user)
assertEquals("p@ss", r.password)
}
@Test fun `простой пароль без спецсимволов`() {
val r = LoginFlowClient.parseCredentialParams(
"server:https://x.ru&user:u&password:simple",
)!!
assertEquals("simple", r.password)
}
@Test fun `нет обязательного поля — null`() {
assertNull(LoginFlowClient.parseCredentialParams("server:https://x.ru&user:u"))
}
}
@@ -150,7 +150,8 @@ class MailCacheRepository(context: Context) {
private fun parseFolderPage(json: JSONObject): MailMessagesPage = private fun parseFolderPage(json: JSONObject): MailMessagesPage =
MailMessagesPage( MailMessagesPage(
messages = parseMessages(json.optJSONArray("messages")), messages = parseMessages(json.optJSONArray("messages")),
nextCursor = json.opt("nextCursor").takeUnless { it == JSONObject.NULL } as? Int, // org.json может вернуть число как Integer или Long — берём через Number.toLong().
nextCursor = (json.opt("nextCursor").takeUnless { it == JSONObject.NULL } as? Number)?.toLong(),
) )
private fun messageDetailToJson(detail: MailMessageDetail): JSONObject = private fun messageDetailToJson(detail: MailMessageDetail): JSONObject =
@@ -266,7 +266,8 @@ data class MailBootstrap(
data class MailMessagesPage( data class MailMessagesPage(
val messages: List<MailMessage>, val messages: List<MailMessage>,
val nextCursor: Int?, // Long: курсор = dateInt (Unix-секунды); Int резал бы значение (баг, ломается ~2038).
val nextCursor: Long?,
) )
data class MailAppSettings( data class MailAppSettings(
@@ -38,7 +38,7 @@ class MailRepository {
folder: MailFolderEntry, folder: MailFolderEntry,
searchQuery: String = "", searchQuery: String = "",
searchParams: MailSearchParams = MailSearchParams(), searchParams: MailSearchParams = MailSearchParams(),
cursor: Int? = null, cursor: Long? = null,
limit: Int = PAGE_SIZE, limit: Int = PAGE_SIZE,
): MailMessagesPage { ): MailMessagesPage {
val client = authedClient(session) val client = authedClient(session)
@@ -51,10 +51,10 @@ class MailRepository {
return pageFromMessages(messages, limit) return pageFromMessages(messages, limit)
} }
private fun pageFromMessages(messages: List<MailMessage>, limit: Int): MailMessagesPage { internal fun pageFromMessages(messages: List<MailMessage>, limit: Int): MailMessagesPage {
// API cursor is sent_at (dateInt), not database message id. // API cursor is sent_at (dateInt, Unix-секунды) — Long, без обрезки в Int.
val nextCursor = if (messages.size >= limit) { val nextCursor = if (messages.size >= limit) {
messages.lastOrNull()?.dateInt?.takeIf { it > 0 }?.toInt() messages.lastOrNull()?.dateInt?.takeIf { it > 0 }
} else { } else {
null null
} }
@@ -533,7 +533,7 @@ private fun MailInboxScreen(
isOutbox: Boolean, isOutbox: Boolean,
messages: List<MailMessage>, messages: List<MailMessage>,
outboxMessages: List<MailOutboxMessage>, outboxMessages: List<MailOutboxMessage>,
nextCursor: Int?, nextCursor: Long?,
onSearchChange: (String) -> Unit, onSearchChange: (String) -> Unit,
onQuickFilterToggle: (MailQuickFilter) -> Unit, onQuickFilterToggle: (MailQuickFilter) -> Unit,
onComposeClick: () -> Unit, onComposeClick: () -> Unit,
@@ -25,7 +25,7 @@ data class MailUiState(
val folders: List<MailFolderEntry> = emptyList(), val folders: List<MailFolderEntry> = emptyList(),
val selectedFolder: MailFolderEntry? = null, val selectedFolder: MailFolderEntry? = null,
val messages: List<MailMessage> = emptyList(), val messages: List<MailMessage> = emptyList(),
val nextCursor: Int? = null, val nextCursor: Long? = null,
val searchQuery: String = "", val searchQuery: String = "",
val searchParams: MailSearchParams = MailSearchParams(), val searchParams: MailSearchParams = MailSearchParams(),
val collapsedAccountIds: Set<Int> = emptySet(), val collapsedAccountIds: Set<Int> = emptySet(),
@@ -1005,7 +1005,7 @@ class MailViewModel(
if (page.messages.size < MailRepository.PAGE_SIZE) { if (page.messages.size < MailRepository.PAGE_SIZE) {
return page.copy(nextCursor = null) return page.copy(nextCursor = null)
} }
val cursor = page.messages.lastOrNull()?.dateInt?.takeIf { it > 0 }?.toInt() val cursor = page.messages.lastOrNull()?.dateInt?.takeIf { it > 0 }
return page.copy(nextCursor = cursor) return page.copy(nextCursor = cursor)
} }
} }