diff --git a/CHANGELOG.md b/CHANGELOG.md index 365fd09..c1eb75e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 (атрибуция) diff --git a/core/network/build.gradle b/core/network/build.gradle index 06d47e6..e4eeb9a 100644 --- a/core/network/build.gradle +++ b/core/network/build.gradle @@ -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' } diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt index a2cf608..415d006 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalDavClient.kt @@ -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) diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt index a7fc937..c8e0d0a 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/CalendarIcs.kt @@ -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, val value: String) fun parseAll(ics: String): List { 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 { - val map = mutableMapOf() - 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 { + val out = mutableListOf() + 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 = + 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 { - 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 { @@ -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'")) diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/LoginFlowClient.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/LoginFlowClient.kt index a50df7d..0659772 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/LoginFlowClient.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/LoginFlowClient.kt @@ -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() + 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() + 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) } diff --git a/core/network/src/test/java/ru/forbion/f7cloud/core/network/CalendarIcsTest.kt b/core/network/src/test/java/ru/forbion/f7cloud/core/network/CalendarIcsTest.kt new file mode 100644 index 0000000..8f23022 --- /dev/null +++ b/core/network/src/test/java/ru/forbion/f7cloud/core/network/CalendarIcsTest.kt @@ -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)) + } + } +} diff --git a/core/network/src/test/java/ru/forbion/f7cloud/core/network/LoginFlowClientTest.kt b/core/network/src/test/java/ru/forbion/f7cloud/core/network/LoginFlowClientTest.kt new file mode 100644 index 0000000..ef6a989 --- /dev/null +++ b/core/network/src/test/java/ru/forbion/f7cloud/core/network/LoginFlowClientTest.kt @@ -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")) + } +} diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCacheRepository.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCacheRepository.kt index 96eee7f..49918bf 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCacheRepository.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailCacheRepository.kt @@ -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 = diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailModels.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailModels.kt index 5a4ba12..4279470 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailModels.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailModels.kt @@ -266,7 +266,8 @@ data class MailBootstrap( data class MailMessagesPage( val messages: List, - val nextCursor: Int?, + // Long: курсор = dateInt (Unix-секунды); Int резал бы значение (баг, ломается ~2038). + val nextCursor: Long?, ) data class MailAppSettings( diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt index 921da41..e362fa8 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailRepository.kt @@ -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, limit: Int): MailMessagesPage { - // API cursor is sent_at (dateInt), not database message id. + internal fun pageFromMessages(messages: List, 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 } diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt index 97f29ac..de74ad1 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailScreen.kt @@ -533,7 +533,7 @@ private fun MailInboxScreen( isOutbox: Boolean, messages: List, outboxMessages: List, - nextCursor: Int?, + nextCursor: Long?, onSearchChange: (String) -> Unit, onQuickFilterToggle: (MailQuickFilter) -> Unit, onComposeClick: () -> Unit, diff --git a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt index b57f4fc..d45e139 100644 --- a/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt +++ b/feature/mail/src/main/java/ru/forbion/f7cloud/feature/mail/MailViewModel.kt @@ -25,7 +25,7 @@ data class MailUiState( val folders: List = emptyList(), val selectedFolder: MailFolderEntry? = null, val messages: List = emptyList(), - val nextCursor: Int? = null, + val nextCursor: Long? = null, val searchQuery: String = "", val searchParams: MailSearchParams = MailSearchParams(), val collapsedAccountIds: Set = 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) } }