Этап 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
@@ -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)
}
}