Этап 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:
@@ -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) | Требование владельца: звонки/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 (атрибуция)
|
||||
|
||||
@@ -26,4 +26,6 @@ dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.json:json:20240303'
|
||||
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? {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
MailMessagesPage(
|
||||
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 =
|
||||
|
||||
@@ -266,7 +266,8 @@ data class MailBootstrap(
|
||||
|
||||
data class MailMessagesPage(
|
||||
val messages: List<MailMessage>,
|
||||
val nextCursor: Int?,
|
||||
// Long: курсор = dateInt (Unix-секунды); Int резал бы значение (баг, ломается ~2038).
|
||||
val nextCursor: Long?,
|
||||
)
|
||||
|
||||
data class MailAppSettings(
|
||||
|
||||
@@ -38,7 +38,7 @@ class MailRepository {
|
||||
folder: MailFolderEntry,
|
||||
searchQuery: String = "",
|
||||
searchParams: MailSearchParams = MailSearchParams(),
|
||||
cursor: Int? = null,
|
||||
cursor: Long? = null,
|
||||
limit: Int = PAGE_SIZE,
|
||||
): MailMessagesPage {
|
||||
val client = authedClient(session)
|
||||
@@ -51,10 +51,10 @@ class MailRepository {
|
||||
return pageFromMessages(messages, limit)
|
||||
}
|
||||
|
||||
private fun pageFromMessages(messages: List<MailMessage>, limit: Int): MailMessagesPage {
|
||||
// API cursor is sent_at (dateInt), not database message id.
|
||||
internal fun pageFromMessages(messages: List<MailMessage>, limit: Int): MailMessagesPage {
|
||||
// API cursor is sent_at (dateInt, Unix-секунды) — Long, без обрезки в Int.
|
||||
val nextCursor = if (messages.size >= limit) {
|
||||
messages.lastOrNull()?.dateInt?.takeIf { it > 0 }?.toInt()
|
||||
messages.lastOrNull()?.dateInt?.takeIf { it > 0 }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ private fun MailInboxScreen(
|
||||
isOutbox: Boolean,
|
||||
messages: List<MailMessage>,
|
||||
outboxMessages: List<MailOutboxMessage>,
|
||||
nextCursor: Int?,
|
||||
nextCursor: Long?,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onQuickFilterToggle: (MailQuickFilter) -> Unit,
|
||||
onComposeClick: () -> Unit,
|
||||
|
||||
@@ -25,7 +25,7 @@ data class MailUiState(
|
||||
val folders: List<MailFolderEntry> = emptyList(),
|
||||
val selectedFolder: MailFolderEntry? = null,
|
||||
val messages: List<MailMessage> = emptyList(),
|
||||
val nextCursor: Int? = null,
|
||||
val nextCursor: Long? = null,
|
||||
val searchQuery: String = "",
|
||||
val searchParams: MailSearchParams = MailSearchParams(),
|
||||
val collapsedAccountIds: Set<Int> = emptySet(),
|
||||
@@ -1005,7 +1005,7 @@ class MailViewModel(
|
||||
if (page.messages.size < MailRepository.PAGE_SIZE) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user