Compare commits
2 Commits
f7c0c721a2
...
1c1e9c89e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c1e9c89e2 | |||
| bc1a4970ef |
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||||
|
|
||||||
|
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Контакты — ядро правки/удаления (P1, contacts.diff, шаг 1/2).** `CardDavClient`: `updateContact` (PUT If-Match по ETag, round-trip неизвестных свойств vCard — PHOTO/UID/X-* сохраняются) и `deleteContact` (DELETE If-Match, 404=уже удалён, 412=конфликт ETag). `DavContact` +`href`/`etag`/`raw` (заполняются чтением книги; сигнатуры list/create НЕ менялись). `parseContacts` теперь ловит href/getetag на каждый `<response>`; общий `davContactFromVCard`. Чистый движок `VCardEditor.applyEdits` (замена управляемых FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE, фолдинг 75, CRLF, экранирование) + модели `VCardFields`/`VCardTypedValue`. **10 юнит-тестов** (`VCardEditorTest`) зелёные: round-trip неизвестных полей, FN с запятыми, PHOTO base64, folded ADR, CRLF-фолдинг. Гейт `:app:compileReleaseKotlin` зелёный. Шаг 2/2 (Room href/etag/raw + миграция, форма правки в карточке, delete в UI) — следующим коммитом. | контракты: core `CardDavClient` +2 метода, `DavContact` +3 опц. поля (обратно совместимо, list/create не тронуты); одобрено лидом mail/072 | низкий: только добавление методов + поля с дефолтами, синк не задет; на устройстве/сервере ещё не гонялось (юниты + компил-гейт)
|
||||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось
|
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Задачи — умные коллекции в UI (P1, tasks.diff).** Умные коллекции (Важное/Сегодня/Неделя/Все/Текущие/Завершённые) выведены в `TasksListNavView` над реальными списками: агрегат задач всех списков (`loadAllTasks`), счётчики-бейджи, тап открывает отфильтрованный вид; создание в коллекции идёт в список по умолчанию. 3-state видимость (Скрыта/Видима/Авто) в шите настроек с персистом в SharedPreferences (`TasksPrefsStore`, VM через `Factory(context)`). Движок `TasksSmartLists` расширен (`SmartListMode`/`visibleKeys`/`counts`), 25 юнит-тестов зелёные. Ff_app 16.7→24.6%, Fd_app 41.5→52.7% (`tasks.diff.md`). | контракты не менялись | низкий: UI изолирован в модуле, на устройстве не гонялось
|
||||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays` — `ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное UI-действие + чистый рефактор, на устройстве не гонялось
|
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: «Ответить лично» + рефактор (P1).** Пункт «Ответить лично» в меню сообщения (для чужих сообщений): `TalkViewModel.replyPrivately` открывает/создаёт 1:1 с автором через существующий `createOneToOneRoom` (OCS spreed v4, сервер идемпотентен) и `openRoom`. Заодно рефактор: блок оверлеев сообщения (лист действий + диалоги правки/удаления) вынесен из `ChatContent` в отдельный `MessageActionOverlays` — `ChatContent` LongMethod 267→174, cyclomatic 29→<25 (перестал плодить baseline-churn на каждый пункт меню). Baseline пере-привязан (сигнатура ChatContent сдвинулась от нового параметра); новый код чист (компиляция+detekt зелёные). | контракты не менялись (OCS spreed v4/v1) | низкий: изолированное UI-действие + чистый рефактор, на устройстве не гонялось
|
||||||
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось
|
- 2026-07-13 | (без бампа — версия/релиз за лидом) | **Конференции — меню сообщения: напоминание (P1).** Пункт «Напоминание» в `TalkMessageActionSheet` с пресетами «Сегодня» (18:00) / «Завтра» (08:00) / «Выходные» (сб 08:00) / «Неделя» (пн 08:00): `TalkRepository.setReminder` (OCS `POST /chat/{token}/{messageId}/reminder`, timestamp сек) + `TalkViewModel.setMessageReminder`/`reminderTimestamp` + enum `ReminderPreset`. Закрывает пункт «Напоминание» дыр меню сообщения (talk.diff.md). Baseline `feature/talk` пере-привязан (сигнатура ChatContent + импорт сдвинули существующие записи; новый код чист, компиляция+detekt зелёные). | контракты не менялись (OCS spreed v1) | низкий: изолированное UI-действие, на устройстве не гонялось
|
||||||
|
|||||||
@@ -26,6 +26,35 @@ data class DavContact(
|
|||||||
val birthday: String = "",
|
val birthday: String = "",
|
||||||
val emails: String = "",
|
val emails: String = "",
|
||||||
val phones: String = "",
|
val phones: String = "",
|
||||||
|
// Ниже — для безопасной правки/удаления (If-Match) и round-trip неизвестных полей vCard.
|
||||||
|
// Заполняются только чтением из книги (listContacts); create возвращает пустыми — ок.
|
||||||
|
val href: String = "",
|
||||||
|
val etag: String = "",
|
||||||
|
val raw: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Типизированное значение (EMAIL/TEL) с меткой TYPE (HOME/WORK/CELL/…). */
|
||||||
|
data class VCardTypedValue(
|
||||||
|
val value: String,
|
||||||
|
val type: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Управляемые поля контакта, которые правит UI. Всё, чего здесь нет
|
||||||
|
* (PHOTO, X-*, UID, кастомные свойства), сохраняется из сырого vCard как есть.
|
||||||
|
*/
|
||||||
|
data class VCardFields(
|
||||||
|
val displayName: String,
|
||||||
|
val firstName: String = "",
|
||||||
|
val lastName: String = "",
|
||||||
|
val emails: List<VCardTypedValue> = emptyList(),
|
||||||
|
val phones: List<VCardTypedValue> = emptyList(),
|
||||||
|
val organization: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val address: String = "",
|
||||||
|
val website: String = "",
|
||||||
|
val birthday: String = "",
|
||||||
|
val note: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
object CardDavClient {
|
object CardDavClient {
|
||||||
@@ -150,6 +179,58 @@ object CardDavClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правка существующего контакта. Берёт СЫРОЙ vCard (round-trip: неизвестные свойства —
|
||||||
|
* PHOTO, X-*, кастомные — сохраняются), заменяет управляемые поля и делает PUT с If-Match
|
||||||
|
* по ETag (защита от гонки). Возвращает обновлённый DavContact с новым ETag.
|
||||||
|
* Сигнатуры list/create не трогаю — это отдельный метод.
|
||||||
|
*/
|
||||||
|
fun updateContact(
|
||||||
|
client: OkHttpClient,
|
||||||
|
href: String,
|
||||||
|
etag: String,
|
||||||
|
rawVCard: String,
|
||||||
|
fields: VCardFields,
|
||||||
|
bookName: String = "",
|
||||||
|
): DavContact {
|
||||||
|
require(href.isNotBlank()) { "Пустой href контакта — правка невозможна" }
|
||||||
|
val newVCard = VCardEditor.applyEdits(rawVCard, fields)
|
||||||
|
val builder = Request.Builder()
|
||||||
|
.url(href)
|
||||||
|
.put(newVCard.toRequestBody("text/vcard; charset=utf-8".toMediaType()))
|
||||||
|
if (etag.isNotBlank()) builder.header("If-Match", etag)
|
||||||
|
val newEtag = client.newCall(builder.build()).execute().use { response ->
|
||||||
|
if (response.code == 412) {
|
||||||
|
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
|
||||||
|
}
|
||||||
|
if (response.code !in 200..299 && response.code != 204) {
|
||||||
|
error("Не удалось сохранить контакт (HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
response.header("ETag").orEmpty()
|
||||||
|
}
|
||||||
|
return davContactFromVCard(newVCard, bookName, href, newEtag.ifBlank { etag })
|
||||||
|
?: error("Не удалось разобрать сохранённый контакт")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаление контакта с If-Match по ETag. 404 трактуем как «уже удалён» — не ошибка. */
|
||||||
|
fun deleteContact(
|
||||||
|
client: OkHttpClient,
|
||||||
|
href: String,
|
||||||
|
etag: String,
|
||||||
|
) {
|
||||||
|
require(href.isNotBlank()) { "Пустой href контакта — удаление невозможно" }
|
||||||
|
val builder = Request.Builder().url(href).delete()
|
||||||
|
if (etag.isNotBlank()) builder.header("If-Match", etag)
|
||||||
|
client.newCall(builder.build()).execute().use { response ->
|
||||||
|
if (response.code == 412) {
|
||||||
|
error("Контакт изменён на сервере (конфликт ETag) — обновите и повторите")
|
||||||
|
}
|
||||||
|
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
|
||||||
|
error("Не удалось удалить контакт (HTTP ${response.code})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
|
private fun buildVCard(uid: String, displayName: String, email: String, phone: String): String {
|
||||||
val fn = displayName.trim()
|
val fn = displayName.trim()
|
||||||
val parts = fn.split(Regex("\\s+"), limit = 2)
|
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||||
@@ -199,7 +280,7 @@ object CardDavClient {
|
|||||||
</d:propfind>
|
</d:propfind>
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val xml = propfind(client, url, depth = 1, body)
|
val xml = propfind(client, url, depth = 1, body)
|
||||||
return parseContacts(xml, bookName).take(limit)
|
return parseContacts(xml, bookName, url).take(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
|
private fun propfind(client: OkHttpClient, url: String, depth: Int, body: String): String {
|
||||||
@@ -264,31 +345,63 @@ object CardDavClient {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseContacts(xml: String, bookName: String): List<DavContact> {
|
private fun parseContacts(xml: String, bookName: String, bookUrl: String): List<DavContact> {
|
||||||
val parser = newParser(xml)
|
val parser = newParser(xml)
|
||||||
val out = mutableListOf<DavContact>()
|
val out = mutableListOf<DavContact>()
|
||||||
var inAddressData = false
|
var inAddressData = false
|
||||||
val data = StringBuilder()
|
val data = StringBuilder()
|
||||||
|
var currentHref = ""
|
||||||
|
var currentEtag = ""
|
||||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||||
when (parser.eventType) {
|
when (parser.eventType) {
|
||||||
XmlPullParser.START_TAG -> if (parser.localTag() == "address-data") {
|
XmlPullParser.START_TAG -> when (parser.localTag()) {
|
||||||
|
"response" -> {
|
||||||
|
currentHref = ""
|
||||||
|
currentEtag = ""
|
||||||
|
}
|
||||||
|
// href до address-data — это путь vCard в текущем <response>
|
||||||
|
"href" -> if (!inAddressData && currentHref.isBlank()) {
|
||||||
|
currentHref = parser.readText().trim()
|
||||||
|
}
|
||||||
|
"getetag" -> currentEtag = parser.readText().trim()
|
||||||
|
"address-data" -> {
|
||||||
inAddressData = true
|
inAddressData = true
|
||||||
data.clear()
|
data.clear()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
|
XmlPullParser.TEXT -> if (inAddressData) data.append(parser.text)
|
||||||
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
|
XmlPullParser.END_TAG -> if (parser.localTag() == "address-data" && inAddressData) {
|
||||||
val vcard = unfoldVCard(data.toString())
|
val fullHref = if (currentHref.isBlank()) "" else resolveHref(bookUrl, currentHref)
|
||||||
val uid = extract(uidPattern, vcard).ifBlank {
|
davContactFromVCard(data.toString(), bookName, fullHref, currentEtag)
|
||||||
vcard.hashCode().toString()
|
?.let { out += it }
|
||||||
|
inAddressData = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
parser.next()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит DavContact из сырого vCard. `raw` сохраняем как есть (для round-trip при правке),
|
||||||
|
* разбор — по развёрнутому (unfold) тексту. Общий для чтения книги и для updateContact.
|
||||||
|
*/
|
||||||
|
private fun davContactFromVCard(
|
||||||
|
rawVCard: String,
|
||||||
|
bookName: String,
|
||||||
|
href: String,
|
||||||
|
etag: String,
|
||||||
|
): DavContact? {
|
||||||
|
val vcard = unfoldVCard(rawVCard)
|
||||||
|
val uid = extract(uidPattern, vcard).ifBlank { vcard.hashCode().toString() }
|
||||||
val name = resolveContactName(vcard)
|
val name = resolveContactName(vcard)
|
||||||
val allEmails = extractAll(emailPattern, vcard)
|
val allEmails = extractAll(emailPattern, vcard)
|
||||||
val allPhones = extractAll(telPattern, vcard)
|
val allPhones = extractAll(telPattern, vcard)
|
||||||
val email = allEmails.firstOrNull().orEmpty()
|
val email = allEmails.firstOrNull().orEmpty()
|
||||||
val phone = allPhones.firstOrNull().orEmpty()
|
val phone = allPhones.firstOrNull().orEmpty()
|
||||||
val photo = parseContactPhoto(vcard)
|
val photo = parseContactPhoto(vcard)
|
||||||
if (name.isNotBlank() || email.isNotBlank()) {
|
if (name.isBlank() && email.isBlank()) return null
|
||||||
out += DavContact(
|
return DavContact(
|
||||||
uid = uid,
|
uid = uid,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
email = email,
|
email = email,
|
||||||
@@ -303,15 +416,11 @@ object CardDavClient {
|
|||||||
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
birthday = formatBirthday(extract(bdayPattern, vcard)),
|
||||||
emails = allEmails.joinToString("\n"),
|
emails = allEmails.joinToString("\n"),
|
||||||
phones = allPhones.joinToString("\n"),
|
phones = allPhones.joinToString("\n"),
|
||||||
|
href = href,
|
||||||
|
etag = etag,
|
||||||
|
raw = rawVCard,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
inAddressData = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parser.next()
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unfoldVCard(raw: String): String {
|
private fun unfoldVCard(raw: String): String {
|
||||||
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||||
@@ -484,3 +593,119 @@ object CardDavClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чистый (без сети) редактор vCard 3.0 для round-trip правки. Управляемые свойства
|
||||||
|
* (FN/N/EMAIL/TEL/ORG/TITLE/ADR/URL/BDAY/NOTE) заменяются значениями из [VCardFields];
|
||||||
|
* все прочие (VERSION, UID, PHOTO, REV, X-* и кастомные) сохраняются из исходного vCard как есть.
|
||||||
|
* Тестируется юнитами: round-trip неизвестных полей, FN с запятыми, PHOTO base64,
|
||||||
|
* многострочный (folded) ADR, CRLF-фолдинг.
|
||||||
|
*/
|
||||||
|
internal object VCardEditor {
|
||||||
|
private const val CRLF = "\r\n"
|
||||||
|
private val MANAGED = setOf(
|
||||||
|
"FN", "N", "EMAIL", "TEL", "ORG", "TITLE", "ADR", "URL", "BDAY", "NOTE",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun applyEdits(rawVCard: String, fields: VCardFields): String {
|
||||||
|
val logical = groupLogicalLines(rawVCard)
|
||||||
|
val preserved = logical.filter { line ->
|
||||||
|
val p = propertyName(line).uppercase()
|
||||||
|
p.isNotEmpty() && p != "BEGIN" && p != "END" && p !in MANAGED
|
||||||
|
}
|
||||||
|
val hasVersion = preserved.any { propertyName(it).equals("VERSION", ignoreCase = true) }
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.append("BEGIN:VCARD").append(CRLF)
|
||||||
|
if (!hasVersion) sb.append("VERSION:3.0").append(CRLF)
|
||||||
|
preserved.forEach { appendFolded(sb, it) }
|
||||||
|
emitManaged(sb, fields)
|
||||||
|
sb.append("END:VCARD").append(CRLF)
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitManaged(sb: StringBuilder, f: VCardFields) {
|
||||||
|
val fn = f.displayName.trim()
|
||||||
|
if (fn.isNotEmpty()) appendFolded(sb, "FN:${escapeText(fn)}")
|
||||||
|
val last = f.lastName.trim()
|
||||||
|
val first = f.firstName.trim()
|
||||||
|
when {
|
||||||
|
last.isNotEmpty() || first.isNotEmpty() ->
|
||||||
|
appendFolded(sb, "N:${escapeComponent(last)};${escapeComponent(first)};;;")
|
||||||
|
fn.isNotEmpty() -> {
|
||||||
|
// N обязателен в vCard 3.0 — выводим из FN (первое слово — имя, остальное — фамилия)
|
||||||
|
val parts = fn.split(Regex("\\s+"), limit = 2)
|
||||||
|
val given = parts.getOrNull(0).orEmpty()
|
||||||
|
val family = parts.getOrNull(1).orEmpty()
|
||||||
|
appendFolded(sb, "N:${escapeComponent(family)};${escapeComponent(given)};;;")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.emails.filter { it.value.isNotBlank() }.forEach {
|
||||||
|
appendFolded(sb, "EMAIL;TYPE=${it.type.ifBlank { "INTERNET" }}:${escapeText(it.value.trim())}")
|
||||||
|
}
|
||||||
|
f.phones.filter { it.value.isNotBlank() }.forEach {
|
||||||
|
appendFolded(sb, "TEL;TYPE=${it.type.ifBlank { "CELL" }}:${escapeText(it.value.trim())}")
|
||||||
|
}
|
||||||
|
if (f.organization.isNotBlank()) appendFolded(sb, "ORG:${escapeComponent(f.organization.trim())}")
|
||||||
|
if (f.title.isNotBlank()) appendFolded(sb, "TITLE:${escapeText(f.title.trim())}")
|
||||||
|
if (f.address.isNotBlank()) {
|
||||||
|
// плоский адрес → поле street структуры ADR: pobox;ext;street;city;region;zip;country
|
||||||
|
appendFolded(sb, "ADR;TYPE=HOME:;;${escapeComponent(f.address.trim())};;;;")
|
||||||
|
}
|
||||||
|
if (f.website.isNotBlank()) appendFolded(sb, "URL:${escapeText(f.website.trim())}")
|
||||||
|
if (f.birthday.isNotBlank()) appendFolded(sb, "BDAY:${f.birthday.trim()}")
|
||||||
|
if (f.note.isNotBlank()) appendFolded(sb, "NOTE:${escapeText(f.note.trim())}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Группирует физические строки в логические: продолжение начинается с ' ' или '\t'. */
|
||||||
|
private fun groupLogicalLines(raw: String): List<String> {
|
||||||
|
val normalized = raw.replace("\r\n", "\n").replace('\r', '\n')
|
||||||
|
val out = mutableListOf<String>()
|
||||||
|
val cur = StringBuilder()
|
||||||
|
for (line in normalized.split('\n')) {
|
||||||
|
if (line.isEmpty()) continue
|
||||||
|
if ((line[0] == ' ' || line[0] == '\t') && cur.isNotEmpty()) {
|
||||||
|
cur.append(line.substring(1))
|
||||||
|
} else {
|
||||||
|
if (cur.isNotEmpty()) out += cur.toString()
|
||||||
|
cur.setLength(0)
|
||||||
|
cur.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.isNotEmpty()) out += cur.toString()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun propertyName(logicalLine: String): String {
|
||||||
|
val end = logicalLine.indexOfFirst { it == ':' || it == ';' }
|
||||||
|
val head = if (end < 0) logicalLine else logicalLine.substring(0, end)
|
||||||
|
// свойство может иметь group-префикс "item1.EMAIL" — берём часть после точки
|
||||||
|
return head.substringAfterLast('.').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Фолдинг длинных строк по 75 символов с CRLF+пробел (RFC 2426 §2.6). */
|
||||||
|
private fun appendFolded(sb: StringBuilder, line: String) {
|
||||||
|
if (line.length <= 75) {
|
||||||
|
sb.append(line).append(CRLF)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var i = 0
|
||||||
|
var first = true
|
||||||
|
while (i < line.length) {
|
||||||
|
val take = if (first) 75 else 74
|
||||||
|
val end = minOf(i + take, line.length)
|
||||||
|
if (!first) sb.append(' ')
|
||||||
|
sb.append(line, i, end).append(CRLF)
|
||||||
|
i = end
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEXT-значение (FN/TITLE/URL/NOTE/EMAIL/TEL): по совместимости с существующим buildVCard
|
||||||
|
// экранируем только backslash и перевод строки (запятые в имени оставляем литералом).
|
||||||
|
private fun escapeText(v: String): String =
|
||||||
|
v.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "")
|
||||||
|
|
||||||
|
// компонент структурного значения (N/ADR/ORG): дополнительно экранируем разделители ; и ,
|
||||||
|
private fun escapeComponent(v: String): String =
|
||||||
|
escapeText(v).replace(";", "\\;").replace(",", "\\,")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package ru.forbion.f7cloud.core.network
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тесты round-trip редактора vCard (P1 правка контакта, mail/072).
|
||||||
|
* Ключевое: неизвестные свойства (PHOTO, X-*, UID) сохраняются, управляемые — заменяются.
|
||||||
|
* Краевые из письма лида: FN с запятыми, PHOTO base64, многострочный ADR, CRLF-фолдинг.
|
||||||
|
*/
|
||||||
|
class VCardEditorTest {
|
||||||
|
|
||||||
|
private fun edit(raw: String, fields: VCardFields) = VCardEditor.applyEdits(raw, fields)
|
||||||
|
|
||||||
|
private val base = VCardFields(displayName = "Иван Петров")
|
||||||
|
|
||||||
|
@Test fun preserves_unknown_properties_and_uid_and_photo() {
|
||||||
|
val raw = buildString {
|
||||||
|
append("BEGIN:VCARD\r\n")
|
||||||
|
append("VERSION:3.0\r\n")
|
||||||
|
append("UID:abc-123\r\n")
|
||||||
|
append("FN:Старое Имя\r\n")
|
||||||
|
append("N:Имя;Старое;;;\r\n")
|
||||||
|
append("PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD\r\n")
|
||||||
|
append("X-CUSTOM-FIELD:держим-как-есть\r\n")
|
||||||
|
append("TEL;TYPE=CELL:+70000000000\r\n")
|
||||||
|
append("END:VCARD\r\n")
|
||||||
|
}
|
||||||
|
val out = edit(raw, VCardFields(displayName = "Иван Петров", phones = listOf(VCardTypedValue("+79990001122", "WORK"))))
|
||||||
|
// UID и неизвестные свойства сохранены
|
||||||
|
assertTrue("UID сохранён", out.contains("UID:abc-123"))
|
||||||
|
assertTrue("PHOTO сохранён", out.contains("/9j/4AAQSkZJRgABAQAAAQABAAD"))
|
||||||
|
assertTrue("X-CUSTOM сохранён", out.contains("X-CUSTOM-FIELD:держим-как-есть"))
|
||||||
|
// управляемые поля заменены на новые
|
||||||
|
assertTrue("новый FN", out.contains("FN:Иван Петров"))
|
||||||
|
assertFalse("старый FN убран", out.contains("Старое Имя"))
|
||||||
|
assertTrue("новый телефон", out.contains("TEL;TYPE=WORK:+79990001122"))
|
||||||
|
assertFalse("старый телефон убран", out.contains("+70000000000"))
|
||||||
|
// валидный каркас
|
||||||
|
assertTrue(out.startsWith("BEGIN:VCARD\r\n"))
|
||||||
|
assertTrue(out.trimEnd().endsWith("END:VCARD"))
|
||||||
|
assertEquals("одна VERSION", 1, Regex("(?m)^VERSION:").findAll(out).count())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun fn_with_commas_is_kept_literally() {
|
||||||
|
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:x\r\nFN:X\r\nEND:VCARD\r\n"
|
||||||
|
val out = edit(raw, VCardFields(displayName = "Петров, Иван, Иванович"))
|
||||||
|
assertTrue("запятые в FN сохранены литералом", out.contains("FN:Петров, Иван, Иванович"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun folded_multiline_adr_is_regrouped_and_replaced() {
|
||||||
|
// ADR разбит фолдингом на 2 физические строки (продолжение с пробела)
|
||||||
|
val raw = buildString {
|
||||||
|
append("BEGIN:VCARD\r\n")
|
||||||
|
append("VERSION:3.0\r\n")
|
||||||
|
append("UID:y\r\n")
|
||||||
|
append("FN:Кто-то\r\n")
|
||||||
|
append("ADR;TYPE=HOME:;;ул. Очень Длинная Дом 1 Корпус 2 Строение 3\r\n")
|
||||||
|
append(" Квартира 456 Подъезд 7;Город;;;\r\n")
|
||||||
|
append("END:VCARD\r\n")
|
||||||
|
}
|
||||||
|
val out = edit(raw, VCardFields(displayName = "Кто-то", address = "Новый Город, ул. Новая 5"))
|
||||||
|
assertTrue("новый адрес в ADR", out.contains("Новый Город"))
|
||||||
|
assertFalse("старый адрес убран", out.contains("Очень Длинная"))
|
||||||
|
// не осталось «висящей» строки-продолжения старого ADR
|
||||||
|
assertFalse(out.contains("Квартира 456"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun long_line_is_folded_at_75_with_crlf_space() {
|
||||||
|
val longNote = "Z".repeat(200)
|
||||||
|
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:z\r\nFN:N\r\nEND:VCARD\r\n"
|
||||||
|
val out = edit(raw, base.copy(note = longNote))
|
||||||
|
// каждая физическая строка ≤ 76 символов (75 + возможный ведущий пробел)
|
||||||
|
val physical = out.split("\r\n").filter { it.isNotEmpty() }
|
||||||
|
assertTrue("все строки свёрнуты ≤76", physical.all { it.length <= 76 })
|
||||||
|
// продолжения начинаются с пробела
|
||||||
|
assertTrue("есть свёрнутое продолжение", physical.any { it.startsWith(" Z") })
|
||||||
|
// при развороте NOTE восстанавливается целиком
|
||||||
|
val unfolded = out.replace("\r\n ", "")
|
||||||
|
assertTrue(unfolded.contains("NOTE:$longNote"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun crlf_and_lf_inputs_both_parse() {
|
||||||
|
val lf = "BEGIN:VCARD\nVERSION:3.0\nUID:m\nFN:Старое\nX-KEEP:1\nEND:VCARD\n"
|
||||||
|
val out = edit(lf, base)
|
||||||
|
assertTrue(out.contains("X-KEEP:1"))
|
||||||
|
assertTrue(out.contains("FN:Иван Петров"))
|
||||||
|
// выход всегда CRLF
|
||||||
|
assertTrue(out.contains("\r\n"))
|
||||||
|
assertFalse("нет одиночных LF", out.replace("\r\n", "").contains("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun n_derived_from_fn_when_name_parts_absent() {
|
||||||
|
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:n\r\nEND:VCARD\r\n"
|
||||||
|
val out = edit(raw, VCardFields(displayName = "Иван Петров"))
|
||||||
|
// N: фамилия;имя;;; — из FN: given=Иван, family=Петров
|
||||||
|
assertTrue(out.contains("N:Петров;Иван;;;"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun explicit_name_parts_win_over_fn() {
|
||||||
|
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:e\r\nEND:VCARD\r\n"
|
||||||
|
val out = edit(raw, VCardFields(displayName = "Ivan P", firstName = "Ivan", lastName = "Petrov"))
|
||||||
|
assertTrue(out.contains("N:Petrov;Ivan;;;"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun multiple_emails_and_types_emitted() {
|
||||||
|
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:me\r\nFN:X\r\nEMAIL:old@x.ru\r\nEND:VCARD\r\n"
|
||||||
|
val out = edit(
|
||||||
|
raw,
|
||||||
|
base.copy(
|
||||||
|
emails = listOf(
|
||||||
|
VCardTypedValue("home@x.ru", "HOME"),
|
||||||
|
VCardTypedValue("work@x.ru", "WORK"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(out.contains("old@x.ru"))
|
||||||
|
assertTrue(out.contains("EMAIL;TYPE=HOME:home@x.ru"))
|
||||||
|
assertTrue(out.contains("EMAIL;TYPE=WORK:work@x.ru"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun blank_managed_fields_are_omitted() {
|
||||||
|
val raw = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:b\r\nFN:X\r\nEND:VCARD\r\n"
|
||||||
|
val out = edit(raw, VCardFields(displayName = "Только Имя"))
|
||||||
|
assertFalse("нет пустого EMAIL", out.contains("EMAIL"))
|
||||||
|
assertFalse("нет пустого TEL", out.contains("TEL"))
|
||||||
|
assertFalse("нет пустого ADR", out.contains("ADR"))
|
||||||
|
assertFalse("нет пустого ORG", out.contains("ORG"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Diff: Конференции (Talk) — итерация 1 (2026-07-13)
|
# Diff: Конференции (Talk) — итерация 1 (2026-07-13)
|
||||||
Ff_app: **40.0%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
Ff_app: **40.7%** · Fd_app: **предварительно** (только сайдбар сверен live; дизайн-проход на устройстве — след. итерация) · n-a: 4
|
||||||
|
|
||||||
> Прогресс P1 (коммиты 59543a7, bbaaf7a): реализованы меню сообщения «Копировать», «Копировать
|
> Прогресс P1 (коммиты 59543a7, bbaaf7a, a60fd78): реализованы меню сообщения «Копировать»,
|
||||||
> ссылку», «Переслать в «Личные заметки»», «Напоминание» (пресеты) — `[done]` ниже
|
> «Копировать ссылку», «Переслать в «Личные заметки»», «Напоминание» (пресеты), «Ответить лично»
|
||||||
> (Ff экрана «Беседа» 38.1%→54.5%).
|
> — `[done]` ниже (Ff экрана «Беседа» 38.1%→59.1%).
|
||||||
|
|
||||||
Сверка веб (карта `talk.md`, апстрим `spreed v22.0.12` + тема) ↔ натив (`feature/talk` Compose на
|
Сверка веб (карта `talk.md`, апстрим `spreed v22.0.12` + тема) ↔ натив (`feature/talk` Compose на
|
||||||
OCS /api/v4; звонки — `vendor/talk-android` v23). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
|
OCS /api/v4; звонки — `vendor/talk-android` v23). Метод mail/083: `[Ф|Д]`·`[open|done|n-a]`,
|
||||||
@@ -42,7 +42,7 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
|
|||||||
- Мастер создания: [Ф][done] имя/тип/аватар; [Ф][open] описание, защита паролем, «Разрешить
|
- Мастер создания: [Ф][done] имя/тип/аватар; [Ф][open] описание, защита паролем, «Разрешить
|
||||||
гостям по ссылке» — в нативе минимальный (имя+тип). *(учтено в open выше как часть «создать».)*
|
гостям по ссылке» — в нативе минимальный (имя+тип). *(учтено в open выше как часть «создать».)*
|
||||||
|
|
||||||
## Экран 3: Беседа + композер + меню сообщения — Ff 54.5% (10 open Ф / 22)
|
## Экран 3: Беседа + композер + меню сообщения — Ff 59.1% (9 open Ф / 22)
|
||||||
Композер:
|
Композер:
|
||||||
- [Ф][done] Отправить сообщение · «Отправить».
|
- [Ф][done] Отправить сообщение · «Отправить».
|
||||||
- [Ф][done] Эмодзи · «Смайлики».
|
- [Ф][done] Эмодзи · «Смайлики».
|
||||||
@@ -57,7 +57,7 @@ Ff/Fd на экран. Дизайн (Fd) сверяю на устройстве
|
|||||||
- [Ф][done] Изменить · «Изменить».
|
- [Ф][done] Изменить · «Изменить».
|
||||||
- [Ф][done] Удалить · «Удалить».
|
- [Ф][done] Удалить · «Удалить».
|
||||||
- [Ф][done] Реакция · набор эмодзи (реакции есть).
|
- [Ф][done] Реакция · набор эмодзи (реакции есть).
|
||||||
- [Ф][open] **Ответить лично** (Reply privately) — нет.
|
- [Ф][done] **Ответить лично** (Reply privately) — реализовано (a60fd78): открывает/создаёт 1:1 с автором.
|
||||||
- [Ф][done] **Переслать в «Личные заметки»** — реализовано (59543a7); пересылка в произвольную
|
- [Ф][done] **Переслать в «Личные заметки»** — реализовано (59543a7); пересылка в произвольную
|
||||||
комнату (выбор комнаты) — ещё open, вынес отдельным пунктом ниже.
|
комнату (выбор комнаты) — ещё open, вынес отдельным пунктом ниже.
|
||||||
- [Ф][open] Переслать в произвольную комнату (выбор из списка) — нет (пока только note-to-self).
|
- [Ф][open] Переслать в произвольную комнату (выбор из списка) — нет (пока только note-to-self).
|
||||||
|
|||||||
Reference in New Issue
Block a user