3 Commits

Author SHA1 Message Date
b-dev-mobile 91aaaba5a0 chore: версия 0.5.144 (152) + CHANGELOG сводного релиза 2026-07-10 22:28:15 +00:00
b-dev-mobile e6a71de7a6 feat(calendar): пакет функционала — событие 1:1 с веб, вложения, повторение, шаринг, free/busy
- Русские месяцы в шапке; нативный DatePickerDialog (material3 крашился)
- 403 при PUT события: учёт supported-calendar-component-set (VTODO-коллекции
  не принимают VEVENT) + sabre-precondition в тексте ошибки
- Окно события по _calendar-edit-full-fields.css: pill-поля, крестик, зелёная
  галочка-сохранение, нативные пикеры даты/времени, высота 94% экрана
- Talk-беседа к событию: выбор существующей / создание публичной|приватной
- Вложения (ATTACH+FILENAME/FMTTYPE): «из файлов» (WebDAV-пикер) или загрузка
  с устройства в /Calendar; round-trip при редактировании события
- Кастомное повторение (_calendar-repeat-modal.css): интервал+частота, дни
  недели кружками, окончание никогда/до даты/N раз ↔ RRULE (parse/build/описание)
- Многодневные события (endDate) и несколько напоминаний (VALARM list)
- Управление календарём (✎ в сайдбаре): PROPPATCH имя/цвет, DELETE,
  шаринг через DAV oc:invite + OCS sharees (польз./группы, чтение/редакт.)
- Discard-диалог при закрытии редактора с несохранёнными изменениями
- Занятость участников: iTIP VFREEBUSY через DAV outbox, конфликты красным
2026-07-10 22:28:15 +00:00
b-dev-mobile 4b000d3b27 feat(files): веб-сессия Nextcloud для .whiteboard в WebView
Доска (whiteboard) открывается через веб-viewer, которому мало Basic-auth:
Vue-XHR ходят по session-cookie+CSRF. Теперь перед загрузкой web-файла
логинимся формой /login app-паролем (NextcloudWebSession, обходит 2FA),
переносим cookie в CookieManager и грузим страницу нативно — для web-файлов
отключено OkHttp-проксирование (оно теряло тело POST/PROPFIND → «нет файлов»).
Collabora-путь не тронут (WOPI-токен самодостаточен).
+NetworkFactory.newBaseBuilder — билдер на общем пуле без auth.
2026-07-10 22:26:38 +00:00
17 changed files with 2179 additions and 109 deletions
+2
View File
@@ -2,6 +2,8 @@
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск` Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
- 2026-07-10 | v0.5.144 (152) | СВОДНЫЙ РЕЛИЗ (включает непубликовавшиеся v0.5.133143). **Whiteboard**: .whiteboard наконец открывается — реальная веб-сессия Nextcloud в WebView (login-flow app-паролем → cookie+CSRF), для web-файлов отключено OkHttp-проксирование (теряло тело POST/PROPFIND). **Календарь — большой пакет**: русские месяцы в шапке; тап по дате не крашит (нативный DatePickerDialog вместо material3); фикс 403 при создании события (учёт supported-calendar-component-set — событие не уходит в VTODO-коллекцию; в ошибках теперь sabre-precondition); окно события 1:1 с живой темой (pill-поля 40dp #FBFBFB/#E6E6E6, шапка с крестиком, зелёная галочка, высота 94% экрана); дата/время нативными пикерами; беседа Talk к событию (выбор существующей или создание публичной/приватной); вложения (ATTACH: «из файлов» с навигацией по WebDAV или загрузка с устройства в /Calendar; round-trip при редактировании); кастомное повторение (модалка: интервал+частота, кружки дней недели, окончание никогда/до даты/N раз ↔ честный RRULE); многодневные события (отдельная дата окончания); несколько напоминаний; управление календарём из сайдбара (✎: переименование, цвет, шаринг пользователям/группам через DAV oc:invite + OCS sharees, удаление с подтверждением; подписки read-only); discard-диалог при закрытии с несохранёнными изменениями; занятость участников (iTIP VFREEBUSY через DAV outbox, конфликты подсвечены). **Задачи** (из 133–137): подзадачи (RELATED-TO), меню «⋯» (вложенная задача/удаление), стрелка панели открывает списки, кэш ускоряет загрузку. **Файлы** (из 133137): .md в нативном редакторе (WebDAV load/save + предпросмотр), прогрев WebView для docx/xlsx. **Меню**: иконки шторки в белых круглых рамках с тенью, единый размер. Push/звонки: 30-с таймаут → «Пропущенный звонок» (обезличенно на локскрине) | +NextcloudWebSession (POST /login); CalDAV: PROPPATCH/DELETE/share на календарях, POST outbox (VFREEBUSY), ATTACH в VEVENT; OCS: sharees, cloud/user(email), spreed room list | средний: пишущие операции календаря (шаринг/удаление/free-busy/вложения) проверены компиляцией, на устройстве не гонялись
- 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий - 2026-07-10 | v0.5.128 (136) | Фиксы по отчёту владельца. (1) Иконки шторки: были glass-кружки, стали ПЛОСКИЕ menu-иконки живой темы (images/menu/*.svg — зелёный+чёрный контур), 1:1 с мобильным сайтом. (2) Левая стрелка нижней панели больше не мёртвая на Карточках/Конференциях/Контактах/Задачах — работает как «назад» к предыдущему разделу (для Почты/Файлов/Календаря по-прежнему тумблер папок). (3) Deck GET-запросы возвращены к прежнему рабочему виду (без OCS-заголовка) — исключён риск регресса чтения досок. Напоминание: основной новый функционал Карточек — ВНУТРИ доски (колонки, карточки, деталь с правкой/перемещением/метками/сроком) | контракты не менялись | низкий
- 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение - 2026-07-10 | v0.5.127 (135) | Меню и Карточки. **Меню 1:1 по живому forbion:** шторка приложений (по «трём полоскам») без скругления верха и ручки, высота по контенту почти на весь экран, сетка 3 кол. gap 8, иконки-glass локально (офлайн); нижняя панель — плавающая пилюля 328×61, кнопки 55 (#FBFBFB/рамка #E6E6E6), возвращена подсветка активного раздела и открытого бургера (полупрозрачный зелёный градиент). **Карточки (Deck) — полный функционал** (было только чтение): доска с колонками-стеками, карточки с метками/сроком/исполнителями/чекбоксом; карточка-деталь (правка названия/описания, срок через пикер, done, метки доски, перемещение в колонку, архив, удаление); создание карточек и колонок; учёт прав PERMISSION_EDIT. Всё через внутренний Deck API — общее с веб-версией | Deck API /api/v1.0 (write) | средний: пишущие операции Deck не тестировались на устройстве — проверить создание/правку/перемещение
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile' applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26 minSdk 26
targetSdk 36 targetSdk 36
versionCode 145 versionCode 152
versionName '0.5.137' versionName '0.5.144'
missingDimensionStrategy 'default', 'f7' missingDimensionStrategy 'default', 'f7'
multiDexEnabled true multiDexEnabled true
@@ -40,12 +40,17 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7Theme import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NextcloudWebSession
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
class OfficeEditorActivity : ComponentActivity() { class OfficeEditorActivity : ComponentActivity() {
@@ -200,7 +205,55 @@ class OfficeEditorActivity : ComponentActivity() {
if (current == next) return if (current == next) return
editorVisible = false editorVisible = false
loadError = null loadError = null
view.loadUrl(target.url) startLoad(view, target)
}
/**
* Collabora — грузим сразу (iframe самодостаточен по WOPI-токену). Web-viewer (доска и пр.)
* требует НАСТОЯЩУЮ веб-сессию: сначала логинимся app-паролем, переносим cookie в
* WebView CookieManager, только потом открываем URL — иначе viewer видит анонима («нет файлов»).
*/
private fun startLoad(view: WebView, target: OfficeEditorLaunch) {
if (target.collaboraBaseUrl.isNotBlank()) {
view.loadUrl(target.url)
return
}
lifecycleScope.launch {
val ok = runCatching { establishWebSession(target) }.getOrDefault(false)
if (!ok) {
loadError = "Не удалось открыть сессию. Проверьте интернет и попробуйте снова."
return@launch
}
view.loadUrl(target.url)
}
}
/** Логин по форме `/login`, перенос session-cookie в WebView. Возвращает true при успехе. */
private suspend fun establishWebSession(target: OfficeEditorLaunch): Boolean {
val serverUrl = target.serverUrl.ifBlank { target.url }
val cookies = withContext(Dispatchers.IO) {
NextcloudWebSession.login(
serverUrl = serverUrl,
username = target.username,
appPassword = target.password,
trustAllCerts = target.trustAllCerts,
)
}
if (cookies.isEmpty()) return false
val cm = CookieManager.getInstance()
cm.setAcceptCookie(true)
webViewRef?.let { cm.setAcceptThirdPartyCookies(it, true) }
val base = serverUrl.trimEnd('/')
cookies.forEach { c ->
val attrs = buildString {
append("${c.name}=${c.value}; Path=${c.path}")
if (c.secure) append("; Secure")
if (c.httpOnly) append("; HttpOnly")
}
cm.setCookie(base, attrs)
}
withContext(Dispatchers.IO) { cm.flush() }
return true
} }
private fun readLaunch(): OfficeEditorLaunch? { private fun readLaunch(): OfficeEditorLaunch? {
@@ -250,7 +303,7 @@ class OfficeEditorActivity : ComponentActivity() {
val current = webView.url?.trimEnd('/').orEmpty() val current = webView.url?.trimEnd('/').orEmpty()
val target = launch.url.trimEnd('/') val target = launch.url.trimEnd('/')
if (current != target) { if (current != target) {
webView.loadUrl(launch.url) startLoad(webView, launch)
} }
} }
}, },
@@ -128,11 +128,10 @@ internal class OfficeWebViewClient(
} }
fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> { fun buildInterceptPrefixes(launch: OfficeEditorLaunch): List<String> {
val prefixes = mutableListOf(launch.serverUrl.trimEnd('/')) // Web-файлы (whiteboard): НЕ проксируем — WebView ходит нативно по веб-сессии
if (launch.collaboraBaseUrl.isNotBlank()) { // (cookie+CSRF). Проксирование через OkHttp теряло тело POST/PROPFIND → пустой Files.
prefixes += launch.collaboraBaseUrl.trimEnd('/') if (launch.collaboraBaseUrl.isBlank()) return emptyList()
} return listOf(launch.serverUrl.trimEnd('/'), launch.collaboraBaseUrl.trimEnd('/')).distinct()
return prefixes.distinct()
} }
} }
} }
@@ -25,6 +25,10 @@ data class DavCalendar(
// CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает): // CTag коллекции (CalendarServer-расширение, Nextcloud поддерживает):
// меняется при любом изменении в календаре → ключ инкрементального кэша событий. // меняется при любом изменении в календаре → ключ инкрементального кэша событий.
val ctag: String = "", val ctag: String = "",
// supported-calendar-component-set: поддерживает ли коллекция VEVENT. VTODO-only списки
// задач не принимают события → PUT VEVENT туда возвращает 403. По умолчанию true (если
// сервер не вернул свойство — не ломаем поведение).
val supportsEvents: Boolean = true,
) )
data class DavEvent( data class DavEvent(
@@ -50,6 +54,7 @@ data class DavEvent(
val organizerName: String = "", val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(), val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "", val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
) )
data class DavTrashEvent( data class DavTrashEvent(
@@ -112,8 +117,8 @@ object CalDavClient {
fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> { fun listCalendars(client: OkHttpClient, baseUrl: String): List<DavCalendar> {
val body = """ val body = """
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/"> <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/></d:prop> <d:prop><d:displayname/><d:resourcetype/><ical:calendar-color/><cs:getctag/><c:supported-calendar-component-set/></d:prop>
</d:propfind> </d:propfind>
""".trimIndent() """.trimIndent()
val xml = propfind(client, baseUrl, depth = 1, body) val xml = propfind(client, baseUrl, depth = 1, body)
@@ -298,11 +303,27 @@ object CalDavClient {
} }
client.newCall(builder.build()).execute().use { response -> client.newCall(builder.build()).execute().use { response ->
if (response.code !in 200..299 && response.code != 201 && response.code != 204) { if (response.code !in 200..299 && response.code != 201 && response.code != 204) {
error("CalDAV put event HTTP ${response.code}") error("CalDAV put event HTTP ${response.code}${davErrorDetail(response)}")
} }
} }
} }
/** Из тела sabre-ошибки достаём человекочитаемую причину (precondition/сообщение). */
private fun davErrorDetail(response: okhttp3.Response): String {
val body = runCatching { response.peekBody(4096).string() }.getOrNull().orEmpty()
if (body.isBlank()) return ""
// sabre: <s:message>…</s:message> и имя нарушенного precondition-элемента.
val message = Regex("<s:message>(.*?)</s:message>", RegexOption.DOT_MATCHES_ALL)
.find(body)?.groupValues?.getOrNull(1)?.trim()
val precondition = Regex("<cal:(supported-calendar-component-set|valid-calendar-object-resource|calendar-collection-location-ok)")
.find(body)?.groupValues?.getOrNull(1)
?: Regex("<d:(need-privileges)").find(body)?.groupValues?.getOrNull(1)
return buildString {
if (precondition != null) append(" ($precondition)")
if (!message.isNullOrBlank()) append(": ${message.take(180)}")
}
}
fun createCalendar( fun createCalendar(
client: OkHttpClient, client: OkHttpClient,
baseUrl: String, baseUrl: String,
@@ -339,6 +360,122 @@ object CalDavClient {
return DavCalendar(href = url, displayName = displayName) return DavCalendar(href = url, displayName = displayName)
} }
/** PROPPATCH: переименование и/или смена цвета календаря (null = не менять). */
fun updateCalendarProps(
client: OkHttpClient,
calendarHref: String,
displayName: String? = null,
color: String? = null,
) {
if (displayName == null && color == null) return
val props = buildString {
if (displayName != null) append("<d:displayname>${escapeXml(displayName)}</d:displayname>")
if (color != null) append("<ical:calendar-color>${escapeXml(color)}</ical:calendar-color>")
}
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:ical="http://apple.com/ns/ical/">
<d:set><d:prop>$props</d:prop></d:set>
</d:propertyupdate>
""".trimIndent()
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.method("PROPPATCH", body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV proppatch HTTP ${response.code}")
}
}
fun deleteCalendar(client: OkHttpClient, calendarHref: String) {
val req = Request.Builder().url(calendarHref.trimEnd('/') + "/").delete().build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299 && response.code != 204 && response.code != 404) {
error("CalDAV delete calendar HTTP ${response.code}")
}
}
}
data class DavSharee(
val principal: String, // principal:principals/users/<id> или groups/<id>
val displayName: String,
val writable: Boolean,
)
/** Текущие получатели шаринга календаря (oc:invite). */
fun listCalendarShares(client: OkHttpClient, calendarHref: String): List<DavSharee> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop><oc:invite/></d:prop>
</d:propfind>
""".trimIndent()
val xml = propfind(client, calendarHref.trimEnd('/') + "/", depth = 0, body)
val out = mutableListOf<DavSharee>()
val parser = newParser(xml)
var principal = ""
var name = ""
var writable = false
var inUser = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"user" -> { inUser = true; principal = ""; name = ""; writable = false }
"href" -> if (inUser) principal = parser.readText().trim()
"common-name" -> if (inUser) name = parser.readText().trim()
"read-write" -> if (inUser) writable = true
}
XmlPullParser.END_TAG -> if (parser.localTag() == "user" && inUser) {
if (principal.isNotBlank()) {
out += DavSharee(
principal = principal,
displayName = name.ifBlank { principal.substringAfterLast('/') },
writable = writable,
)
}
inUser = false
}
}
parser.next()
}
return out
}
/** Пошарить календарь пользователю/группе (sabre sharing POST). */
fun shareCalendar(client: OkHttpClient, calendarHref: String, principal: String, writable: Boolean) {
val rw = if (writable) "<o:read-write/>" else ""
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:set>
<d:href>${escapeXml(principal)}</d:href>
<o:summary/>$rw
</o:set>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
fun unshareCalendar(client: OkHttpClient, calendarHref: String, principal: String) {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<o:share xmlns:d="DAV:" xmlns:o="http://owncloud.org/ns">
<o:remove><d:href>${escapeXml(principal)}</d:href></o:remove>
</o:share>
""".trimIndent()
sharePost(client, calendarHref, body)
}
private fun sharePost(client: OkHttpClient, calendarHref: String, body: String) {
val req = Request.Builder()
.url(calendarHref.trimEnd('/') + "/")
.post(body.toRequestBody("application/xml; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { response ->
if (response.code !in 200..299) error("CalDAV share HTTP ${response.code}")
}
}
fun createSubscription( fun createSubscription(
client: OkHttpClient, client: OkHttpClient,
baseUrl: String, baseUrl: String,
@@ -593,6 +730,7 @@ object CalDavClient {
organizerName = parsed.organizerName, organizerName = parsed.organizerName,
alarms = parsed.alarms, alarms = parsed.alarms,
conferenceUri = parsed.conferenceUri, conferenceUri = parsed.conferenceUri,
attachments = parsed.attachments,
) )
} }
@@ -799,6 +937,99 @@ object CalDavClient {
.format(instant) .format(instant)
} }
/** Занятый интервал участника (free/busy), millis UTC. */
data class BusyPeriod(val startEpochMilli: Long, val endEpochMilli: Long)
/**
* Запрос занятости участников через scheduling outbox (iTIP VFREEBUSY REQUEST).
* Возвращает email → занятые интервалы; участник без данных — пустой список.
*/
fun freeBusy(
client: OkHttpClient,
serverUrl: String,
userId: String,
organizerEmail: String,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<BusyPeriod>> {
if (organizerEmail.isBlank() || attendeeEmails.isEmpty()) return emptyMap()
val outbox = calendarsBase(serverUrl, userId).trimEnd('/') + "/outbox/"
val ics = buildString {
appendLine("BEGIN:VCALENDAR")
appendLine("VERSION:2.0")
appendLine("PRODID:-//F7cloud Mobile//EN")
appendLine("METHOD:REQUEST")
appendLine("BEGIN:VFREEBUSY")
appendLine("UID:${UUID.randomUUID()}@f7cloud.mobile")
appendLine("DTSTAMP:${formatCalDavTime(Instant.now())}")
appendLine("DTSTART:${formatCalDavTime(rangeStart)}")
appendLine("DTEND:${formatCalDavTime(rangeEnd)}")
appendLine("ORGANIZER:mailto:$organizerEmail")
attendeeEmails.forEach { appendLine("ATTENDEE:mailto:$it") }
appendLine("END:VFREEBUSY")
appendLine("END:VCALENDAR")
}
val req = Request.Builder()
.url(outbox)
.post(ics.toRequestBody("text/calendar; charset=utf-8".toMediaType()))
.build()
val xml = client.newCall(req).execute().use { response ->
if (!response.isSuccessful) error("CalDAV free-busy HTTP ${response.code}")
response.body?.string().orEmpty()
}
return parseFreeBusyResponse(xml)
}
private fun parseFreeBusyResponse(xml: String): Map<String, List<BusyPeriod>> {
val out = mutableMapOf<String, List<BusyPeriod>>()
val parser = newParser(xml)
var recipient = ""
var calendarData = StringBuilder()
var inCalendarData = false
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) {
"response" -> { recipient = ""; calendarData = StringBuilder() }
"href" -> if (recipient.isBlank()) recipient = parser.readText().trim()
"calendar-data" -> { inCalendarData = true; calendarData = StringBuilder() }
}
XmlPullParser.TEXT -> if (inCalendarData) calendarData.append(parser.text)
XmlPullParser.END_TAG -> when (parser.localTag()) {
"calendar-data" -> inCalendarData = false
"response" -> if (recipient.isNotBlank()) {
val email = recipient.removePrefix("mailto:").trim()
out[email] = parseBusyPeriods(calendarData.toString())
}
}
}
parser.next()
}
return out
}
private val freeBusyLine = Pattern.compile("^FREEBUSY[^:]*:(.+)$", Pattern.MULTILINE or Pattern.CASE_INSENSITIVE)
private fun parseBusyPeriods(ics: String): List<BusyPeriod> {
val unfolded = ics.replace(Regex("\\r?\\n[ \\t]"), "")
val out = mutableListOf<BusyPeriod>()
val m = freeBusyLine.matcher(unfolded)
while (m.find()) {
m.group(1).orEmpty().split(',').forEach { period ->
val parts = period.trim().split('/')
if (parts.size != 2) return@forEach
val start = CalendarIcs.parseIcsInstant(parts[0]) ?: return@forEach
val end = if (parts[1].startsWith("P", ignoreCase = true)) {
runCatching { start.plus(java.time.Duration.parse(parts[1])) }.getOrNull() ?: return@forEach
} else {
CalendarIcs.parseIcsInstant(parts[1]) ?: return@forEach
}
out += BusyPeriod(start.toEpochMilli(), end.toEpochMilli())
}
}
return out.sortedBy { it.startEpochMilli }
}
private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> { private fun parseCalendars(xml: String, baseUrl: String): List<DavCalendar> {
val parser = newParser(xml) val parser = newParser(xml)
val out = mutableListOf<DavCalendar>() val out = mutableListOf<DavCalendar>()
@@ -809,6 +1040,7 @@ object CalDavClient {
var calendarColor: String? = null var calendarColor: String? = null
var ctag = "" var ctag = ""
var isCollection = false var isCollection = false
var comps = mutableSetOf<String>()
while (parser.eventType != XmlPullParser.END_DOCUMENT) { while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) { when (parser.eventType) {
XmlPullParser.START_TAG -> when (parser.localTag()) { XmlPullParser.START_TAG -> when (parser.localTag()) {
@@ -819,8 +1051,12 @@ object CalDavClient {
calendarColor = null calendarColor = null
ctag = "" ctag = ""
isCollection = false isCollection = false
comps = mutableSetOf()
} }
"collection" -> if (inResponse) isCollection = true "collection" -> if (inResponse) isCollection = true
"comp" -> if (inResponse) {
parser.getAttributeValue(null, "name")?.trim()?.uppercase()?.let { comps += it }
}
"displayname" -> if (inResponse) displayName = parser.readText().trim() "displayname" -> if (inResponse) displayName = parser.readText().trim()
"calendar-color" -> if (inResponse) calendarColor = parser.readText().trim() "calendar-color" -> if (inResponse) calendarColor = parser.readText().trim()
"getctag" -> if (inResponse) ctag = parser.readText().trim() "getctag" -> if (inResponse) ctag = parser.readText().trim()
@@ -837,7 +1073,15 @@ object CalDavClient {
val name = displayName.ifBlank { val name = displayName.ifBlank {
fullPath.removePrefix(basePath).trim('/').substringAfterLast('/') fullPath.removePrefix(basePath).trim('/').substringAfterLast('/')
} }
out += DavCalendar(href = full, displayName = name, color = calendarColor, ctag = ctag) // Свойство не вернулось (comps пуст) → считаем событийным (не ломаем).
val supportsEvents = comps.isEmpty() || "VEVENT" in comps
out += DavCalendar(
href = full,
displayName = name,
color = calendarColor,
ctag = ctag,
supportsEvents = supportsEvents,
)
} }
} }
inResponse = false inResponse = false
@@ -23,6 +23,12 @@ data class CalendarAlarmData(
val action: String = "DISPLAY", val action: String = "DISPLAY",
) )
data class CalendarAttachmentData(
val url: String,
val fileName: String = "",
val mimeType: String = "",
)
data class CalendarEventData( data class CalendarEventData(
val uid: String, val uid: String,
val summary: String, val summary: String,
@@ -40,6 +46,7 @@ data class CalendarEventData(
val organizerName: String = "", val organizerName: String = "",
val alarms: List<CalendarAlarmData> = emptyList(), val alarms: List<CalendarAlarmData> = emptyList(),
val conferenceUri: String = "", val conferenceUri: String = "",
val attachments: List<CalendarAttachmentData> = emptyList(),
) )
object CalendarIcs { object CalendarIcs {
@@ -99,6 +106,13 @@ object CalendarIcs {
val talkUrl = conference.ifBlank { val talkUrl = conference.ifBlank {
if (location.contains("/call/")) location else "" if (location.contains("/call/")) location else ""
} }
val attachments = props.filter { it.name == "ATTACH" && it.value.isNotBlank() }.map {
CalendarAttachmentData(
url = it.value.trim(),
fileName = it.params["FILENAME"]?.let(::unescape).orEmpty(),
mimeType = it.params["FMTTYPE"].orEmpty(),
)
}
return CalendarEventData( return CalendarEventData(
uid = uid, uid = uid,
summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" }, summary = unescape(lines["SUMMARY"].orEmpty()).ifBlank { "(без названия)" },
@@ -116,6 +130,7 @@ object CalendarIcs {
organizerName = orgName, organizerName = orgName,
alarms = alarms, alarms = alarms,
conferenceUri = talkUrl, conferenceUri = talkUrl,
attachments = attachments,
) )
} }
@@ -163,6 +178,13 @@ object CalendarIcs {
appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk") appendLine("CONFERENCE;FEATURE=PHONE,VIDEO;VALUE=URI:$talk")
if (data.location.isBlank()) appendLine("LOCATION:$talk") if (data.location.isBlank()) appendLine("LOCATION:$talk")
} }
data.attachments.forEach { att ->
if (att.url.isNotBlank()) {
val fmt = if (att.mimeType.isNotBlank()) ";FMTTYPE=${att.mimeType}" else ""
val fname = if (att.fileName.isNotBlank()) ";FILENAME=${escape(att.fileName)}" else ""
appendLine("ATTACH$fmt$fname:${att.url}")
}
}
data.alarms.forEach { alarm -> data.alarms.forEach { alarm ->
appendLine("BEGIN:VALARM") appendLine("BEGIN:VALARM")
appendLine("ACTION:${alarm.action}") appendLine("ACTION:${alarm.action}")
@@ -33,6 +33,14 @@ object NetworkFactory {
clients.clear() // пересобрать производные клиенты уже с кэшем clients.clear() // пересобрать производные клиенты уже с кэшем
} }
/** Билдер на основе базового клиента (пул/кэш) c опциональным trust-all TLS, без auth. */
fun newBaseBuilder(trustAllCerts: Boolean = false): OkHttpClient.Builder =
base.newBuilder()
.callTimeout(30, TimeUnit.SECONDS)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.applyUnsafeSslIfNeeded(trustAllCerts)
fun newAuthedClient( fun newAuthedClient(
username: String, username: String,
appPassword: String, appPassword: String,
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.core.network
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
/**
* Устанавливает НАСТОЯЩУЮ веб-сессию Nextcloud (cookie + CSRF) по форме `/login`, используя
* app-пароль как пароль (он валиден для входа и обходит 2FA). Нужно для встраивания веб-viewer'ов
* (доска Whiteboard и т.п.), которым мало Basic-auth: их Vue-XHR ходят по сессии.
*/
object NextcloudWebSession {
private val requestTokenRegex = Regex(
"""(?:name=["']requesttoken["']\s+value|<meta\s+name=["']requesttoken["']\s+content)=["']([^"']+)["']""",
RegexOption.IGNORE_CASE,
)
/** Cookie, собранные при логине (для переноса в WebView CookieManager). */
fun login(
serverUrl: String,
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
): List<Cookie> {
val base = serverUrl.trimEnd('/')
val host = base.toHttpUrl().host
val jar = MemoryCookieJar()
val client = NetworkFactory.newBaseBuilder(trustAllCerts)
.cookieJar(jar)
.followRedirects(true)
.followSslRedirects(true)
.build()
// 1. GET страницы логина — начальные cookie + requesttoken.
val loginHtml = client.newCall(
Request.Builder().url("$base/login").header("User-Agent", USER_AGENT).get().build(),
).execute().use { it.body?.string().orEmpty() }
val token = requestTokenRegex.find(loginHtml)?.groupValues?.getOrNull(1).orEmpty()
// 2. POST формы логина app-паролем — сервер ставит session-cookie.
val form = FormBody.Builder()
.add("user", username)
.add("password", appPassword)
.apply { if (token.isNotBlank()) add("requesttoken", token) }
.add("remember_login", "1")
.add("timezone", "UTC")
.add("timezone_offset", "0")
.build()
client.newCall(
Request.Builder()
.url("$base/login")
.header("User-Agent", USER_AGENT)
.apply { if (token.isNotBlank()) header("requesttoken", token) }
.header("OCS-APIRequest", "true")
.post(form)
.build(),
).execute().use { /* cookie осели в jar */ }
return jar.cookiesForHost(host)
}
private const val USER_AGENT = "Mozilla/5.0 (Android) F7cloud-Mobile"
private class MemoryCookieJar : CookieJar {
private val store = ConcurrentHashMap<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { c ->
list.removeAll { it.name == c.name }
list += c
}
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host].orEmpty().filter { it.matches(url) }
fun cookiesForHost(host: String): List<Cookie> = store[host].orEmpty().toList()
}
}
+1
View File
@@ -32,6 +32,7 @@ dependencies {
implementation composeBom implementation composeBom
implementation libs.compose.ui implementation libs.compose.ui
implementation libs.compose.material3 implementation libs.compose.material3
implementation libs.compose.material.icons.extended
implementation libs.compose.foundation implementation libs.compose.foundation
implementation libs.lifecycle.viewmodel.compose implementation libs.lifecycle.viewmodel.compose
implementation libs.activity.compose implementation libs.activity.compose
@@ -25,6 +25,19 @@ data class CalendarLocationSuggestion(
val address: String, val address: String,
) )
data class CalendarTalkRoom(
val token: String,
val displayName: String,
val callUrl: String,
)
data class CalendarShareeSuggestion(
val displayName: String,
/** DAV-принципал: principal:principals/users/<id> или groups/<id>. */
val principal: String,
val isGroup: Boolean,
)
data class CalendarUserSettings( data class CalendarUserSettings(
val timezone: String = "automatic", val timezone: String = "automatic",
val showWeekends: Boolean = true, val showWeekends: Boolean = true,
@@ -106,6 +119,105 @@ class CalendarApiClient {
} }
} }
/** Email текущего пользователя (для ORGANIZER в iTIP free-busy). */
fun getUserEmail(session: AuthSession): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "user info")
json.optJSONObject("ocs")?.optJSONObject("data")?.optString("email").orEmpty()
}
}
/** Поиск пользователей/групп для шаринга календаря (OCS sharees). */
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val q = java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v1.php/apps/files_sharing/api/v1/sharees" +
"?format=json&itemType=calendar&perPage=10&search=$q"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "sharees")
val data = json.optJSONObject("ocs")?.optJSONObject("data") ?: return emptyList()
val out = mutableListOf<CalendarShareeSuggestion>()
fun collect(arrName: String, isGroup: Boolean) {
val exactWrap = data.optJSONObject("exact")?.optJSONArray(arrName) ?: JSONArray()
val plain = data.optJSONArray(arrName) ?: JSONArray()
for (arr in listOf(exactWrap, plain)) {
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
val id = item.optJSONObject("value")?.optString("shareWith").orEmpty()
if (id.isBlank()) continue
val kind = if (isGroup) "groups" else "users"
out += CalendarShareeSuggestion(
displayName = item.optString("label").ifBlank { id },
principal = "principal:principals/$kind/$id",
isGroup = isGroup,
)
}
}
}
collect("users", isGroup = false)
collect("groups", isGroup = true)
out.distinctBy { it.principal }
}
}
/** Существующие беседы Talk (для выбора комнаты в событии). */
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val request = Request.Builder().url(url).get().applyOcsJson().build()
return client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "list talk rooms")
if (!isOcsSuccess(json.ocsMeta())) return emptyList()
val data = json.optJSONObject("ocs")?.optJSONArray("data") ?: JSONArray()
val out = mutableListOf<CalendarTalkRoom>()
for (i in 0 until data.length()) {
val room = data.optJSONObject(i) ?: continue
val token = room.optString("token")
if (token.isBlank()) continue
val name = room.optString("displayName").ifBlank { room.optString("name") }
out += CalendarTalkRoom(
token = token,
displayName = name.ifBlank { "Беседа" },
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
)
}
out
}
}
/** Создать беседу заданного типа (2 — групповая/публичная, 3 — публичная по ссылке). */
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val payload = JSONObject()
.put("roomType", roomType)
.put("roomName", roomName.trim().ifBlank { "Встреча" })
.put("readOnly", 0)
.put("listable", 0)
.toString()
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
if (!isOcsSuccess(json.ocsMeta())) error("Не удалось создать беседу Talk")
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
if (token.isBlank()) error("Не удалось создать беседу Talk")
return CalendarTalkRoom(
token = token,
displayName = roomName.trim().ifBlank { "Встреча" },
callUrl = "${session.serverUrl.trimEnd('/')}/call/$token",
)
}
}
fun createTalkRoom(session: AuthSession, roomName: String): String { fun createTalkRoom(session: AuthSession, roomName: String): String {
val client = authedClient(session) val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json" val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
@@ -23,26 +23,26 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -77,7 +77,6 @@ import java.util.Locale
private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс") private val weekDayLabels = listOf("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru")) private val dayTitleFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
private val listDayFormatter = DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.forLanguageTag("ru")) private val listDayFormatter = DateTimeFormatter.ofPattern("EEEE, d MMMM", Locale.forLanguageTag("ru"))
private val toolbarDateFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)
private val dayHeaderFormatter = DateTimeFormatter.ofPattern("EE dd.M.yyyy", Locale.forLanguageTag("ru")) private val dayHeaderFormatter = DateTimeFormatter.ofPattern("EE dd.M.yyyy", Locale.forLanguageTag("ru"))
private fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) { private fun viewModeIconUrl(base: String, mode: CalendarViewMode): String = when (mode) {
@@ -865,6 +864,7 @@ fun CalendarNavigationSidebar(
onOpenTasks: () -> Unit, onOpenTasks: () -> Unit,
onCreateBook: () -> Unit, onCreateBook: () -> Unit,
onSyncNow: () -> Unit, onSyncNow: () -> Unit,
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
) { ) {
val base = session.serverUrl.trimEnd('/') val base = session.serverUrl.trimEnd('/')
val (myCalendars, deckCalendars) = remember(state.calendars) { val (myCalendars, deckCalendars) = remember(state.calendars) {
@@ -916,7 +916,7 @@ fun CalendarNavigationSidebar(
onAdd = onCreateBook, onAdd = onCreateBook,
) )
myCalendars.forEach { cal -> myCalendars.forEach { cal ->
CalendarSidebarCalendarRow(cal, onToggleCalendar) CalendarSidebarCalendarRow(cal, onToggleCalendar, onEditCalendar)
} }
if (deckCalendars.isNotEmpty()) { if (deckCalendars.isNotEmpty()) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -1006,6 +1006,7 @@ private fun CalendarSidebarCaption(
private fun CalendarSidebarCalendarRow( private fun CalendarSidebarCalendarRow(
cal: CalendarBookItem, cal: CalendarBookItem,
onToggleCalendar: (String) -> Unit, onToggleCalendar: (String) -> Unit,
onEditCalendar: ((CalendarBookItem) -> Unit)? = null,
) { ) {
val accent = calendarAccentColor(cal) val accent = calendarAccentColor(cal)
Row( Row(
@@ -1029,7 +1030,14 @@ private fun CalendarSidebarCalendarRow(
color = F7Colors.TextPrimary, color = F7Colors.TextPrimary,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
// Подписки редактировать нельзя (PROPPATCH/шаринг не применимы)
if (onEditCalendar != null && !cal.isSubscription) {
IconButton(onClick = { onEditCalendar(cal) }, modifier = Modifier.size(28.dp)) {
Text("", color = F7Colors.TextSecondary, fontSize = 16.sp)
}
}
} }
} }
@@ -1217,23 +1225,26 @@ fun CalendarDatePickerDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (LocalDate) -> Unit, onConfirm: (LocalDate) -> Unit,
) { ) {
val state = rememberDatePickerState( // Нативный DatePickerDialog вместо material3.DatePicker: material3-версия (BOM 2025.02)
initialSelectedDateMillis = selectedDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(), // падала при открытии, а платформенный диалог стабилен и сам локализуется под системную
) // локаль (русские месяцы). Показываем один раз, пока композабл присутствует в дереве.
DatePickerDialog( val context = LocalContext.current
onDismissRequest = onDismiss, val currentOnConfirm by rememberUpdatedState(onConfirm)
confirmButton = { val currentOnDismiss by rememberUpdatedState(onDismiss)
TextButton(onClick = { DisposableEffect(Unit) {
val millis = state.selectedDateMillis ?: return@TextButton val dialog = android.app.DatePickerDialog(
val day = Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDate() context,
onConfirm(day) { _, year, month, dayOfMonth ->
}) { Text("OK") } currentOnConfirm(LocalDate.of(year, month + 1, dayOfMonth))
}, },
dismissButton = { selectedDay.year,
TextButton(onClick = onDismiss) { Text("Отмена") } selectedDay.monthValue - 1,
}, selectedDay.dayOfMonth,
) { )
DatePicker(state = state) // onDismiss закрывает и по «Отмена», и после выбора (диалог сам себя закрывает).
dialog.setOnDismissListener { currentOnDismiss() }
dialog.show()
onDispose { dialog.setOnDismissListener(null); dialog.dismiss() }
} }
} }
@@ -69,6 +69,8 @@ data class CalendarTaskItem(
data class CalendarEventDraft( data class CalendarEventDraft(
val title: String = "", val title: String = "",
val date: java.time.LocalDate = java.time.LocalDate.now(), val date: java.time.LocalDate = java.time.LocalDate.now(),
/** Дата окончания; null = совпадает с датой начала (однодневное событие). */
val endDate: java.time.LocalDate? = null,
val startTime: String = "10:00", val startTime: String = "10:00",
val endTime: String = "11:00", val endTime: String = "11:00",
val allDay: Boolean = false, val allDay: Boolean = false,
@@ -82,9 +84,17 @@ data class CalendarEventDraft(
val classification: EventClassification = EventClassification.PUBLIC, val classification: EventClassification = EventClassification.PUBLIC,
val attendees: List<CalendarAttendeeItem> = emptyList(), val attendees: List<CalendarAttendeeItem> = emptyList(),
val attendeeQuery: String = "", val attendeeQuery: String = "",
val reminderMinutes: Int = 15, /** Напоминания в минутах до начала (0 = во время начала); поддерживается несколько. */
val reminders: List<Int> = listOf(15),
val addTalkRoom: Boolean = false, val addTalkRoom: Boolean = false,
val talkRoomUrl: String = "", val talkRoomUrl: String = "",
val attachments: List<CalendarAttachmentItem> = emptyList(),
)
data class CalendarAttachmentItem(
val fileName: String,
val url: String,
val mimeType: String = "",
) )
data class CalendarCreateBookDraft( data class CalendarCreateBookDraft(
@@ -1,18 +1,24 @@
package ru.forbion.f7cloud.feature.calendar package ru.forbion.f7cloud.feature.calendar
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.CalendarAlarmData import ru.forbion.f7cloud.core.network.CalendarAlarmData
import ru.forbion.f7cloud.core.network.CalendarAttachmentData
import ru.forbion.f7cloud.core.network.CalendarAttendeeData import ru.forbion.f7cloud.core.network.CalendarAttendeeData
import ru.forbion.f7cloud.core.network.CalendarEventData import ru.forbion.f7cloud.core.network.CalendarEventData
import ru.forbion.f7cloud.core.network.CalendarIcs import ru.forbion.f7cloud.core.network.CalendarIcs
import ru.forbion.f7cloud.core.network.CalDavClient import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavCalendar import ru.forbion.f7cloud.core.network.DavCalendar
import ru.forbion.f7cloud.core.network.DavClient
import ru.forbion.f7cloud.core.network.DavEvent import ru.forbion.f7cloud.core.network.DavEvent
import ru.forbion.f7cloud.core.network.DavTask import ru.forbion.f7cloud.core.network.DavTask
import ru.forbion.f7cloud.core.network.DavTrashEvent import ru.forbion.f7cloud.core.network.DavTrashEvent
import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -104,13 +110,111 @@ class CalendarRepository(
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName) fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
fun listTalkRooms(session: AuthSession): List<CalendarTalkRoom> = apiClient.listTalkRooms(session)
private fun davUserId(session: AuthSession): String =
session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
// --- Управление календарём ---
fun updateCalendarProps(session: AuthSession, calendarHref: String, displayName: String?, color: String?) {
CalDavClient.updateCalendarProps(davClient(session), calendarHref, displayName, color)
}
fun deleteCalendar(session: AuthSession, calendarHref: String) {
CalDavClient.deleteCalendar(davClient(session), calendarHref)
}
fun listCalendarShares(session: AuthSession, calendarHref: String): List<CalDavClient.DavSharee> =
CalDavClient.listCalendarShares(davClient(session), calendarHref)
fun shareCalendar(session: AuthSession, calendarHref: String, principal: String, writable: Boolean) {
CalDavClient.shareCalendar(davClient(session), calendarHref, principal, writable)
}
fun unshareCalendar(session: AuthSession, calendarHref: String, principal: String) {
CalDavClient.unshareCalendar(davClient(session), calendarHref, principal)
}
fun searchSharees(session: AuthSession, query: String): List<CalendarShareeSuggestion> =
apiClient.searchSharees(session, query)
/** Занятость участников (email → интервалы) за диапазон. */
fun freeBusy(
session: AuthSession,
attendeeEmails: List<String>,
rangeStart: Instant,
rangeEnd: Instant,
): Map<String, List<CalDavClient.BusyPeriod>> {
val organizer = apiClient.getUserEmail(session)
if (organizer.isBlank()) error("У пользователя не задан email — занятость недоступна")
return CalDavClient.freeBusy(
davClient(session),
session.serverUrl,
davUserId(session),
organizer,
attendeeEmails,
rangeStart,
rangeEnd,
)
}
private fun davClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
/** Загрузка файла с устройства в папку /Calendar и возврат ATTACH-элемента (WebDAV-URL). */
fun uploadAttachment(
session: AuthSession,
fileName: String,
mimeType: String,
bytes: ByteArray,
): CalendarAttachmentItem {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val userId = davUserId(session)
// Папка для вложений календаря; создаём best-effort (уже существует → игнорируем).
runCatching { DavClient.mkcol(client, davFolderUrl(session.serverUrl, userId, "Calendar")) }
val safeName = sanitizeFileName(fileName)
val fileUrl = davFileUrl(session.serverUrl, userId, "Calendar/$safeName")
val media = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
DavClient.put(client, fileUrl, bytes.toRequestBody(media))
return CalendarAttachmentItem(fileName = safeName, url = fileUrl, mimeType = mimeType)
}
/** Список файлов/папок в директории (для пикера «Добавить из файлов»). relativePath — от корня. */
fun listUserFiles(session: AuthSession, relativePath: String): List<DavClient.DavEntry> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val userId = davUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
val entries = DavClient.propfind(client, folderUrl, depth = 1)
val self = folderUrl.trimEnd('/')
// Убираем саму папку (первый ответ PROPFIND) и скрытые файлы.
return entries
.filter { it.href.trimEnd('/') != self && !it.name.startsWith('.') }
.sortedWith(compareByDescending<DavClient.DavEntry> { it.isDirectory }.thenBy { it.name.lowercase() })
}
/** Полный WebDAV-URL файла (для ATTACH при выборе из Файлов). */
fun fileAttachment(session: AuthSession, relativePath: String, mimeType: String): CalendarAttachmentItem {
val userId = davUserId(session)
val url = davFileUrl(session.serverUrl, userId, relativePath)
return CalendarAttachmentItem(fileName = relativePath.substringAfterLast('/'), url = url, mimeType = mimeType)
}
private fun sanitizeFileName(name: String): String =
name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
.replace(Regex("[\\r\\n\"]"), "_")
fun createTalkRoomTyped(session: AuthSession, roomName: String, roomType: Int): CalendarTalkRoom =
apiClient.createTalkRoomTyped(session, roomName, roomType)
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) { fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
apiClient.addTalkParticipants(session, callUrl, emails) apiClient.addTalkParticipants(session, callUrl, emails)
} }
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem { fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
val ctx = openDavContext(session) val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar val picked = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
// Если выбранная коллекция не принимает VEVENT (список задач) — уводим в событийный дефолт.
val calendar = if (picked.supportsEvents) picked else ctx.defaultCalendar
var talkUrl = draft.talkRoomUrl var talkUrl = draft.talkRoomUrl
if (draft.addTalkRoom && talkUrl.isBlank()) { if (draft.addTalkRoom && talkUrl.isBlank()) {
talkUrl = apiClient.createTalkRoom(session, draft.title) talkUrl = apiClient.createTalkRoom(session, draft.title)
@@ -197,10 +301,12 @@ class CalendarRepository(
val base = CalDavClient.calendarsBase(session.serverUrl, userId) val base = CalDavClient.calendarsBase(session.serverUrl, userId)
val calendars = CalDavClient.listCalendars(client, base) val calendars = CalDavClient.listCalendars(client, base)
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены") if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
val default = calendars.firstOrNull { cal -> // Дефолт под события — только событийная коллекция (не VTODO-список задач), иначе PUT 403.
val eventCals = calendars.filter { it.supportsEvents }.ifEmpty { calendars }
val default = eventCals.firstOrNull { cal ->
val name = cal.displayName.lowercase() val name = cal.displayName.lowercase()
name.contains("личн") || name.contains("personal") || name == "calendar" name.contains("личн") || name.contains("personal") || name == "calendar"
} ?: calendars.first() } ?: eventCals.first()
return CalDavContext(client, calendars, default) return CalDavContext(client, calendars, default)
} }
@@ -264,6 +370,7 @@ class CalendarRepository(
organizerEmail = organizerEmail, organizerEmail = organizerEmail,
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" }, talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) }, alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
source = CalendarEventSource.F7CLOUD, source = CalendarEventSource.F7CLOUD,
) )
@@ -361,23 +468,27 @@ data class CalendarEventItem(
val organizerEmail: String = "", val organizerEmail: String = "",
val talkRoomUrl: String = "", val talkRoomUrl: String = "",
val alarms: List<CalendarReminderItem> = emptyList(), val alarms: List<CalendarReminderItem> = emptyList(),
val attachments: List<CalendarAttachmentItem> = emptyList(),
val source: CalendarEventSource = CalendarEventSource.F7CLOUD, val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
) )
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData { private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
val zone = ZoneId.systemDefault() val zone = ZoneId.systemDefault()
// Многодневные события: дата окончания отдельная (не раньше даты начала).
val endDay = (endDate ?: date).coerceAtLeast(date)
val start = if (allDay) { val start = if (allDay) {
date.atStartOfDay(zone).toInstant().toEpochMilli() date.atStartOfDay(zone).toInstant().toEpochMilli()
} else { } else {
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli() parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli() ?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
} }
val end = if (allDay) { val endRaw = if (allDay) {
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() endDay.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
} else { } else {
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli() parseDateTime(endDay, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli() ?: endDay.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
} }
val end = endRaw.coerceAtLeast(start)
val rrule = when { val rrule = when {
recurrence != RecurrencePreset.NONE -> recurrence.rrule recurrence != RecurrencePreset.NONE -> recurrence.rrule
customRrule.isNotBlank() -> customRrule customRrule.isNotBlank() -> customRrule
@@ -400,8 +511,9 @@ private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String
attendees = attendees.map { attendees = attendees.map {
CalendarAttendeeData(it.email, it.displayName, it.partStat) CalendarAttendeeData(it.email, it.displayName, it.partStat)
}, },
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(), alarms = reminders.filter { it >= 0 }.distinct().map { CalendarAlarmData(it) },
conferenceUri = conf, conferenceUri = conf,
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
) )
} }
@@ -421,6 +533,7 @@ private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventDa
organizerEmail = organizerEmail, organizerEmail = organizerEmail,
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) }, alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = talkRoomUrl, conferenceUri = talkRoomUrl,
attachments = attachments.map { CalendarAttachmentData(it.url, it.fileName, it.mimeType) },
) )
private fun CalendarEventData.toItem( private fun CalendarEventData.toItem(
@@ -448,6 +561,7 @@ private fun CalendarEventData.toItem(
attendees = attendees.map { it.toItem() }, attendees = attendees.map { it.toItem() },
talkRoomUrl = conferenceUri, talkRoomUrl = conferenceUri,
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) }, alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
attachments = attachments.map { CalendarAttachmentItem(it.fileName, it.url, it.mimeType) },
) )
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat) private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
@@ -74,6 +74,9 @@ fun CalendarScreen(
vm.updateEventDraft { it } // no-op; snack via error path if needed vm.updateEventDraft { it } // no-op; snack via error path if needed
} }
} }
val attachmentLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) vm.uploadDeviceAttachment(uri)
}
fun hasCalendarPermission(): Boolean { fun hasCalendarPermission(): Boolean {
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
@@ -220,6 +223,48 @@ fun CalendarScreen(
onRemoveAttendee = vm::removeAttendee, onRemoveAttendee = vm::removeAttendee,
onSearchLocations = vm::searchLocations, onSearchLocations = vm::searchLocations,
onApplyLocation = vm::applyLocationSuggestion, onApplyLocation = vm::applyLocationSuggestion,
onOpenTalkPicker = vm::openTalkPicker,
onPickDeviceAttachment = { attachmentLauncher.launch("*/*") },
onPickFilesAttachment = vm::openFilesPicker,
onRemoveAttachment = vm::removeAttachment,
onOpenFreeBusy = vm::openFreeBusy,
)
}
if (state.freeBusyOpen) {
CalendarFreeBusyDialog(
state = state,
onDismiss = vm::closeFreeBusy,
)
}
if (state.talkPickerOpen) {
CalendarTalkPickerDialog(
state = state,
onDismiss = vm::closeTalkPicker,
onSelect = vm::selectTalkRoom,
onCreate = vm::createTalkConversation,
)
}
if (state.filesPickerOpen) {
CalendarFilesPickerDialog(
state = state,
onDismiss = vm::closeFilesPicker,
onNavigate = vm::navigateFilesPicker,
onPick = vm::pickFileAttachment,
)
}
if (state.editCalendarOpen) {
CalendarEditBookDialog(
state = state,
onDismiss = vm::closeEditCalendar,
onSave = vm::saveCalendarProps,
onDelete = vm::deleteCalendarConfirmed,
onSearchSharees = vm::searchSharees,
onAddSharee = vm::addSharee,
onRemoveSharee = vm::removeSharee,
) )
} }
@@ -287,6 +332,11 @@ fun CalendarScreen(
onSyncNow = { onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow() if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
}, },
onEditCalendar = { cal ->
onSidebarOpenChange(false)
scope.launch { drawerState.close() }
vm.openEditCalendar(cal)
},
) )
} }
}, },
@@ -45,6 +45,23 @@ data class CalendarUiState(
val unscheduledTasks: List<CalendarTaskItem> = emptyList(), val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(), val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(), val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
val talkPickerOpen: Boolean = false,
val talkPickerLoading: Boolean = false,
val talkRooms: List<CalendarTalkRoom> = emptyList(),
val attachmentUploading: Boolean = false,
val editCalendarOpen: Boolean = false,
val editCalendarTarget: CalendarBookItem? = null,
val editCalendarShares: List<ru.forbion.f7cloud.core.network.CalDavClient.DavSharee> = emptyList(),
val shareeSuggestions: List<CalendarShareeSuggestion> = emptyList(),
val editCalendarBusy: Boolean = false,
val freeBusyOpen: Boolean = false,
val freeBusyLoading: Boolean = false,
val freeBusyError: String? = null,
val freeBusyResult: Map<String, List<ru.forbion.f7cloud.core.network.CalDavClient.BusyPeriod>> = emptyMap(),
val filesPickerOpen: Boolean = false,
val filesPickerLoading: Boolean = false,
val filesPickerPath: String = "",
val filesPickerEntries: List<ru.forbion.f7cloud.core.network.DavClient.DavEntry> = emptyList(),
val userSettings: CalendarUserSettings = CalendarUserSettings(), val userSettings: CalendarUserSettings = CalendarUserSettings(),
val saving: Boolean = false, val saving: Boolean = false,
val deleting: Boolean = false, val deleting: Boolean = false,
@@ -362,7 +379,7 @@ class CalendarViewModel(
eventDraft = CalendarEventDraft( eventDraft = CalendarEventDraft(
date = targetDay, date = targetDay,
calendarHref = createHref, calendarHref = createHref,
reminderMinutes = _state.value.userSettings.defaultReminderMinutes, reminders = listOf(_state.value.userSettings.defaultReminderMinutes),
), ),
) )
} }
@@ -410,6 +427,212 @@ class CalendarViewModel(
_state.value = _state.value.copy(locationSuggestions = emptyList()) _state.value = _state.value.copy(locationSuggestions = emptyList())
} }
fun openTalkPicker() {
val s = session ?: return
_state.value = _state.value.copy(talkPickerOpen = true, talkPickerLoading = true, talkRooms = emptyList())
viewModelScope.launch(Dispatchers.IO) {
val rooms = runCatching { repository.listTalkRooms(s) }.getOrDefault(emptyList())
_state.value = _state.value.copy(talkRooms = rooms, talkPickerLoading = false)
}
}
fun closeTalkPicker() { _state.value = _state.value.copy(talkPickerOpen = false) }
fun selectTalkRoom(room: CalendarTalkRoom) {
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
_state.value = _state.value.copy(talkPickerOpen = false)
}
/** roomType: 2 — публичная беседа, 3 — приватная (в терминах spreed). */
fun createTalkConversation(roomType: Int) {
val s = session ?: return
val name = _state.value.eventDraft.title.ifBlank { "Встреча" }
_state.value = _state.value.copy(talkPickerLoading = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.createTalkRoomTyped(s, name, roomType) }
.onSuccess { room ->
updateEventDraft { it.copy(talkRoomUrl = room.callUrl, addTalkRoom = false, location = it.location.ifBlank { room.callUrl }) }
_state.value = _state.value.copy(talkPickerOpen = false, talkPickerLoading = false)
}
.onFailure { _state.value = _state.value.copy(talkPickerLoading = false, snackMessage = "Не удалось создать беседу") }
}
}
// --- Управление календарём ---
fun openEditCalendar(cal: CalendarBookItem) {
val s = session ?: return
_state.value = _state.value.copy(
editCalendarOpen = true,
editCalendarTarget = cal,
editCalendarShares = emptyList(),
shareeSuggestions = emptyList(),
)
viewModelScope.launch(Dispatchers.IO) {
val shares = runCatching { repository.listCalendarShares(s, cal.href) }.getOrDefault(emptyList())
_state.value = _state.value.copy(editCalendarShares = shares)
}
}
fun closeEditCalendar() {
_state.value = _state.value.copy(editCalendarOpen = false, editCalendarTarget = null, shareeSuggestions = emptyList())
}
fun saveCalendarProps(displayName: String, color: String?) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
_state.value = _state.value.copy(editCalendarBusy = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.updateCalendarProps(
s,
target.href,
displayName.trim().takeIf { it.isNotBlank() && it != target.displayName },
color?.takeIf { it != target.color },
)
}
.onSuccess {
loadCalendars(s)
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь обновлён")
}
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось обновить: ${it.message}") }
}
}
fun deleteCalendarConfirmed() {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
_state.value = _state.value.copy(editCalendarBusy = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.deleteCalendar(s, target.href) }
.onSuccess {
loadCalendars(s)
reloadEvents(s)
_state.value = _state.value.copy(editCalendarBusy = false, editCalendarOpen = false, editCalendarTarget = null, snackMessage = "Календарь удалён")
}
.onFailure { _state.value = _state.value.copy(editCalendarBusy = false, snackMessage = "Не удалось удалить: ${it.message}") }
}
}
fun searchSharees(query: String) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
val found = runCatching { repository.searchSharees(s, query) }.getOrDefault(emptyList())
_state.value = _state.value.copy(shareeSuggestions = found)
}
}
fun addSharee(sharee: CalendarShareeSuggestion, writable: Boolean) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.shareCalendar(s, target.href, sharee.principal, writable) }
.onSuccess {
val shares = runCatching { repository.listCalendarShares(s, target.href) }.getOrDefault(emptyList())
_state.value = _state.value.copy(editCalendarShares = shares, shareeSuggestions = emptyList())
}
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось пошарить: ${it.message}") }
}
}
fun removeSharee(principal: String) {
val s = session ?: return
val target = _state.value.editCalendarTarget ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.unshareCalendar(s, target.href, principal) }
.onSuccess {
_state.value = _state.value.copy(
editCalendarShares = _state.value.editCalendarShares.filterNot { it.principal == principal },
)
}
.onFailure { _state.value = _state.value.copy(snackMessage = "Не удалось убрать доступ: ${it.message}") }
}
}
// --- Занятость участников (free/busy) ---
fun openFreeBusy() {
val s = session ?: return
val draft = _state.value.eventDraft
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
if (emails.isEmpty()) return
_state.value = _state.value.copy(freeBusyOpen = true, freeBusyLoading = true, freeBusyError = null, freeBusyResult = emptyMap())
val zone = ZoneId.systemDefault()
// Диапазон — весь день события (локальные сутки).
val start = draft.date.atStartOfDay(zone).toInstant()
val end = draft.date.plusDays(1).atStartOfDay(zone).toInstant()
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.freeBusy(s, emails, start, end) }
.onSuccess { _state.value = _state.value.copy(freeBusyResult = it, freeBusyLoading = false) }
.onFailure { _state.value = _state.value.copy(freeBusyLoading = false, freeBusyError = it.message) }
}
}
fun closeFreeBusy() { _state.value = _state.value.copy(freeBusyOpen = false) }
// --- Вложения ---
fun uploadDeviceAttachment(uri: android.net.Uri) {
val s = session ?: return
_state.value = _state.value.copy(attachmentUploading = true)
viewModelScope.launch(Dispatchers.IO) {
val resolver = appContext.contentResolver
val name = queryDisplayName(uri) ?: "attachment"
val mime = resolver.getType(uri).orEmpty()
val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
if (bytes == null || bytes.isEmpty()) {
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Не удалось прочитать файл")
return@launch
}
runCatching { repository.uploadAttachment(s, name, mime, bytes) }
.onSuccess { item ->
updateEventDraft { it.copy(attachments = it.attachments + item) }
_state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Файл загружен")
}
.onFailure { _state.value = _state.value.copy(attachmentUploading = false, snackMessage = "Ошибка загрузки: ${it.message}") }
}
}
fun openFilesPicker() {
_state.value = _state.value.copy(filesPickerOpen = true)
loadFilesPicker("")
}
fun closeFilesPicker() { _state.value = _state.value.copy(filesPickerOpen = false) }
fun navigateFilesPicker(path: String) = loadFilesPicker(path)
private fun loadFilesPicker(path: String) {
val s = session ?: return
_state.value = _state.value.copy(filesPickerLoading = true, filesPickerPath = path)
viewModelScope.launch(Dispatchers.IO) {
val entries = runCatching { repository.listUserFiles(s, path) }.getOrDefault(emptyList())
_state.value = _state.value.copy(filesPickerEntries = entries, filesPickerLoading = false)
}
}
fun pickFileAttachment(entry: ru.forbion.f7cloud.core.network.DavClient.DavEntry) {
val s = session ?: return
val path = _state.value.filesPickerPath
val rel = if (path.isBlank()) entry.name else "$path/${entry.name}"
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.fileAttachment(s, rel, entry.mimeType.orEmpty()) }
.onSuccess { item ->
updateEventDraft { it.copy(attachments = it.attachments + item) }
_state.value = _state.value.copy(filesPickerOpen = false, snackMessage = "Файл прикреплён")
}
}
}
fun removeAttachment(url: String) {
updateEventDraft { d -> d.copy(attachments = d.attachments.filterNot { it.url == url }) }
}
private fun queryDisplayName(uri: android.net.Uri): String? =
runCatching {
appContext.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
}
}.getOrNull() ?: uri.lastPathSegment
fun openEventDetail(event: CalendarEventItem) { fun openEventDetail(event: CalendarEventItem) {
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid) _state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
} }
@@ -428,6 +651,11 @@ class CalendarViewModel(
eventDraft = CalendarEventDraft( eventDraft = CalendarEventDraft(
title = event.summary, title = event.summary,
date = start.toLocalDate(), date = start.toLocalDate(),
// DTEND эксклюзивен для all-day → показываем последний включённый день.
endDate = run {
val endDay = if (event.allDay) end.toLocalDate().minusDays(1) else end.toLocalDate()
endDay.coerceAtLeast(start.toLocalDate()).takeIf { it != start.toLocalDate() }
},
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")), startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")), endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
allDay = event.allDay, allDay = event.allDay,
@@ -440,9 +668,10 @@ class CalendarViewModel(
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED, status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC, classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
attendees = event.attendees, attendees = event.attendees,
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15, reminders = event.alarms.map { it.minutesBefore }.ifEmpty { emptyList() },
talkRoomUrl = event.talkRoomUrl, talkRoomUrl = event.talkRoomUrl,
addTalkRoom = event.talkRoomUrl.isNotBlank(), addTalkRoom = event.talkRoomUrl.isNotBlank(),
attachments = event.attachments,
), ),
focusedEventUid = event.uid, focusedEventUid = event.uid,
) )
@@ -541,10 +770,10 @@ class CalendarViewModel(
CalendarViewMode.LIST -> "Предстоящие события" CalendarViewMode.LIST -> "Предстоящие события"
CalendarViewMode.WEEK -> { CalendarViewMode.WEEK -> {
val (start, end) = CalendarRepository.weekRange(st.selectedDay) val (start, end) = CalendarRepository.weekRange(st.selectedDay)
"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}" "${start.format(DateTimeFormatter.ofPattern("d MMM", RU))} ${end.format(DateTimeFormatter.ofPattern("d MMM yyyy", RU))}"
} }
CalendarViewMode.DAY, CalendarViewMode.MONTH -> CalendarViewMode.DAY, CalendarViewMode.MONTH ->
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH)) st.selectedDay.format(DateTimeFormatter.ofPattern("d MMMM yyyy", RU))
} }
} }
@@ -642,6 +871,7 @@ class CalendarViewModel(
companion object { companion object {
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru")) private val RU: Locale = Locale.forLanguageTag("ru")
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", RU)
} }
} }
@@ -0,0 +1,88 @@
package ru.forbion.f7cloud.feature.calendar
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/** Разобранное правило повторения (подмножество RFC 5545 RRULE, как в веб-версии). */
data class RecurrenceRule(
val freq: String = "WEEKLY", // DAILY | WEEKLY | MONTHLY | YEARLY
val interval: Int = 1,
/** Дни недели для WEEKLY: MO..SU (порядок как в ICS). */
val byDays: Set<String> = emptySet(),
val endType: EndType = EndType.NEVER,
val untilDate: LocalDate? = null,
val count: Int = 10,
) {
enum class EndType { NEVER, UNTIL, COUNT }
fun toRrule(): String = buildString {
append("FREQ=$freq")
if (interval > 1) append(";INTERVAL=$interval")
if (freq == "WEEKLY" && byDays.isNotEmpty()) {
append(";BYDAY=${ICS_DAYS.filter { it in byDays }.joinToString(",")}")
}
when (endType) {
EndType.UNTIL -> untilDate?.let { append(";UNTIL=${it.format(UNTIL_FMT)}T235959Z") }
EndType.COUNT -> append(";COUNT=${count.coerceAtLeast(1)}")
EndType.NEVER -> Unit
}
}
companion object {
val ICS_DAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
val DAY_LABELS = mapOf(
"MO" to "Пн", "TU" to "Вт", "WE" to "Ср", "TH" to "Чт",
"FR" to "Пт", "SA" to "Сб", "SU" to "Вс",
)
private val UNTIL_FMT = DateTimeFormatter.BASIC_ISO_DATE
/** Парсинг RRULE-строки; null для пустой/неразбираемой. */
fun parse(rrule: String): RecurrenceRule? {
if (rrule.isBlank()) return null
val parts = rrule.trim().removePrefix("RRULE:").split(';')
.mapNotNull { p ->
val idx = p.indexOf('=')
if (idx <= 0) null else p.substring(0, idx).uppercase() to p.substring(idx + 1)
}.toMap()
val freq = parts["FREQ"]?.uppercase() ?: return null
if (freq !in setOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY")) return null
val until = parts["UNTIL"]?.let { raw ->
runCatching { LocalDate.parse(raw.take(8), UNTIL_FMT) }.getOrNull()
}
val count = parts["COUNT"]?.toIntOrNull()
return RecurrenceRule(
freq = freq,
interval = parts["INTERVAL"]?.toIntOrNull()?.coerceAtLeast(1) ?: 1,
byDays = parts["BYDAY"]?.split(',')?.map { it.trim().takeLast(2).uppercase() }
?.filter { it in ICS_DAYS }?.toSet().orEmpty(),
endType = when {
until != null -> EndType.UNTIL
count != null -> EndType.COUNT
else -> EndType.NEVER
},
untilDate = until,
count = count ?: 10,
)
}
/** Человекочитаемое описание правила по-русски (для пилюли «Повторение»). */
fun describe(rrule: String): String {
val rule = parse(rrule) ?: return "Не повторять"
val freqLabel = when (rule.freq) {
"DAILY" -> if (rule.interval == 1) "Каждый день" else "Каждые ${rule.interval} дн."
"WEEKLY" -> if (rule.interval == 1) "Каждую неделю" else "Каждые ${rule.interval} нед."
"MONTHLY" -> if (rule.interval == 1) "Каждый месяц" else "Каждые ${rule.interval} мес."
else -> if (rule.interval == 1) "Каждый год" else "Каждые ${rule.interval} г."
}
val days = if (rule.freq == "WEEKLY" && rule.byDays.isNotEmpty()) {
" по " + ICS_DAYS.filter { it in rule.byDays }.joinToString(", ") { DAY_LABELS[it] ?: it }
} else ""
val end = when (rule.endType) {
EndType.UNTIL -> rule.untilDate?.let { " до ${it.format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))}" }.orEmpty()
EndType.COUNT -> ", ${rule.count} раз"
EndType.NEVER -> ""
}
return freqLabel + days + end
}
}
}