Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.calendar'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'io.coil-kt:coil-compose:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,163 @@
package ru.forbion.f7cloud.feature.calendar
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.isOcsSuccess
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonObject
data class CalendarAttendeeSuggestion(
val name: String,
val email: String,
val type: String,
)
data class CalendarLocationSuggestion(
val name: String,
val address: String,
)
data class CalendarUserSettings(
val timezone: String = "automatic",
val showWeekends: Boolean = true,
val showWeekNumbers: Boolean = false,
val defaultReminderMinutes: Int = 15,
val showTasks: Boolean = true,
val tasksSidebar: Boolean = true,
)
class CalendarApiClient {
fun searchAttendees(session: AuthSession, query: String): List<CalendarAttendeeSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/attendee"
val body = JSONObject().put("search", query.trim()).toString()
val request = Request.Builder()
.url(url)
.post(body.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete attendee")
val data = json.optJSONArray("data") ?: json.optJSONObject("ocs")?.optJSONArray("data") ?: return emptyList()
return buildList {
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
val emails = item.optJSONArray("emails")
val email = emails?.optString(0).orEmpty()
if (email.isBlank()) continue
add(
CalendarAttendeeSuggestion(
name = item.optString("name", email),
email = email,
type = item.optString("type", "individual"),
),
)
}
}
}
}
fun searchLocations(session: AuthSession, query: String): List<CalendarLocationSuggestion> {
if (query.trim().length < 2) return emptyList()
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/autocompletion/location?search=${java.net.URLEncoder.encode(query.trim(), Charsets.UTF_8.name())}"
val request = Request.Builder()
.url(url)
.get()
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "autocomplete location")
val data = json.optJSONArray("data") ?: return emptyList()
return buildList {
for (i in 0 until data.length()) {
val item = data.optJSONObject(i) ?: continue
add(
CalendarLocationSuggestion(
name = item.optString("name", ""),
address = item.optString("address", item.optString("label", "")),
),
)
}
}
}
}
fun setConfig(session: AuthSession, key: String, value: String) {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/calendar/v1/config/$key"
val body = JSONObject().put("value", value).toString()
val request = Request.Builder()
.url(url)
.post(body.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Calendar config failed HTTP ${response.code}")
}
}
fun createTalkRoom(session: AuthSession, roomName: String): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room?format=json"
val payload = JSONObject()
.put("roomType", 2)
.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 ->
if (response.code == 401) throw UnauthorizedException()
val json = parseJsonObject(response.body?.string().orEmpty(), "create talk room")
val meta = json.ocsMeta()
if (!isOcsSuccess(meta)) error("Не удалось создать комнату Talk")
val token = json.optJSONObject("ocs")?.optJSONObject("data")?.optString("token").orEmpty()
if (token.isBlank()) error("Не удалось создать комнату Talk")
return "${session.serverUrl.trimEnd('/')}/call/$token"
}
}
fun addTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
val token = callUrl.substringAfter("/call/").substringBefore('?').substringBefore('#')
if (token.isBlank()) return
val client = authedClient(session)
emails.forEach { email ->
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/spreed/api/v4/room/$token/participants?format=json"
val payload = JSONObject()
.put("newParticipant", email)
.put("source", "emails")
.toString()
val request = Request.Builder()
.url(url)
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.applyOcsJson()
.build()
runCatching {
client.newCall(request).execute().use { /* best effort */ }
}
}
}
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
@@ -0,0 +1,290 @@
package ru.forbion.f7cloud.feature.calendar
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import ru.forbion.f7cloud.core.designsystem.F7TextButton
import java.time.YearMonth
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarYearView(
year: Int,
selectedDay: java.time.LocalDate,
events: List<CalendarEventItem>,
onSelectMonth: (YearMonth) -> Unit,
modifier: Modifier = Modifier,
) {
val months = CalendarRepository.yearMonths(year)
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
months.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
row.forEach { month ->
val count = events.count { event ->
val day = java.time.Instant.ofEpochMilli(event.startEpochMilli)
.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
day.year == month.year && day.month == month.month
}
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(if (month == YearMonth.from(selectedDay)) F7Colors.PrimaryLight else F7Colors.Surface)
.clickable { onSelectMonth(month) }
.padding(10.dp),
) {
Text(month.month.getDisplayName(java.time.format.TextStyle.FULL_STANDALONE, java.util.Locale.forLanguageTag("ru")), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
Text("$count событ.", style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTrashSheet(
items: List<CalendarTrashItem>,
onDismiss: () -> Unit,
onRestore: (CalendarTrashItem) -> Unit,
onPurge: (CalendarTrashItem) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Корзина", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (items.isEmpty()) Text("Корзина пуста", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))
items.forEach { item ->
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Text(item.summary, fontWeight = FontWeight.SemiBold)
Text(item.calendarUri, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) {
F7TextButton(text = "Восстановить", onClick = { onRestore(item) })
F7TextButton(text = "Удалить навсегда", onClick = { onPurge(item) })
}
}
HorizontalDivider(color = F7Colors.Border)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTasksSheet(
tasks: List<CalendarTaskItem>,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(Modifier.padding(horizontal = 16.dp).padding(bottom = 24.dp)) {
Text("Незапланированные задачи", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (tasks.isEmpty()) Text("Нет задач без срока", color = F7Colors.TextSecondary, modifier = Modifier.padding(top = 12.dp))
tasks.forEach { task ->
Column(Modifier.padding(vertical = 8.dp)) {
Text(task.summary, fontWeight = FontWeight.Medium)
Text(task.calendarName, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextMuted)
}
HorizontalDivider(color = F7Colors.Border)
}
}
}
}
@Composable
fun CalendarCreateBookDialog(
draft: CalendarCreateBookDraft,
saving: Boolean,
onDismiss: () -> Unit,
onDraftChange: (CalendarCreateBookDraft) -> Unit,
onSave: () -> Unit,
) {
AlertDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text("Добавить календарь") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
CreateBookMode.entries.forEach { mode ->
Row(Modifier.fillMaxWidth().clickable { onDraftChange(draft.copy(mode = mode)) }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = draft.mode == mode, onClick = { onDraftChange(draft.copy(mode = mode)) })
Text(
when (mode) {
CreateBookMode.NEW -> "Новый календарь"
CreateBookMode.WITH_TASKS -> "Календарь со списком задач"
CreateBookMode.SUBSCRIBE -> "Подписка по ссылке"
},
modifier = Modifier.padding(start = 4.dp),
)
}
}
F7OutlinedField(value = draft.name, onValueChange = { onDraftChange(draft.copy(name = it)) }, label = "Название")
if (draft.mode == CreateBookMode.SUBSCRIBE) {
F7OutlinedField(value = draft.subscriptionUrl, onValueChange = { onDraftChange(draft.copy(subscriptionUrl = it)) }, label = "URL подписки (WebCal)")
}
}
},
confirmButton = { F7PrimaryButton(text = if (saving) "Сохранение…" else "Создать", onClick = onSave, enabled = !saving) },
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarAppSettingsSheet(
settings: CalendarUserSettings,
onDismiss: () -> Unit,
onChange: (CalendarUserSettings) -> Unit,
onImportClick: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = F7Colors.Surface) {
Column(Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Параметры календаря", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
SettingsSwitch("Показывать выходные", settings.showWeekends) { onChange(settings.copy(showWeekends = it)) }
SettingsSwitch("Номера недель", settings.showWeekNumbers) { onChange(settings.copy(showWeekNumbers = it)) }
SettingsSwitch("Показывать задачи", settings.showTasks) { onChange(settings.copy(showTasks = it)) }
SettingsSwitch("Боковая панель задач", settings.tasksSidebar) { onChange(settings.copy(tasksSidebar = it)) }
F7OutlinedField(
value = settings.defaultReminderMinutes.toString(),
onValueChange = { v -> v.toIntOrNull()?.let { onChange(settings.copy(defaultReminderMinutes = it)) } },
label = "Напоминание по умолчанию (мин)",
)
F7SecondaryButton(text = "Импорт .ics", onClick = onImportClick)
}
}
}
@Composable
private fun SettingsSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(label)
Switch(checked = checked, onCheckedChange = onChecked)
}
}
@Composable
fun CalendarFullEventEditorDialog(
state: CalendarUiState,
onDismiss: () -> Unit,
onDraftChange: (CalendarEventDraft) -> Unit,
onSave: () -> Unit,
onDelete: () -> Unit,
onSearchAttendees: (String) -> Unit,
onAddAttendee: (CalendarAttendeeSuggestion) -> Unit,
onRemoveAttendee: (String) -> Unit,
onSearchLocations: (String) -> Unit,
onApplyLocation: (CalendarLocationSuggestion) -> Unit,
) {
val draft = state.eventDraft
val isEdit = state.editorMode == CalendarEditorMode.EDIT
AlertDialog(
onDismissRequest = { if (!state.saving && !state.deleting) onDismiss() },
title = { Text(if (isEdit) "Редактировать событие" else "Создать событие") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
F7OutlinedField(value = draft.title, onValueChange = { onDraftChange(draft.copy(title = it)) }, label = "Название")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Весь день"); Switch(checked = draft.allDay, onCheckedChange = { onDraftChange(draft.copy(allDay = it)) })
}
if (!draft.allDay) {
F7OutlinedField(value = draft.startTime, onValueChange = { onDraftChange(draft.copy(startTime = it)) }, label = "Начало")
F7OutlinedField(value = draft.endTime, onValueChange = { onDraftChange(draft.copy(endTime = it)) }, label = "Окончание")
}
Text("Повторение", style = MaterialTheme.typography.labelMedium)
RecurrencePreset.entries.forEach { preset ->
Row(Modifier.clickable { onDraftChange(draft.copy(recurrence = preset, customRrule = "")) }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = draft.recurrence == preset, onClick = { onDraftChange(draft.copy(recurrence = preset)) })
Text(preset.label, modifier = Modifier.padding(start = 4.dp))
}
}
F7OutlinedField(value = draft.location, onValueChange = { onSearchLocations(it) }, label = "Место")
state.locationSuggestions.take(4).forEach { s ->
Text(s.address.ifBlank { s.name }, modifier = Modifier.clickable { onApplyLocation(s) }.padding(start = 8.dp), color = F7Colors.Primary)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Комната Talk"); Switch(checked = draft.addTalkRoom, onCheckedChange = { onDraftChange(draft.copy(addTalkRoom = it)) })
}
F7OutlinedField(value = draft.description, onValueChange = { onDraftChange(draft.copy(description = it)) }, label = "Описание")
F7OutlinedField(value = draft.categories, onValueChange = { onDraftChange(draft.copy(categories = it)) }, label = "Категории")
F7OutlinedField(value = draft.reminderMinutes.toString(), onValueChange = { v -> v.toIntOrNull()?.let { onDraftChange(draft.copy(reminderMinutes = it)) } }, label = "Напоминание (мин)")
Text("Статус", style = MaterialTheme.typography.labelMedium)
EventStatus.entries.forEach { st ->
Row(Modifier.clickable { onDraftChange(draft.copy(status = st)) }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = draft.status == st, onClick = { onDraftChange(draft.copy(status = st)) })
Text(st.label, modifier = Modifier.padding(start = 4.dp))
}
}
Text("Видимость", style = MaterialTheme.typography.labelMedium)
EventClassification.entries.forEach { cl ->
Row(Modifier.clickable { onDraftChange(draft.copy(classification = cl)) }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = draft.classification == cl, onClick = { onDraftChange(draft.copy(classification = cl)) })
Text(cl.label, modifier = Modifier.padding(start = 4.dp))
}
}
Text("Участники", style = MaterialTheme.typography.labelMedium)
F7OutlinedField(value = draft.attendeeQuery, onValueChange = onSearchAttendees, label = "Поиск участника")
state.attendeeSuggestions.take(5).forEach { s ->
Text("${s.name} · ${s.email}", modifier = Modifier.clickable { onAddAttendee(s) }.padding(start = 8.dp), color = F7Colors.Primary)
}
draft.attendees.forEach { a ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${a.displayName.ifBlank { a.email }}")
F7TextButton(text = "", onClick = { onRemoveAttendee(a.email) })
}
}
if (state.calendars.isNotEmpty()) {
Text("Календарь", style = MaterialTheme.typography.labelMedium)
state.calendars.filter { it.visible }.forEach { cal ->
Row(Modifier.clickable { onDraftChange(draft.copy(calendarHref = cal.href)) }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = draft.calendarHref == cal.href, onClick = { onDraftChange(draft.copy(calendarHref = cal.href)) })
Text(cal.displayName, modifier = Modifier.padding(start = 4.dp))
}
}
}
}
},
confirmButton = { F7PrimaryButton(text = if (state.saving) "Сохранение…" else "Сохранить", onClick = onSave, enabled = !state.saving) },
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isEdit) TextButton(onClick = onDelete, enabled = !state.deleting) { Text(if (state.deleting) "Удаление…" else "Удалить", color = F7Colors.Error) }
TextButton(onClick = onDismiss) { Text("Отмена") }
}
},
)
}
@@ -0,0 +1,101 @@
package ru.forbion.f7cloud.feature.calendar
import ru.forbion.f7cloud.core.network.CalendarAlarmData
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
enum class CalendarViewMode(val label: String) {
DAY("День"),
WEEK("Неделя"),
MONTH("Месяц"),
YEAR("Год"),
LIST("Список"),
}
enum class CalendarEditorMode {
CREATE,
EDIT,
}
enum class RecurrencePreset(val label: String, val rrule: String) {
NONE("Не повторяется", ""),
DAILY("Каждый день", "FREQ=DAILY"),
WEEKLY("Каждую неделю", "FREQ=WEEKLY"),
MONTHLY("Каждый месяц", "FREQ=MONTHLY"),
YEARLY("Каждый год", "FREQ=YEARLY"),
}
enum class EventStatus(val label: String, val icsValue: String) {
CONFIRMED("Подтверждено", "CONFIRMED"),
TENTATIVE("Предварительно", "TENTATIVE"),
CANCELLED("Отменено", "CANCELLED"),
}
enum class EventClassification(val label: String, val icsValue: String) {
PUBLIC("Публичное", "PUBLIC"),
PRIVATE("Приватное", "PRIVATE"),
CONFIDENTIAL("Конфиденциальное", "CONFIDENTIAL"),
}
data class CalendarBookItem(
val href: String,
val displayName: String,
val visible: Boolean,
val isSubscription: Boolean = false,
val color: String? = null,
)
data class CalendarAttendeeItem(
val email: String,
val displayName: String = "",
val partStat: String = "NEEDS-ACTION",
)
data class CalendarTrashItem(
val uid: String,
val href: String,
val etag: String,
val summary: String,
val deletedAt: String,
val calendarUri: String,
)
data class CalendarTaskItem(
val uid: String,
val summary: String,
val due: String,
val calendarName: String,
)
data class CalendarEventDraft(
val title: String = "",
val date: java.time.LocalDate = java.time.LocalDate.now(),
val startTime: String = "10:00",
val endTime: String = "11:00",
val allDay: Boolean = false,
val description: String = "",
val location: String = "",
val calendarHref: String = "",
val recurrence: RecurrencePreset = RecurrencePreset.NONE,
val customRrule: String = "",
val categories: String = "",
val status: EventStatus = EventStatus.CONFIRMED,
val classification: EventClassification = EventClassification.PUBLIC,
val attendees: List<CalendarAttendeeItem> = emptyList(),
val attendeeQuery: String = "",
val reminderMinutes: Int = 15,
val addTalkRoom: Boolean = false,
val talkRoomUrl: String = "",
)
data class CalendarCreateBookDraft(
val name: String = "",
val withTasks: Boolean = false,
val subscriptionUrl: String = "",
val mode: CreateBookMode = CreateBookMode.NEW,
)
enum class CreateBookMode {
NEW,
WITH_TASKS,
SUBSCRIBE,
}
@@ -0,0 +1,63 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
class CalendarPrefsStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun getViewMode(accountKey: String): CalendarViewMode {
val raw = prefs.getString(keyViewMode(accountKey), CalendarViewMode.MONTH.name)
return runCatching { CalendarViewMode.valueOf(raw ?: CalendarViewMode.MONTH.name) }
.getOrDefault(CalendarViewMode.MONTH)
}
fun setViewMode(accountKey: String, mode: CalendarViewMode) {
prefs.edit().putString(keyViewMode(accountKey), mode.name).apply()
}
fun getVisibleCalendarHrefs(accountKey: String): Set<String> {
val raw = prefs.getStringSet(keyVisibleCalendars(accountKey), null)
return raw ?: emptySet()
}
fun setVisibleCalendarHrefs(accountKey: String, hrefs: Set<String>) {
prefs.edit().putStringSet(keyVisibleCalendars(accountKey), hrefs).apply()
}
fun getCreateCalendarHref(accountKey: String): String? =
prefs.getString(keyCreateCalendar(accountKey), null)?.takeIf { it.isNotBlank() }
fun setCreateCalendarHref(accountKey: String, href: String) {
prefs.edit().putString(keyCreateCalendar(accountKey), href).apply()
}
fun getUserSettings(accountKey: String): CalendarUserSettings = CalendarUserSettings(
timezone = prefs.getString(keySetting(accountKey, "timezone"), "automatic") ?: "automatic",
showWeekends = prefs.getBoolean(keySetting(accountKey, "showWeekends"), true),
showWeekNumbers = prefs.getBoolean(keySetting(accountKey, "showWeekNr"), false),
defaultReminderMinutes = prefs.getInt(keySetting(accountKey, "defaultReminder"), 15),
showTasks = prefs.getBoolean(keySetting(accountKey, "showTasks"), true),
tasksSidebar = prefs.getBoolean(keySetting(accountKey, "tasksSidebar"), true),
)
fun setUserSettings(accountKey: String, settings: CalendarUserSettings) {
prefs.edit()
.putString(keySetting(accountKey, "timezone"), settings.timezone)
.putBoolean(keySetting(accountKey, "showWeekends"), settings.showWeekends)
.putBoolean(keySetting(accountKey, "showWeekNr"), settings.showWeekNumbers)
.putInt(keySetting(accountKey, "defaultReminder"), settings.defaultReminderMinutes)
.putBoolean(keySetting(accountKey, "showTasks"), settings.showTasks)
.putBoolean(keySetting(accountKey, "tasksSidebar"), settings.tasksSidebar)
.apply()
}
private fun keySetting(account: String, key: String) = "setting_${key}_$account"
companion object {
private const val PREFS = "f7_calendar_prefs"
private fun keyViewMode(account: String) = "view_mode::$account"
private fun keyVisibleCalendars(account: String) = "visible_cals::$account"
private fun keyCreateCalendar(account: String) = "create_cal::$account"
}
}
@@ -0,0 +1,446 @@
package ru.forbion.f7cloud.feature.calendar
import okhttp3.OkHttpClient
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.CalendarAlarmData
import ru.forbion.f7cloud.core.network.CalendarAttendeeData
import ru.forbion.f7cloud.core.network.CalendarEventData
import ru.forbion.f7cloud.core.network.CalendarIcs
import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavCalendar
import ru.forbion.f7cloud.core.network.DavEvent
import ru.forbion.f7cloud.core.network.DavTask
import ru.forbion.f7cloud.core.network.DavTrashEvent
import ru.forbion.f7cloud.core.network.NetworkFactory
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.ZoneOffset
class CalendarRepository(
private val apiClient: CalendarApiClient = CalendarApiClient(),
) {
fun listCalendars(session: AuthSession): List<DavCalendar> = openDavContext(session).calendars
fun loadMonth(session: AuthSession, month: YearMonth, visibleHrefs: Set<String>): List<CalendarEventItem> {
return loadRange(session, monthGridStart(month), monthGridEnd(month), visibleHrefs)
}
fun loadRange(
session: AuthSession,
rangeStart: LocalDate,
rangeEnd: LocalDate,
visibleHrefs: Set<String>,
): List<CalendarEventItem> {
val ctx = openDavContext(session)
val startInstant = rangeStart.atStartOfDay(ZoneOffset.UTC).toInstant()
val endInstant = rangeEnd.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
return fetchEvents(ctx, startInstant, endInstant, visibleHrefs)
}
fun loadUnscheduledTasks(session: AuthSession): List<CalendarTaskItem> {
val ctx = openDavContext(session)
val out = mutableListOf<CalendarTaskItem>()
for (cal in ctx.calendars.take(8)) {
runCatching { CalDavClient.queryTasks(ctx.client, cal) }
.onSuccess { tasks ->
out += tasks.filter { !it.isCompleted && it.dueRaw.isBlank() }
.map { it.toTaskItem() }
}
}
return out.distinctBy { it.uid }
}
fun loadTrash(session: AuthSession): List<CalendarTrashItem> {
val ctx = openDavContext(session)
val userBase = CalDavClient.calendarsBase(
session.serverUrl,
session.davUserId ?: OcsUserResolver.resolveDavUserId(session),
)
return CalDavClient.listTrash(ctx.client, userBase).map { it.toTrashItem() }
}
fun restoreTrashItem(session: AuthSession, item: CalendarTrashItem) {
val ctx = openDavContext(session)
val name = item.href.substringAfterLast('/')
CalDavClient.restoreTrashEvent(ctx.client, item.href, name)
}
fun purgeTrashItem(session: AuthSession, item: CalendarTrashItem) {
val ctx = openDavContext(session)
CalDavClient.purgeTrashEvent(ctx.client, item.href, item.etag.ifBlank { "*" })
}
fun createCalendar(session: AuthSession, name: String, withTasks: Boolean): CalendarBookItem {
val ctx = openDavContext(session)
val uri = slugify(name) + "-" + System.currentTimeMillis().toString(36)
val cal = CalDavClient.createCalendar(ctx.client, calendarsRoot(session), uri, name, withTasks)
return CalendarBookItem(cal.href, cal.displayName, visible = true, isSubscription = false)
}
fun subscribeCalendar(session: AuthSession, name: String, sourceUrl: String): CalendarBookItem {
val ctx = openDavContext(session)
val uri = "sub-" + System.currentTimeMillis().toString(36)
val cal = CalDavClient.createSubscription(ctx.client, calendarsRoot(session), uri, name, sourceUrl)
return CalendarBookItem(cal.href, cal.displayName, visible = true, isSubscription = true)
}
fun importIcs(session: AuthSession, calendarHref: String, icsContent: String): Int {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == calendarHref } ?: ctx.defaultCalendar
return CalDavClient.importEvents(ctx.client, calendar, icsContent)
}
fun searchAttendees(session: AuthSession, query: String) = apiClient.searchAttendees(session, query)
fun searchLocations(session: AuthSession, query: String) = apiClient.searchLocations(session, query)
fun saveUserSetting(session: AuthSession, key: String, value: String) = apiClient.setConfig(session, key, value)
fun createTalkRoom(session: AuthSession, roomName: String): String = apiClient.createTalkRoom(session, roomName)
fun syncTalkParticipants(session: AuthSession, callUrl: String, emails: List<String>) {
apiClient.addTalkParticipants(session, callUrl, emails)
}
fun saveEvent(session: AuthSession, draft: CalendarEventDraft, existing: CalendarEventItem? = null): CalendarEventItem {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == draft.calendarHref } ?: ctx.defaultCalendar
var talkUrl = draft.talkRoomUrl
if (draft.addTalkRoom && talkUrl.isBlank()) {
talkUrl = apiClient.createTalkRoom(session, draft.title)
val emails = draft.attendees.map { it.email }.filter { it.isNotBlank() }
if (emails.isNotEmpty()) apiClient.addTalkParticipants(session, talkUrl, emails)
}
val data = draft.toEventData(existing?.uid, talkUrl)
if (existing == null) {
val url = calendar.href.trimEnd('/') + "/${data.uid}.ics"
CalDavClient.putEventIcs(ctx.client, url, null, CalendarIcs.build(data))
return data.toItem(calendar.displayName, calendar.href, url, "")
} else {
CalDavClient.updateEvent(ctx.client, calendar, existing.href, existing.etag, data)
return existing.copy(
summary = data.summary,
startEpochMilli = data.startEpochMilli,
endEpochMilli = data.endEpochMilli,
allDay = data.allDay,
description = data.description,
location = data.location.ifBlank { talkUrl },
rrule = data.rrule,
categories = data.categories,
status = data.status,
classification = data.classification,
attendees = data.attendees.map { it.toItem() },
talkRoomUrl = talkUrl,
alarms = data.alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
)
}
}
fun deleteEvent(session: AuthSession, event: CalendarEventItem) {
val ctx = openDavContext(session)
CalDavClient.deleteEvent(ctx.client, event.href, event.etag.ifBlank { "*" })
}
fun duplicateEvent(session: AuthSession, event: CalendarEventItem, day: LocalDate): CalendarEventItem {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == event.calendarHref } ?: ctx.defaultCalendar
val zone = ZoneId.systemDefault()
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone)
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone)
val duration = java.time.Duration.between(start, end)
val newStart = if (event.allDay) day.atStartOfDay(zone) else day.atTime(start.toLocalTime()).atZone(zone)
val newEnd = if (event.allDay) day.plusDays(1).atStartOfDay(zone) else newStart.plus(duration)
val data = CalendarEventData(
uid = CalendarIcs.newUid(),
summary = event.summary,
description = event.description,
location = event.location,
startEpochMilli = newStart.toInstant().toEpochMilli(),
endEpochMilli = newEnd.toInstant().toEpochMilli(),
allDay = event.allDay,
rrule = event.rrule,
categories = event.categories,
status = event.status,
classification = event.classification,
attendees = event.attendees.map { it.toData() },
alarms = event.alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = event.talkRoomUrl,
)
val url = calendar.href.trimEnd('/') + "/${data.uid}.ics"
CalDavClient.putEventIcs(ctx.client, url, null, CalendarIcs.build(data))
return data.toItem(calendar.displayName, calendar.href, url, "")
}
fun respondToInvite(session: AuthSession, event: CalendarEventItem, partStat: String, userEmail: String) {
val ctx = openDavContext(session)
val calendar = ctx.calendars.firstOrNull { it.href == event.calendarHref } ?: ctx.defaultCalendar
val attendees = event.attendees.map {
if (it.email.equals(userEmail, ignoreCase = true)) it.copy(partStat = partStat) else it
}
val data = event.toEventData().copy(attendees = attendees.map { it.toData() })
CalDavClient.updateEvent(ctx.client, calendar, event.href, event.etag, data)
}
fun openDavContext(session: AuthSession): CalDavContext {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val base = CalDavClient.calendarsBase(session.serverUrl, userId)
val calendars = CalDavClient.listCalendars(client, base)
if (calendars.isEmpty()) throw IllegalStateException("Календари не найдены")
val default = calendars.firstOrNull { cal ->
val name = cal.displayName.lowercase()
name.contains("личн") || name.contains("personal") || name == "calendar"
} ?: calendars.first()
return CalDavContext(client, calendars, default)
}
private fun calendarsRoot(session: AuthSession): String =
CalDavClient.calendarsBase(session.serverUrl, session.davUserId ?: OcsUserResolver.resolveDavUserId(session))
private fun fetchEvents(
ctx: CalDavContext,
rangeStart: Instant,
rangeEnd: Instant,
visibleHrefs: Set<String>,
): List<CalendarEventItem> {
val calendars = if (visibleHrefs.isEmpty()) ctx.calendars else ctx.calendars.filter { it.href in visibleHrefs }
val events = mutableListOf<DavEvent>()
var successCount = 0
var lastError: String? = null
for (cal in calendars.take(12)) {
runCatching { CalDavClient.queryEventsInRange(ctx.client, cal, rangeStart, rangeEnd) }
.onSuccess { successCount++; events += it }
.onFailure { lastError = it.message }
}
if (successCount == 0 && lastError != null) {
throw IllegalStateException("Не удалось загрузить события календаря: $lastError")
}
return events.distinctBy { it.uid }.sortedBy { it.startEpochMilli }.map { it.toItem() }
}
private fun DavEvent.toItem() = CalendarEventItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
startLabel = startLabel,
startEpochMilli = startEpochMilli,
endEpochMilli = endEpochMilli,
calendarName = calendarName,
calendarHref = calendarHref,
allDay = allDay,
description = description,
location = location,
rrule = rrule,
categories = categories,
status = status,
classification = classification,
attendees = attendees.map { it.toItem() },
organizerEmail = organizerEmail,
talkRoomUrl = conferenceUri.ifBlank { if (location.contains("/call/")) location else "" },
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
source = CalendarEventSource.F7CLOUD,
)
private fun DavTrashEvent.toTrashItem() = CalendarTrashItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
deletedAt = deletedAt,
calendarUri = calendarUri,
)
private fun DavTask.toTaskItem() = CalendarTaskItem(
uid = uid,
summary = summary,
due = due,
calendarName = calendarName,
)
private fun slugify(name: String): String =
name.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifBlank { "calendar" }
data class CalDavContext(
val client: OkHttpClient,
val calendars: List<DavCalendar>,
val defaultCalendar: DavCalendar,
)
companion object {
fun monthGridStart(month: YearMonth): LocalDate {
var day = month.atDay(1)
while (day.dayOfWeek.value != 1) day = day.minusDays(1)
return day
}
fun monthGridEnd(month: YearMonth): LocalDate {
var day = month.atEndOfMonth()
while (day.dayOfWeek.value != 7) day = day.plusDays(1)
return day
}
fun monthGridDays(month: YearMonth): List<LocalDate> {
val start = monthGridStart(month)
return (0 until 42).map { start.plusDays(it.toLong()) }
}
fun yearMonths(year: Int): List<YearMonth> = (1..12).map { YearMonth.of(year, it) }
fun weekRange(day: LocalDate): Pair<LocalDate, LocalDate> {
var start = day
while (start.dayOfWeek.value != 1) start = start.minusDays(1)
return start to start.plusDays(6)
}
fun listRange(day: LocalDate): Pair<LocalDate, LocalDate> = day.minusDays(7) to day.plusDays(60)
fun eventOccursOnDay(event: CalendarEventItem, day: LocalDate, zone: ZoneId = ZoneId.systemDefault()): Boolean {
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
return if (event.allDay) !day.isBefore(start) && day.isBefore(end) else day == start
}
fun formatEventTime(event: CalendarEventItem, zone: ZoneId = ZoneId.systemDefault()): String {
if (event.allDay) return "Весь день"
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone)
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone)
val fmt = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
return "${start.format(fmt)} ${end.format(fmt)}"
}
}
}
enum class CalendarEventSource { F7CLOUD, GOOGLE }
data class CalendarReminderItem(val minutesBefore: Int, val action: String = "DISPLAY")
data class CalendarEventItem(
val uid: String = "",
val href: String = "",
val etag: String = "",
val summary: String,
val startLabel: String,
val startEpochMilli: Long,
val endEpochMilli: Long,
val calendarName: String,
val calendarHref: String = "",
val allDay: Boolean = false,
val description: String = "",
val location: String = "",
val rrule: String = "",
val categories: List<String> = emptyList(),
val status: String = "CONFIRMED",
val classification: String = "PUBLIC",
val attendees: List<CalendarAttendeeItem> = emptyList(),
val organizerEmail: String = "",
val talkRoomUrl: String = "",
val alarms: List<CalendarReminderItem> = emptyList(),
val source: CalendarEventSource = CalendarEventSource.F7CLOUD,
)
private fun CalendarEventDraft.toEventData(uid: String? = null, talkUrl: String = ""): CalendarEventData {
val zone = ZoneId.systemDefault()
val start = if (allDay) {
date.atStartOfDay(zone).toInstant().toEpochMilli()
} else {
parseDateTime(date, startTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(10, 0).atZone(zone).toInstant().toEpochMilli()
}
val end = if (allDay) {
date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
} else {
parseDateTime(date, endTime)?.atZone(zone)?.toInstant()?.toEpochMilli()
?: date.atTime(11, 0).atZone(zone).toInstant().toEpochMilli()
}
val rrule = when {
recurrence != RecurrencePreset.NONE -> recurrence.rrule
customRrule.isNotBlank() -> customRrule
else -> ""
}
val cats = categories.split(',').map { it.trim() }.filter { it.isNotBlank() }
val conf = talkUrl.ifBlank { this.talkRoomUrl }
return CalendarEventData(
uid = uid ?: CalendarIcs.newUid(),
summary = title.trim(),
description = description.trim(),
location = location.trim().ifBlank { conf },
startEpochMilli = start,
endEpochMilli = end,
allDay = allDay,
rrule = rrule,
categories = cats,
status = status.icsValue,
classification = classification.icsValue,
attendees = attendees.map {
CalendarAttendeeData(it.email, it.displayName, it.partStat)
},
alarms = if (reminderMinutes > 0) listOf(CalendarAlarmData(reminderMinutes)) else emptyList(),
conferenceUri = conf,
)
}
private fun CalendarEventItem.toEventData(): CalendarEventData = CalendarEventData(
uid = uid,
summary = summary,
description = description,
location = location,
startEpochMilli = startEpochMilli,
endEpochMilli = endEpochMilli,
allDay = allDay,
rrule = rrule,
categories = categories,
status = status,
classification = classification,
attendees = attendees.map { it.toData() },
organizerEmail = organizerEmail,
alarms = alarms.map { CalendarAlarmData(it.minutesBefore, it.action) },
conferenceUri = talkRoomUrl,
)
private fun CalendarEventData.toItem(
calendarName: String,
calendarHref: String,
href: String,
etag: String,
) = CalendarEventItem(
uid = uid,
href = href,
etag = etag,
summary = summary,
startLabel = "",
startEpochMilli = startEpochMilli,
endEpochMilli = endEpochMilli,
calendarName = calendarName,
calendarHref = calendarHref,
allDay = allDay,
description = description,
location = location,
rrule = rrule,
categories = categories,
status = status,
classification = classification,
attendees = attendees.map { it.toItem() },
talkRoomUrl = conferenceUri,
alarms = alarms.map { CalendarReminderItem(it.minutesBefore, it.action) },
)
private fun CalendarAttendeeData.toItem() = CalendarAttendeeItem(email, displayName, partStat)
private fun CalendarAttendeeItem.toData() = CalendarAttendeeData(email, displayName, partStat)
private fun parseDateTime(day: LocalDate, timeText: String): LocalDateTime? {
val parts = timeText.trim().split(':')
if (parts.size != 2) return null
val hour = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
if (hour !in 0..23 || minute !in 0..59) return null
return day.atTime(hour, minute)
}
@@ -0,0 +1,471 @@
package ru.forbion.f7cloud.feature.calendar
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarScreen(
session: AuthSession,
modifier: Modifier = Modifier,
focusEventUid: String? = null,
settingsRequest: Int = 0,
sidebarOpen: Boolean = false,
onSidebarOpenChange: (Boolean) -> Unit = {},
onFocusEventConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val vm: CalendarViewModel = viewModel(factory = CalendarViewModel.Factory(context))
val state by vm.state.collectAsState()
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
vm.onCalendarPermissionResult(result.values.all { it })
}
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
}.onSuccess { content ->
if (!content.isNullOrBlank()) vm.importIcs(content)
}.onFailure {
vm.updateEventDraft { it } // no-op; snack via error path if needed
}
}
fun hasCalendarPermission(): Boolean {
val read = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
val write = ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED
return read && write
}
fun requestCalendarPermissions() {
permissionLauncher.launch(
arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR),
)
}
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
LaunchedEffect(sidebarOpen) {
if (sidebarOpen && drawerState.isClosed) drawerState.open()
if (!sidebarOpen && drawerState.isOpen) drawerState.close()
}
LaunchedEffect(drawerState) {
snapshotFlow { drawerState.isOpen }.distinctUntilChanged().collect { open ->
if (open != sidebarOpen) onSidebarOpenChange(open)
}
}
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
vm.bind(session)
}
LaunchedEffect(focusEventUid) {
val uid = focusEventUid ?: return@LaunchedEffect
vm.focusEventByUid(session, uid)
onFocusEventConsumed()
}
LaunchedEffect(settingsRequest) {
if (settingsRequest > 0) vm.openSyncSettings()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler(
enabled = state.eventDetail != null,
onDismiss = vm::closeEventDetail,
)
F7OverlayDismissHandler(
enabled = state.createDialogOpen,
onDismiss = vm::closeCreateDialog,
)
F7OverlayDismissHandler(
enabled = state.settingsOpen,
onDismiss = vm::closeAppSettings,
)
F7OverlayDismissHandler(
enabled = state.syncSettingsOpen,
onDismiss = vm::closeSyncSettings,
)
F7OverlayDismissHandler(
enabled = state.trashOpen,
onDismiss = vm::closeTrash,
)
F7OverlayDismissHandler(
enabled = state.tasksOpen,
onDismiss = vm::closeTasks,
)
F7OverlayDismissHandler(
enabled = state.createBookOpen,
onDismiss = vm::closeCreateBook,
)
F7OverlayDismissHandler(
enabled = state.datePickerOpen,
onDismiss = vm::closeDatePicker,
)
if (state.syncSettingsOpen) {
CalendarSyncSettingsDialog(
state = state,
onDismiss = vm::closeSyncSettings,
onSyncEnabledChange = { enabled ->
if (enabled && !hasCalendarPermission()) requestCalendarPermissions()
vm.setSyncEnabled(enabled)
},
onSelectCalendar = vm::selectDeviceCalendar,
onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
},
)
}
if (state.settingsOpen) {
CalendarAppSettingsSheet(
settings = state.userSettings,
onDismiss = vm::closeAppSettings,
onChange = vm::updateUserSettings,
onImportClick = { importLauncher.launch("*/*") },
)
}
if (state.trashOpen) {
CalendarTrashSheet(
items = state.trashItems,
onDismiss = vm::closeTrash,
onRestore = vm::restoreTrash,
onPurge = vm::purgeTrash,
)
}
if (state.tasksOpen) {
CalendarTasksSheet(
tasks = state.unscheduledTasks,
onDismiss = vm::closeTasks,
)
}
if (state.createBookOpen) {
CalendarCreateBookDialog(
draft = state.createBookDraft,
saving = state.saving,
onDismiss = vm::closeCreateBook,
onDraftChange = vm::updateCreateBookDraft,
onSave = vm::saveCreateBook,
)
}
if (state.datePickerOpen) {
CalendarDatePickerDialog(
selectedDay = state.selectedDay,
onDismiss = vm::closeDatePicker,
onConfirm = vm::pickDate,
)
}
if (state.createDialogOpen) {
CalendarFullEventEditorDialog(
state = state,
onDismiss = vm::closeCreateDialog,
onDraftChange = { draft -> vm.updateEventDraft { draft } },
onSave = vm::saveEventDraft,
onDelete = vm::deleteSelectedEvent,
onSearchAttendees = vm::searchAttendees,
onAddAttendee = vm::addAttendee,
onRemoveAttendee = vm::removeAttendee,
onSearchLocations = vm::searchLocations,
onApplyLocation = vm::applyLocationSuggestion,
)
}
state.eventDetail?.let { event ->
CalendarEventDetailSheet(
session = session,
event = event,
onDismiss = vm::closeEventDetail,
onEdit = { vm.openEditEvent(event) },
onDelete = vm::deleteSelectedEvent,
onDuplicate = { vm.duplicateEvent(event) },
onAccept = { vm.respondToInvite("ACCEPTED") },
onDecline = { vm.respondToInvite("DECLINED") },
onTentative = { vm.respondToInvite("TENTATIVE") },
deleting = state.deleting,
)
}
val visibleEvents = vm.visibleEvents()
val eventsByDay = rememberEventsByDay(visibleEvents)
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = true,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier.fillMaxWidth(0.86f),
drawerContainerColor = F7Colors.Surface,
) {
CalendarNavigationSidebar(
session = session,
state = state,
periodTitle = vm.periodTitle(),
onPrevious = vm::previousPeriod,
onNext = vm::nextPeriod,
onPeriodClick = vm::openDatePicker,
onCreate = { vm.openCreateDialog() },
onToday = vm::goToToday,
onViewMode = vm::setViewMode,
onDismiss = {
scope.launch { drawerState.close() }
onSidebarOpenChange(false)
},
onToggleCalendar = vm::toggleCalendarVisibility,
onOpenSettings = {
onSidebarOpenChange(false)
vm.openSyncSettings()
},
onOpenAppSettings = {
onSidebarOpenChange(false)
vm.openAppSettings()
},
onOpenTrash = {
onSidebarOpenChange(false)
vm.openTrash()
},
onOpenTasks = {
onSidebarOpenChange(false)
vm.openTasks()
},
onCreateBook = {
onSidebarOpenChange(false)
vm.openCreateBook()
},
onSyncNow = {
if (!hasCalendarPermission()) requestCalendarPermissions() else vm.runSyncNow()
},
)
}
},
) {
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.events.isEmpty(),
error = state.error,
) {
if (state.syncEnabled) {
Text(
buildString {
append("Синхронизация с телефоном")
state.lastSyncLabel?.let { append(" · $it") }
if (state.syncing) append(" · идёт…")
},
style = MaterialTheme.typography.labelSmall,
color = F7Colors.PrimaryDark,
)
}
if (state.needsCalendarPermission && state.syncEnabled) {
Text(
"Нужен доступ к календарю телефона",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.Error,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.padding(vertical = 4.dp),
)
}
if (!state.snackMessage.isNullOrBlank()) {
Text(
state.snackMessage!!,
color = F7Colors.PrimaryDark,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.padding(8.dp),
)
LaunchedEffect(state.snackMessage) {
delay(2500)
vm.clearSnack()
}
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
when (state.viewMode) {
CalendarViewMode.MONTH -> {
CalendarMonthGrid(
month = state.visibleMonth,
selectedDay = state.selectedDay,
daysWithEvents = vm.daysWithEvents(),
eventsByDay = eventsByDay,
onSelectDay = vm::selectDay,
)
SelectedDayEvents(
day = state.selectedDay,
events = vm.eventsForDay(state.selectedDay),
focusedEventUid = state.focusedEventUid,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.WEEK -> {
CalendarWeekView(
selectedDay = state.selectedDay,
events = visibleEvents,
onSelectDay = vm::selectDay,
onEventClick = vm::openEventDetail,
)
SelectedDayEvents(
day = state.selectedDay,
events = vm.eventsForDay(state.selectedDay),
focusedEventUid = state.focusedEventUid,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.DAY -> {
CalendarDayTimeline(
session = session,
day = state.selectedDay,
events = vm.eventsForDay(state.selectedDay),
viewMode = state.viewMode,
onViewMode = vm::setViewMode,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.YEAR -> {
CalendarYearView(
year = state.visibleYear,
selectedDay = state.selectedDay,
events = visibleEvents,
onSelectMonth = vm::selectYearMonth,
modifier = Modifier.weight(1f),
)
}
CalendarViewMode.LIST -> {
if (!state.loading && visibleEvents.isEmpty()) {
Text("Нет предстоящих событий", color = F7Colors.TextSecondary)
} else {
CalendarListView(
events = visibleEvents,
onEventClick = vm::openEventDetail,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
}
@Composable
private fun SelectedDayEvents(
day: java.time.LocalDate,
events: List<CalendarEventItem>,
focusedEventUid: String?,
onEventClick: (CalendarEventItem) -> Unit,
modifier: Modifier = Modifier,
) {
val formatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.forLanguageTag("ru"))
Column(modifier = modifier.fillMaxWidth()) {
Text(
day.format(formatter).replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.forLanguageTag("ru")) else it.toString()
},
style = MaterialTheme.typography.titleSmall,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(top = 4.dp),
)
if (events.isEmpty()) {
Text("Нет событий в этот день", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
} else {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events, key = { "${it.uid}|${it.startEpochMilli}" }) { event ->
val focused = focusedEventUid?.let { needle ->
event.uid == needle || event.uid.endsWith(needle) || needle.endsWith(event.uid)
} == true
CalendarEventRow(
event = event,
highlighted = focused,
onClick = { onEventClick(event) },
)
}
}
}
}
}
@Composable
private fun rememberEventsByDay(events: List<CalendarEventItem>): Map<java.time.LocalDate, List<CalendarEventItem>> {
return androidx.compose.runtime.remember(events) {
val map = mutableMapOf<java.time.LocalDate, MutableList<CalendarEventItem>>()
events.forEach { event ->
val zone = java.time.ZoneId.systemDefault()
val start = java.time.Instant.ofEpochMilli(event.startEpochMilli).atZone(zone).toLocalDate()
val end = java.time.Instant.ofEpochMilli(event.endEpochMilli).atZone(zone).toLocalDate()
var day = start
while (!day.isAfter(end)) {
if (CalendarRepository.eventOccursOnDay(event, day)) {
map.getOrPut(day) { mutableListOf() }.add(event)
}
day = day.plusDays(1)
if (!event.allDay) break
}
}
map
}
}
@@ -0,0 +1,212 @@
package ru.forbion.f7cloud.feature.calendar
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.CalDavClient
import ru.forbion.f7cloud.core.network.DavEvent
import java.time.Instant
data class CalendarSyncResult(
val pushedToDevice: Int = 0,
val pushedToServer: Int = 0,
val updated: Int = 0,
val deleted: Int = 0,
val linked: Int = 0,
)
class CalendarSyncEngine(
private val repository: CalendarRepository,
private val deviceClient: DeviceCalendarClient,
private val store: CalendarSyncStore,
) {
fun run(
session: AuthSession,
deviceCalendarId: Long,
): CalendarSyncResult {
val accountKey = store.accountKey(session.serverUrl, session.username)
val ctx = repository.openDavContext(session)
val rangeStart = Instant.now().minusSeconds(RANGE_PAST_SEC)
val rangeEnd = Instant.now().plusSeconds(RANGE_FUTURE_SEC)
val ncEvents = CalDavClient.queryEventsInRange(
ctx.client,
ctx.defaultCalendar,
rangeStart,
rangeEnd,
)
val deviceEvents = deviceClient.queryEvents(
deviceCalendarId,
rangeStart.toEpochMilli(),
rangeEnd.toEpochMilli(),
)
var mappings = store.loadMappings(accountKey)
.filter { it.deviceCalendarId == deviceCalendarId }
.toMutableList()
var pushedToDevice = 0
var pushedToServer = 0
var updated = 0
var deleted = 0
var linked = 0
val ncByUid = ncEvents.associateBy { it.uid }
val devById = deviceEvents.associateBy { it.eventId }
// Update linked pairs (last-write-wins)
val mappingIterator = mappings.iterator()
while (mappingIterator.hasNext()) {
val map = mappingIterator.next()
val nc = ncByUid[map.ncUid]
val dev = devById[map.deviceEventId]
when {
nc == null && dev == null -> {
mappingIterator.remove()
deleted++
}
nc == null && dev != null -> {
runCatching { deviceClient.deleteEvent(dev.eventId) }
mappingIterator.remove()
deleted++
}
nc != null && dev == null -> {
runCatching {
CalDavClient.deleteEvent(ctx.client, map.ncHref, map.ncEtag.ifBlank { "*" })
}
mappingIterator.remove()
deleted++
}
nc != null && dev != null -> {
when {
nc.lastModifiedEpochMilli > dev.lastModifiedEpochMilli + SYNC_SKEW_MS -> {
deviceClient.updateEvent(
eventId = dev.eventId,
title = nc.summary,
description = dev.description,
startEpochMilli = nc.startEpochMilli,
endEpochMilli = nc.endEpochMilli,
allDay = nc.allDay,
ncUid = nc.uid,
)
updated++
}
dev.lastModifiedEpochMilli > nc.lastModifiedEpochMilli + SYNC_SKEW_MS -> {
CalDavClient.updateEvent(
client = ctx.client,
calendar = ctx.defaultCalendar,
href = nc.href,
etag = nc.etag.ifBlank { map.ncEtag }.ifBlank { "*" },
uid = nc.uid,
summary = dev.title.ifBlank { nc.summary },
startEpochMilli = dev.startEpochMilli,
endEpochMilli = dev.endEpochMilli,
allDay = dev.allDay,
)
updated++
}
}
val refreshed = mappings.indexOfFirst { it.ncUid == map.ncUid }
if (refreshed >= 0) {
mappings[refreshed] = map.copy(
ncHref = nc.href,
ncEtag = nc.etag,
)
}
}
}
}
val mappedNcUids = mappings.map { it.ncUid }.toSet()
val mappedDevIds = mappings.map { it.deviceEventId }.toSet()
// Fuzzy link: same title + close start
for (nc in ncEvents) {
if (nc.uid in mappedNcUids) continue
val match = deviceEvents.firstOrNull { dev ->
dev.eventId !in mappedDevIds &&
titlesMatch(nc.summary, dev.title) &&
kotlin.math.abs(nc.startEpochMilli - dev.startEpochMilli) < FUZZY_START_MS
} ?: continue
mappings += CalendarSyncMapping(
ncUid = nc.uid,
ncHref = nc.href,
ncEtag = nc.etag,
deviceEventId = match.eventId,
deviceCalendarId = deviceCalendarId,
)
deviceClient.stampNcUid(match.eventId, nc.uid)
linked++
}
val mappedNcUids2 = mappings.map { it.ncUid }.toSet()
val mappedDevIds2 = mappings.map { it.deviceEventId }.toSet()
// NC → device
for (nc in ncEvents) {
if (nc.uid in mappedNcUids2) continue
val eventId = deviceClient.insertEvent(
calendarId = deviceCalendarId,
title = nc.summary,
description = "",
startEpochMilli = nc.startEpochMilli,
endEpochMilli = nc.endEpochMilli,
allDay = nc.allDay,
ncUid = nc.uid,
)
mappings += CalendarSyncMapping(
ncUid = nc.uid,
ncHref = nc.href,
ncEtag = nc.etag,
deviceEventId = eventId,
deviceCalendarId = deviceCalendarId,
)
pushedToDevice++
}
val mappedDevIds3 = mappings.map { it.deviceEventId }.toSet()
// device → NC
for (dev in deviceEvents) {
if (dev.eventId in mappedDevIds3) continue
val uid = dev.ncUid ?: "${java.util.UUID.randomUUID()}@f7cloud.mobile"
val start = java.time.LocalDateTime.ofInstant(
Instant.ofEpochMilli(dev.startEpochMilli),
java.time.ZoneId.systemDefault(),
)
val durationMin = ((dev.endEpochMilli - dev.startEpochMilli) / 60_000).coerceAtLeast(15)
CalDavClient.createEvent(
ctx.client,
ctx.defaultCalendar,
dev.title.ifBlank { "(без названия)" },
start,
durationMin,
)
val href = ctx.defaultCalendar.href.trimEnd('/') + "/$uid.ics"
mappings += CalendarSyncMapping(
ncUid = uid,
ncHref = href,
ncEtag = "",
deviceEventId = dev.eventId,
deviceCalendarId = deviceCalendarId,
)
deviceClient.stampNcUid(dev.eventId, uid)
pushedToServer++
}
store.saveMappings(accountKey, mappings)
store.setLastSyncAt(accountKey, System.currentTimeMillis())
return CalendarSyncResult(
pushedToDevice = pushedToDevice,
pushedToServer = pushedToServer,
updated = updated,
deleted = deleted,
linked = linked,
)
}
private fun titlesMatch(a: String, b: String): Boolean =
a.trim().equals(b.trim(), ignoreCase = true)
companion object {
private const val RANGE_PAST_SEC = 60L * 60 * 24 * 60 // 60 days
private const val RANGE_FUTURE_SEC = 60L * 60 * 24 * 365 // 1 year
private const val FUZZY_START_MS = 120_000L
private const val SYNC_SKEW_MS = 2_000L
}
}
@@ -0,0 +1,86 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
data class CalendarSyncMapping(
val ncUid: String,
val ncHref: String,
val ncEtag: String,
val deviceEventId: Long,
val deviceCalendarId: Long,
)
class CalendarSyncStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun isEnabled(accountKey: String): Boolean =
prefs.getBoolean(keyEnabled(accountKey), false)
fun setEnabled(accountKey: String, enabled: Boolean) {
prefs.edit().putBoolean(keyEnabled(accountKey), enabled).apply()
}
fun getDeviceCalendarId(accountKey: String): Long =
prefs.getLong(keyDeviceCal(accountKey), -1L)
fun setDeviceCalendarId(accountKey: String, calendarId: Long) {
prefs.edit().putLong(keyDeviceCal(accountKey), calendarId).apply()
}
fun getLastSyncAt(accountKey: String): Long =
prefs.getLong(keyLastSync(accountKey), 0L)
fun setLastSyncAt(accountKey: String, epochMilli: Long) {
prefs.edit().putLong(keyLastSync(accountKey), epochMilli).apply()
}
fun loadMappings(accountKey: String): List<CalendarSyncMapping> {
val raw = prefs.getString(keyMappings(accountKey), null) ?: return emptyList()
return runCatching {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
add(
CalendarSyncMapping(
ncUid = o.getString("ncUid"),
ncHref = o.getString("ncHref"),
ncEtag = o.optString("ncEtag", ""),
deviceEventId = o.getLong("deviceEventId"),
deviceCalendarId = o.getLong("deviceCalendarId"),
),
)
}
}
}.getOrDefault(emptyList())
}
fun saveMappings(accountKey: String, mappings: List<CalendarSyncMapping>) {
val arr = JSONArray()
mappings.forEach { m ->
arr.put(
JSONObject()
.put("ncUid", m.ncUid)
.put("ncHref", m.ncHref)
.put("ncEtag", m.ncEtag)
.put("deviceEventId", m.deviceEventId)
.put("deviceCalendarId", m.deviceCalendarId),
)
}
prefs.edit().putString(keyMappings(accountKey), arr.toString()).apply()
}
fun accountKey(serverUrl: String, username: String): String =
"${serverUrl.trimEnd('/')}|$username"
companion object {
private const val PREFS = "f7_calendar_sync"
private fun keyEnabled(account: String) = "enabled::$account"
private fun keyDeviceCal(account: String) = "device_cal::$account"
private fun keyMappings(account: String) = "mappings::$account"
private fun keyLastSync(account: String) = "last_sync::$account"
}
}
@@ -0,0 +1,645 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
import java.time.Instant
import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
data class CalendarUiState(
val loading: Boolean = false,
val viewMode: CalendarViewMode = CalendarViewMode.MONTH,
val visibleMonth: YearMonth = YearMonth.now(),
val visibleYear: Int = LocalDate.now().year,
val selectedDay: LocalDate = LocalDate.now(),
val events: List<CalendarEventItem> = emptyList(),
val calendars: List<CalendarBookItem> = emptyList(),
val datePickerOpen: Boolean = false,
val createDialogOpen: Boolean = false,
val syncSettingsOpen: Boolean = false,
val settingsOpen: Boolean = false,
val trashOpen: Boolean = false,
val createBookOpen: Boolean = false,
val tasksOpen: Boolean = false,
val eventDetail: CalendarEventItem? = null,
val editorMode: CalendarEditorMode = CalendarEditorMode.CREATE,
val eventDraft: CalendarEventDraft = CalendarEventDraft(),
val createBookDraft: CalendarCreateBookDraft = CalendarCreateBookDraft(),
val trashItems: List<CalendarTrashItem> = emptyList(),
val unscheduledTasks: List<CalendarTaskItem> = emptyList(),
val attendeeSuggestions: List<CalendarAttendeeSuggestion> = emptyList(),
val locationSuggestions: List<CalendarLocationSuggestion> = emptyList(),
val userSettings: CalendarUserSettings = CalendarUserSettings(),
val saving: Boolean = false,
val deleting: Boolean = false,
val syncing: Boolean = false,
val syncEnabled: Boolean = false,
val deviceCalendars: List<DeviceCalendarInfo> = emptyList(),
val selectedDeviceCalendarId: Long = -1L,
val lastSyncLabel: String? = null,
val needsCalendarPermission: Boolean = false,
val error: String? = null,
val snackMessage: String? = null,
val focusedEventUid: String? = null,
val unauthorized: Boolean = false,
)
class CalendarViewModel(
context: Context,
private val repository: CalendarRepository = CalendarRepository(),
) : ViewModel() {
private val appContext = context.applicationContext
private val deviceClient = DeviceCalendarClient(appContext)
private val syncStore = CalendarSyncStore(appContext)
private val prefsStore = CalendarPrefsStore(appContext)
private val syncEngine = CalendarSyncEngine(repository, deviceClient, syncStore)
private val _state = MutableStateFlow(CalendarUiState())
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
private var session: AuthSession? = null
private var visibleCalendarHrefs: Set<String> = emptySet()
private var periodicRefreshJob: Job? = null
fun bind(session: AuthSession) {
this.session = session
periodicRefreshJob?.cancel()
val accountKey = syncStore.accountKey(session.serverUrl, session.username)
visibleCalendarHrefs = prefsStore.getVisibleCalendarHrefs(accountKey)
_state.value = _state.value.copy(
viewMode = prefsStore.getViewMode(accountKey),
userSettings = prefsStore.getUserSettings(accountKey),
syncEnabled = syncStore.isEnabled(accountKey),
selectedDeviceCalendarId = syncStore.getDeviceCalendarId(accountKey),
lastSyncLabel = formatLastSync(syncStore.getLastSyncAt(accountKey)),
)
refreshDeviceCalendars()
loadCalendars(session)
if (syncStore.isEnabled(accountKey) && syncStore.getDeviceCalendarId(accountKey) > 0) {
runSyncInternal(session, syncStore.getDeviceCalendarId(accountKey), false)
} else {
reloadEvents(session)
}
startPeriodicRefresh()
}
fun refresh() {
val s = session ?: return
val calId = _state.value.selectedDeviceCalendarId
if (_state.value.syncEnabled && calId > 0) runSyncInternal(s, calId, true) else reloadEvents(s)
}
private fun refreshSilent() {
val s = session ?: return
if (!AppForegroundTracker.isForeground) return
reloadEvents(s, showLoading = false)
}
private fun startPeriodicRefresh() {
periodicRefreshJob = viewModelScope.launch {
while (isActive) {
delay(REFRESH_INTERVAL_MS)
if (AppForegroundTracker.isForeground) {
refreshSilent()
}
}
}
}
override fun onCleared() {
periodicRefreshJob?.cancel()
super.onCleared()
}
fun onCalendarPermissionResult(granted: Boolean) {
_state.value = _state.value.copy(needsCalendarPermission = !granted)
if (granted) {
refreshDeviceCalendars()
session?.let { s ->
if (_state.value.syncEnabled && _state.value.selectedDeviceCalendarId > 0) {
runSyncInternal(s, _state.value.selectedDeviceCalendarId, true)
}
}
}
}
fun refreshDeviceCalendars() {
runCatching { deviceClient.listWritableCalendars() }
.onSuccess { _state.value = _state.value.copy(deviceCalendars = it) }
}
fun setViewMode(mode: CalendarViewMode) {
val s = session ?: return
prefsStore.setViewMode(syncStore.accountKey(s.serverUrl, s.username), mode)
_state.value = _state.value.copy(viewMode = mode)
reloadEvents(s)
}
fun goToToday() {
val s = session ?: return
val today = LocalDate.now()
_state.value = _state.value.copy(selectedDay = today, visibleMonth = YearMonth.from(today), visibleYear = today.year)
reloadEvents(s)
}
fun previousPeriod() {
val s = session ?: return
val st = _state.value
_state.value = when (st.viewMode) {
CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.minusDays(1), visibleMonth = YearMonth.from(st.selectedDay.minusDays(1)))
CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.minusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.minusWeeks(1)))
CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.minusMonths(1), selectedDay = st.visibleMonth.minusMonths(1).atDay(1))
CalendarViewMode.YEAR -> st.copy(visibleYear = st.visibleYear - 1)
CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.minusDays(14), visibleMonth = YearMonth.from(st.selectedDay.minusDays(14)))
}
reloadEvents(s)
}
fun nextPeriod() {
val s = session ?: return
val st = _state.value
_state.value = when (st.viewMode) {
CalendarViewMode.DAY -> st.copy(selectedDay = st.selectedDay.plusDays(1), visibleMonth = YearMonth.from(st.selectedDay.plusDays(1)))
CalendarViewMode.WEEK -> st.copy(selectedDay = st.selectedDay.plusWeeks(1), visibleMonth = YearMonth.from(st.selectedDay.plusWeeks(1)))
CalendarViewMode.MONTH -> st.copy(visibleMonth = st.visibleMonth.plusMonths(1), selectedDay = st.visibleMonth.plusMonths(1).atDay(1))
CalendarViewMode.YEAR -> st.copy(visibleYear = st.visibleYear + 1)
CalendarViewMode.LIST -> st.copy(selectedDay = st.selectedDay.plusDays(14), visibleMonth = YearMonth.from(st.selectedDay.plusDays(14)))
}
reloadEvents(s)
}
fun openDatePicker() { _state.value = _state.value.copy(datePickerOpen = true) }
fun closeDatePicker() { _state.value = _state.value.copy(datePickerOpen = false) }
fun pickDate(day: LocalDate) {
val s = session ?: return
_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year, datePickerOpen = false)
reloadEvents(s)
}
fun selectDay(day: LocalDate) {
_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), visibleYear = day.year)
if (_state.value.viewMode != CalendarViewMode.MONTH && _state.value.viewMode != CalendarViewMode.YEAR) {
session?.let { reloadEvents(it) }
}
}
fun selectYearMonth(month: YearMonth) {
val s = session ?: return
_state.value = _state.value.copy(viewMode = CalendarViewMode.MONTH, visibleMonth = month, selectedDay = month.atDay(1))
reloadEvents(s)
}
fun openTrash() {
val s = session ?: return
_state.value = _state.value.copy(trashOpen = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadTrash(s) }
.onSuccess { _state.value = _state.value.copy(trashItems = it) }
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun closeTrash() { _state.value = _state.value.copy(trashOpen = false) }
fun restoreTrash(item: CalendarTrashItem) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.restoreTrashItem(s, item) }
.onSuccess {
openTrash()
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Событие восстановлено")
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun purgeTrash(item: CalendarTrashItem) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.purgeTrashItem(s, item) }
.onSuccess { openTrash() }
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun openTasks() {
val s = session ?: return
_state.value = _state.value.copy(tasksOpen = true)
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadUnscheduledTasks(s) }
.onSuccess { _state.value = _state.value.copy(unscheduledTasks = it) }
}
}
fun closeTasks() { _state.value = _state.value.copy(tasksOpen = false) }
fun openCreateBook() { _state.value = _state.value.copy(createBookOpen = true) }
fun closeCreateBook() { _state.value = _state.value.copy(createBookOpen = false, createBookDraft = CalendarCreateBookDraft()) }
fun updateCreateBookDraft(draft: CalendarCreateBookDraft) { _state.value = _state.value.copy(createBookDraft = draft) }
fun saveCreateBook() {
val s = session ?: return
val draft = _state.value.createBookDraft
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(saving = true)
runCatching {
when (draft.mode) {
CreateBookMode.SUBSCRIBE -> repository.subscribeCalendar(s, draft.name, draft.subscriptionUrl)
CreateBookMode.WITH_TASKS -> repository.createCalendar(s, draft.name, withTasks = true)
CreateBookMode.NEW -> repository.createCalendar(s, draft.name, withTasks = false)
}
}.onSuccess {
loadCalendars(s)
_state.value = _state.value.copy(saving = false, createBookOpen = false, snackMessage = "Календарь добавлен")
reloadEvents(s)
}.onFailure { t ->
_state.value = _state.value.copy(saving = false, error = t.message, unauthorized = t is UnauthorizedException)
}
}
}
fun importIcs(content: String) {
val s = session ?: return
val href = _state.value.calendars.firstOrNull { it.visible }?.href
?: _state.value.calendars.firstOrNull()?.href ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.importIcs(s, href, content) }
.onSuccess { count ->
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Импортировано событий: $count")
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun toggleCalendarVisibility(href: String) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
val updated = _state.value.calendars.map { if (it.href == href) it.copy(visible = !it.visible) else it }
visibleCalendarHrefs = updated.filter { it.visible }.map { it.href }.toSet()
prefsStore.setVisibleCalendarHrefs(accountKey, visibleCalendarHrefs)
_state.value = _state.value.copy(calendars = updated)
reloadEvents(s)
}
fun openSyncSettings() { refreshDeviceCalendars(); _state.value = _state.value.copy(syncSettingsOpen = true) }
fun closeSyncSettings() { _state.value = _state.value.copy(syncSettingsOpen = false) }
fun openAppSettings() { _state.value = _state.value.copy(settingsOpen = true) }
fun closeAppSettings() { _state.value = _state.value.copy(settingsOpen = false) }
fun updateUserSettings(settings: CalendarUserSettings) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
prefsStore.setUserSettings(accountKey, settings)
_state.value = _state.value.copy(userSettings = settings)
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.saveUserSetting(s, "timezone", settings.timezone)
repository.saveUserSetting(s, "showWeekends", if (settings.showWeekends) "yes" else "no")
repository.saveUserSetting(s, "showWeekNr", if (settings.showWeekNumbers) "yes" else "no")
repository.saveUserSetting(s, "defaultReminder", settings.defaultReminderMinutes.toString())
repository.saveUserSetting(s, "showTasks", if (settings.showTasks) "yes" else "no")
repository.saveUserSetting(s, "tasksSidebar", if (settings.tasksSidebar) "yes" else "no")
}
}
}
fun setSyncEnabled(enabled: Boolean) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
syncStore.setEnabled(accountKey, enabled)
_state.value = _state.value.copy(syncEnabled = enabled, needsCalendarPermission = enabled)
if (enabled && _state.value.selectedDeviceCalendarId <= 0 && _state.value.deviceCalendars.isNotEmpty()) {
selectDeviceCalendar(_state.value.deviceCalendars.first().id)
} else if (!enabled) reloadEvents(s)
}
fun selectDeviceCalendar(calendarId: Long) {
val s = session ?: return
syncStore.setDeviceCalendarId(syncStore.accountKey(s.serverUrl, s.username), calendarId)
_state.value = _state.value.copy(selectedDeviceCalendarId = calendarId)
if (_state.value.syncEnabled) runSyncInternal(s, calendarId, true)
}
fun runSyncNow() {
val s = session ?: return
val calId = _state.value.selectedDeviceCalendarId
if (calId <= 0) { _state.value = _state.value.copy(error = "Выберите календарь телефона"); return }
runSyncInternal(s, calId, true)
}
fun openCreateDialog(day: LocalDate? = null) {
val s = session ?: return
val accountKey = syncStore.accountKey(s.serverUrl, s.username)
val targetDay = day ?: _state.value.selectedDay
val createHref = prefsStore.getCreateCalendarHref(accountKey)
?: _state.value.calendars.firstOrNull { it.visible }?.href.orEmpty()
_state.value = _state.value.copy(
createDialogOpen = true,
editorMode = CalendarEditorMode.CREATE,
eventDetail = null,
eventDraft = CalendarEventDraft(
date = targetDay,
calendarHref = createHref,
reminderMinutes = _state.value.userSettings.defaultReminderMinutes,
),
)
}
fun closeCreateDialog() { _state.value = _state.value.copy(createDialogOpen = false) }
fun updateEventDraft(transform: (CalendarEventDraft) -> CalendarEventDraft) {
_state.value = _state.value.copy(eventDraft = transform(_state.value.eventDraft))
}
fun searchAttendees(query: String) {
val s = session ?: return
updateEventDraft { it.copy(attendeeQuery = query) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.searchAttendees(s, query) }
.onSuccess { _state.value = _state.value.copy(attendeeSuggestions = it) }
}
}
fun addAttendee(suggestion: CalendarAttendeeSuggestion) {
updateEventDraft { draft ->
if (draft.attendees.any { it.email.equals(suggestion.email, true) }) return@updateEventDraft draft
draft.copy(
attendees = draft.attendees + CalendarAttendeeItem(suggestion.email, suggestion.name),
attendeeQuery = "",
)
}
_state.value = _state.value.copy(attendeeSuggestions = emptyList())
}
fun removeAttendee(email: String) {
updateEventDraft { it.copy(attendees = it.attendees.filterNot { a -> a.email == email }) }
}
fun searchLocations(query: String) {
val s = session ?: return
updateEventDraft { it.copy(location = query) }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.searchLocations(s, query) }
.onSuccess { _state.value = _state.value.copy(locationSuggestions = it) }
}
}
fun applyLocationSuggestion(suggestion: CalendarLocationSuggestion) {
updateEventDraft { it.copy(location = suggestion.address.ifBlank { suggestion.name }) }
_state.value = _state.value.copy(locationSuggestions = emptyList())
}
fun openEventDetail(event: CalendarEventItem) {
_state.value = _state.value.copy(eventDetail = event, focusedEventUid = event.uid)
}
fun closeEventDetail() { _state.value = _state.value.copy(eventDetail = null, focusedEventUid = null) }
fun openEditEvent(event: CalendarEventItem) {
val zone = ZoneId.systemDefault()
val start = Instant.ofEpochMilli(event.startEpochMilli).atZone(zone)
val end = Instant.ofEpochMilli(event.endEpochMilli).atZone(zone)
val recurrence = RecurrencePreset.entries.firstOrNull { it.rrule == event.rrule } ?: RecurrencePreset.NONE
_state.value = _state.value.copy(
createDialogOpen = true,
editorMode = CalendarEditorMode.EDIT,
eventDetail = null,
eventDraft = CalendarEventDraft(
title = event.summary,
date = start.toLocalDate(),
startTime = start.format(DateTimeFormatter.ofPattern("HH:mm")),
endTime = end.format(DateTimeFormatter.ofPattern("HH:mm")),
allDay = event.allDay,
description = event.description,
location = event.location,
calendarHref = event.calendarHref,
recurrence = recurrence,
customRrule = if (recurrence == RecurrencePreset.NONE) event.rrule else "",
categories = event.categories.joinToString(", "),
status = EventStatus.entries.firstOrNull { it.icsValue == event.status } ?: EventStatus.CONFIRMED,
classification = EventClassification.entries.firstOrNull { it.icsValue == event.classification } ?: EventClassification.PUBLIC,
attendees = event.attendees,
reminderMinutes = event.alarms.firstOrNull()?.minutesBefore ?: 15,
talkRoomUrl = event.talkRoomUrl,
addTalkRoom = event.talkRoomUrl.isNotBlank(),
),
focusedEventUid = event.uid,
)
}
fun duplicateEvent(event: CalendarEventItem) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.duplicateEvent(s, event, _state.value.selectedDay) }
.onSuccess {
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Событие скопировано", eventDetail = null)
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun respondToInvite(partStat: String) {
val s = session ?: return
val event = _state.value.eventDetail ?: return
val email = s.username
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.respondToInvite(s, event, partStat, email) }
.onSuccess {
reloadEvents(s)
_state.value = _state.value.copy(snackMessage = "Ответ отправлен", eventDetail = null)
}
.onFailure { _state.value = _state.value.copy(error = it.message) }
}
}
fun saveEventDraft() {
val s = session ?: return
val draft = _state.value.eventDraft
if (draft.title.trim().isBlank()) { _state.value = _state.value.copy(error = "Введите название"); return }
if (!draft.allDay) {
val start = parseDateTime(draft.date, draft.startTime)
val end = parseDateTime(draft.date, draft.endTime)
if (start == null || end == null) { _state.value = _state.value.copy(error = "Некорректное время"); return }
if (!end.isAfter(start)) { _state.value = _state.value.copy(error = "Окончание должно быть позже начала"); return }
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(saving = true, error = null)
val existing = findEventByUid(_state.value.focusedEventUid.orEmpty())
runCatching { repository.saveEvent(s, draft, existing) }
.onSuccess {
prefsStore.setCreateCalendarHref(syncStore.accountKey(s.serverUrl, s.username), draft.calendarHref)
_state.value = _state.value.copy(saving = false, createDialogOpen = false, snackMessage = "Сохранено")
afterMutation(s)
}
.onFailure { t ->
_state.value = _state.value.copy(saving = false, error = t.message, unauthorized = t is UnauthorizedException)
}
}
}
fun deleteSelectedEvent() {
val s = session ?: return
val event = findEventByUid(_state.value.focusedEventUid.orEmpty()) ?: _state.value.eventDetail ?: return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(deleting = true)
runCatching { repository.deleteEvent(s, event) }
.onSuccess {
_state.value = _state.value.copy(deleting = false, createDialogOpen = false, eventDetail = null, focusedEventUid = null, snackMessage = "Удалено")
afterMutation(s)
}
.onFailure { t -> _state.value = _state.value.copy(deleting = false, error = t.message) }
}
}
fun clearSnack() { _state.value = _state.value.copy(snackMessage = null) }
fun focusEventByUid(session: AuthSession, uid: String) {
viewModelScope.launch(Dispatchers.IO) {
findEventByUid(uid.trim())?.let { applyFocusedEvent(it) } ?: run {
reloadEvents(session)
findEventByUid(uid.trim())?.let { applyFocusedEvent(it) }
}
}
}
fun visibleEvents(): List<CalendarEventItem> {
val visible = _state.value.calendars.filter { it.visible }.map { it.href }.toSet()
return if (visible.isEmpty()) _state.value.events else _state.value.events.filter { it.calendarHref in visible || it.calendarHref.isBlank() }
}
fun eventsForDay(day: LocalDate) = visibleEvents().filter { CalendarRepository.eventOccursOnDay(it, day) }
fun daysWithEvents(): Set<LocalDate> = CalendarRepository.monthGridDays(_state.value.visibleMonth)
.filter { day -> visibleEvents().any { CalendarRepository.eventOccursOnDay(it, day) } }.toSet()
fun periodTitle(): String {
val st = _state.value
return when (st.viewMode) {
CalendarViewMode.YEAR -> st.visibleYear.toString()
CalendarViewMode.LIST -> "Предстоящие события"
CalendarViewMode.WEEK -> {
val (start, end) = CalendarRepository.weekRange(st.selectedDay)
"${start.format(DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH))} ${end.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))}"
}
CalendarViewMode.DAY, CalendarViewMode.MONTH ->
st.selectedDay.format(DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH))
}
}
private fun loadCalendars(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.listCalendars(session) }.onSuccess { cals ->
val accountKey = syncStore.accountKey(session.serverUrl, session.username)
val visible = prefsStore.getVisibleCalendarHrefs(accountKey)
val items = cals.map { cal ->
CalendarBookItem(
href = cal.href,
displayName = cal.displayName.ifBlank { "Календарь" },
visible = visible.isEmpty() || cal.href in visible,
color = cal.color,
)
}
if (visible.isEmpty()) visibleCalendarHrefs = items.map { it.href }.toSet()
_state.value = _state.value.copy(calendars = items)
}
}
}
private fun reloadEvents(session: AuthSession, showLoading: Boolean = true) {
viewModelScope.launch(Dispatchers.IO) {
if (showLoading) {
_state.value = _state.value.copy(loading = true, error = null)
}
val st = _state.value
runCatching {
when (st.viewMode) {
CalendarViewMode.MONTH -> repository.loadMonth(session, st.visibleMonth, visibleCalendarHrefs)
CalendarViewMode.YEAR -> repository.loadRange(session, LocalDate.of(st.visibleYear, 1, 1), LocalDate.of(st.visibleYear, 12, 31), visibleCalendarHrefs)
CalendarViewMode.DAY -> repository.loadRange(session, st.selectedDay, st.selectedDay, visibleCalendarHrefs)
CalendarViewMode.WEEK -> {
val (a, b) = CalendarRepository.weekRange(st.selectedDay)
repository.loadRange(session, a, b, visibleCalendarHrefs)
}
CalendarViewMode.LIST -> {
val (a, b) = CalendarRepository.listRange(st.selectedDay)
repository.loadRange(session, a, b, visibleCalendarHrefs)
}
}
}.onSuccess { _state.value = _state.value.copy(loading = false, events = it) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message, unauthorized = it is UnauthorizedException) }
}
}
private fun afterMutation(session: AuthSession) {
val calId = _state.value.selectedDeviceCalendarId
if (_state.value.syncEnabled && calId > 0) runSyncInternal(session, calId, false) else reloadEvents(session)
}
private fun runSyncInternal(session: AuthSession, deviceCalendarId: Long, showSnack: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(syncing = true, loading = true)
runCatching { syncEngine.run(session, deviceCalendarId) }
.onSuccess { result ->
reloadEvents(session)
_state.value = _state.value.copy(
syncing = false,
snackMessage = if (showSnack) "Синхронизация завершена (${result.pushedToDevice + result.pushedToServer + result.updated + result.linked})" else _state.value.snackMessage,
lastSyncLabel = formatLastSync(syncStore.getLastSyncAt(syncStore.accountKey(session.serverUrl, session.username))),
)
}
.onFailure { t -> _state.value = _state.value.copy(syncing = false, loading = false, error = t.message) }
}
}
private fun findEventByUid(uid: String): CalendarEventItem? {
if (uid.isBlank()) return null
return visibleEvents().find { it.uid == uid || it.uid.endsWith(uid) || uid.endsWith(it.uid) }
}
private fun applyFocusedEvent(event: CalendarEventItem) {
val day = Instant.ofEpochMilli(event.startEpochMilli).atZone(ZoneId.systemDefault()).toLocalDate()
_state.value = _state.value.copy(selectedDay = day, visibleMonth = YearMonth.from(day), eventDetail = event, focusedEventUid = event.uid)
}
private fun formatLastSync(epoch: Long) = if (epoch <= 0) null else "Синхр.: ${Instant.ofEpochMilli(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))}"
private fun parseDateTime(day: LocalDate, time: String): java.time.LocalDateTime? {
val p = time.trim().split(':')
if (p.size != 2) return null
val h = p[0].toIntOrNull() ?: return null
val m = p[1].toIntOrNull() ?: return null
if (h !in 0..23 || m !in 0..59) return null
return day.atTime(h, m)
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
CalendarViewModel(context.applicationContext) as T
}
companion object {
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
val monthTitleFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.forLanguageTag("ru"))
}
}
@@ -0,0 +1,207 @@
package ru.forbion.f7cloud.feature.calendar
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import ru.forbion.f7cloud.core.network.CalDavClient
import java.time.Instant
import java.time.ZoneId
data class DeviceCalendarInfo(
val id: Long,
val displayName: String,
val accountName: String,
)
data class DeviceCalendarEvent(
val eventId: Long,
val calendarId: Long,
val title: String,
val description: String,
val startEpochMilli: Long,
val endEpochMilli: Long,
val allDay: Boolean,
val lastModifiedEpochMilli: Long,
val ncUid: String?,
)
class DeviceCalendarClient(private val context: Context) {
fun listWritableCalendars(): List<DeviceCalendarInfo> {
val out = mutableListOf<DeviceCalendarInfo>()
val uri = CalendarContract.Calendars.CONTENT_URI
val projection = arrayOf(
CalendarContract.Calendars._ID,
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME,
CalendarContract.Calendars.ACCOUNT_NAME,
CalendarContract.Calendars.VISIBLE,
CalendarContract.Calendars.SYNC_EVENTS,
)
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND ${CalendarContract.Calendars.SYNC_EVENTS} = 1"
context.contentResolver.query(uri, projection, selection, null, null)?.use { cursor ->
val idIdx = cursor.getColumnIndex(CalendarContract.Calendars._ID)
val nameIdx = cursor.getColumnIndex(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)
val accountIdx = cursor.getColumnIndex(CalendarContract.Calendars.ACCOUNT_NAME)
while (cursor.moveToNext()) {
if (idIdx < 0 || nameIdx < 0) continue
val id = cursor.getLong(idIdx)
val name = cursor.getString(nameIdx).orEmpty().ifBlank { "Календарь" }
val account = if (accountIdx >= 0) cursor.getString(accountIdx).orEmpty() else ""
out += DeviceCalendarInfo(id, name, account)
}
}
return out.distinctBy { it.id }
}
fun queryEvents(
calendarId: Long,
rangeStartEpochMilli: Long,
rangeEndEpochMilli: Long,
): List<DeviceCalendarEvent> {
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
ContentUris.appendId(builder, rangeStartEpochMilli)
ContentUris.appendId(builder, rangeEndEpochMilli)
val uri = builder.build()
val projection = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.DESCRIPTION,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.LAST_DATE,
)
val selection = "${CalendarContract.Instances.CALENDAR_ID} = ?"
val args = arrayOf(calendarId.toString())
val out = mutableListOf<DeviceCalendarEvent>()
context.contentResolver.query(uri, projection, selection, args, null)?.use { cursor ->
val eventIdIdx = cursor.getColumnIndex(CalendarContract.Instances.EVENT_ID)
val calIdx = cursor.getColumnIndex(CalendarContract.Instances.CALENDAR_ID)
val titleIdx = cursor.getColumnIndex(CalendarContract.Instances.TITLE)
val descIdx = cursor.getColumnIndex(CalendarContract.Instances.DESCRIPTION)
val beginIdx = cursor.getColumnIndex(CalendarContract.Instances.BEGIN)
val endIdx = cursor.getColumnIndex(CalendarContract.Instances.END)
val allDayIdx = cursor.getColumnIndex(CalendarContract.Instances.ALL_DAY)
val lastIdx = cursor.getColumnIndex(CalendarContract.Instances.LAST_DATE)
while (cursor.moveToNext()) {
if (eventIdIdx < 0 || beginIdx < 0 || endIdx < 0) continue
val description = if (descIdx >= 0) cursor.getString(descIdx).orEmpty() else ""
out += DeviceCalendarEvent(
eventId = cursor.getLong(eventIdIdx),
calendarId = if (calIdx >= 0) cursor.getLong(calIdx) else calendarId,
title = if (titleIdx >= 0) cursor.getString(titleIdx).orEmpty() else "",
description = description,
startEpochMilli = cursor.getLong(beginIdx),
endEpochMilli = cursor.getLong(endIdx),
allDay = allDayIdx >= 0 && cursor.getInt(allDayIdx) == 1,
lastModifiedEpochMilli = if (lastIdx >= 0) cursor.getLong(lastIdx) else cursor.getLong(beginIdx),
ncUid = parseNcUid(description),
)
}
}
return out.distinctBy { it.eventId }
}
fun insertEvent(
calendarId: Long,
title: String,
description: String,
startEpochMilli: Long,
endEpochMilli: Long,
allDay: Boolean,
ncUid: String?,
): Long {
val zone = ZoneId.systemDefault()
val values = ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.TITLE, title)
put(CalendarContract.Events.DTSTART, startEpochMilli)
put(CalendarContract.Events.DTEND, endEpochMilli)
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
put(CalendarContract.Events.EVENT_END_TIMEZONE, zone.id)
put(CalendarContract.Events.ALL_DAY, if (allDay) 1 else 0)
put(CalendarContract.Events.DESCRIPTION, buildDescription(description, ncUid))
put(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_BUSY)
}
val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: error("Не удалось создать событие в календаре телефона")
return ContentUris.parseId(uri)
}
fun updateEvent(
eventId: Long,
title: String,
description: String,
startEpochMilli: Long,
endEpochMilli: Long,
allDay: Boolean,
ncUid: String?,
) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
val values = ContentValues().apply {
put(CalendarContract.Events.TITLE, title)
put(CalendarContract.Events.DTSTART, startEpochMilli)
put(CalendarContract.Events.DTEND, endEpochMilli)
put(CalendarContract.Events.ALL_DAY, if (allDay) 1 else 0)
put(CalendarContract.Events.DESCRIPTION, buildDescription(description, ncUid))
}
context.contentResolver.update(uri, values, null, null)
}
fun deleteEvent(eventId: Long) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
context.contentResolver.delete(uri, null, null)
}
fun stampNcUid(eventId: Long, ncUid: String) {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
val existing = readDescription(eventId)
val values = ContentValues().apply {
put(CalendarContract.Events.DESCRIPTION, buildDescription(existing, ncUid))
}
context.contentResolver.update(uri, values, null, null)
}
private fun readDescription(eventId: Long): String {
val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
context.contentResolver.query(
uri,
arrayOf(CalendarContract.Events.DESCRIPTION),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(CalendarContract.Events.DESCRIPTION)
if (idx >= 0) return cursor.getString(idx).orEmpty()
}
}
return ""
}
companion object {
fun parseNcUid(description: String?): String? {
val text = description.orEmpty()
val prefix = CalDavClient.F7CLOUD_UID_MARKER_PREFIX
val line = text.lines().firstOrNull { it.startsWith(prefix, ignoreCase = true) }
?: return null
return line.removePrefix(prefix).trim().takeIf { it.isNotBlank() }
}
fun buildDescription(existing: String, ncUid: String?): String {
val lines = existing.lines().filter {
!it.startsWith(CalDavClient.F7CLOUD_UID_MARKER_PREFIX, ignoreCase = true)
}
val base = lines.joinToString("\n").trim()
return if (ncUid.isNullOrBlank()) {
base
} else {
listOf(base, "${CalDavClient.F7CLOUD_UID_MARKER_PREFIX}$ncUid")
.filter { it.isNotBlank() }
.joinToString("\n")
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.contacts'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:database')
implementation project(':core:designsystem')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'io.coil-kt:coil-compose:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,290 @@
package ru.forbion.f7cloud.feature.contacts
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactDetailSheet(
contact: ContactItem,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = F7Colors.Surface,
) {
ContactDetailContent(
contact = contact,
onDismiss = onDismiss,
)
}
}
@Composable
private fun ContactDetailContent(
contact: ContactItem,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val label = contact.displayName.ifBlank { contact.email }
val photoBytes = remember(contact.uid, contact.photoBase64) {
ContactUi.decodeContactPhoto(contact.photoBase64)
}
val (avatarBg, avatarFg) = ContactUi.avatarColors(label)
val initials = ContactUi.contactInitials(label)
val primaryEmail = contact.emailLines.firstOrNull()
val primaryPhone = contact.phoneLines.firstOrNull()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(88.dp)
.clip(CircleShape)
.background(avatarBg),
contentAlignment = Alignment.Center,
) {
if (photoBytes != null) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(photoBytes)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
} else {
Text(
initials,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = avatarFg,
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
contact.displayName.ifBlank { contact.email },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
contact.subtitle?.let { subtitle ->
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
}
if (primaryEmail != null || primaryPhone != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (primaryEmail != null) {
F7SecondaryButton(
text = "Email",
onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$primaryEmail")),
)
},
modifier = Modifier.weight(1f),
)
}
if (primaryPhone != null) {
F7SecondaryButton(
text = "Позвонить",
onClick = {
context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$primaryPhone")),
)
},
modifier = Modifier.weight(1f),
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(F7Colors.Background)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
if (contact.emailLines.isNotEmpty()) {
contact.emailLines.forEachIndexed { index, email ->
ContactDetailField(
label = if (index == 0) "Email" else "Email ${index + 1}",
value = email,
onClick = {
context.startActivity(
Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:$email")),
)
},
)
}
}
if (contact.phoneLines.isNotEmpty()) {
contact.phoneLines.forEachIndexed { index, phone ->
ContactDetailField(
label = if (index == 0) "Телефон" else "Телефон ${index + 1}",
value = phone,
onClick = {
context.startActivity(
Intent(Intent.ACTION_DIAL, Uri.parse("tel:$phone")),
)
},
)
}
}
if (contact.address.isNotBlank()) {
ContactDetailField(label = "Адрес", value = contact.address)
}
if (contact.website.isNotBlank()) {
ContactDetailField(
label = "Сайт",
value = contact.website,
onClick = {
val url = contact.website.let {
if (it.startsWith("http://") || it.startsWith("https://")) it
else "https://$it"
}
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
)
}
if (contact.birthday.isNotBlank()) {
ContactDetailField(label = "День рождения", value = contact.birthday)
}
if (contact.bookName.isNotBlank()) {
ContactDetailField(label = "Адресная книга", value = contact.bookName)
}
}
Spacer(Modifier.height(4.dp))
Text(
"Закрыть",
modifier = Modifier
.clickable(onClick = onDismiss)
.padding(8.dp),
color = F7Colors.Primary,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun ContactDetailField(
label: String,
value: String,
onClick: (() -> Unit)? = null,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
},
)
.padding(vertical = 12.dp),
) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(4.dp))
Text(
value,
style = MaterialTheme.typography.bodyLarge,
color = if (onClick != null) F7Colors.Primary else F7Colors.TextPrimary,
)
}
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
}
internal object ContactUi {
private val AvatarPalette = listOf(
androidx.compose.ui.graphics.Color(0xFFE8F5E0) to androidx.compose.ui.graphics.Color(0xFF4A7C2E),
androidx.compose.ui.graphics.Color(0xFFFCE4EC) to androidx.compose.ui.graphics.Color(0xFFC2185B),
androidx.compose.ui.graphics.Color(0xFFE3F2FD) to androidx.compose.ui.graphics.Color(0xFF1565C0),
androidx.compose.ui.graphics.Color(0xFFFFF3E0) to androidx.compose.ui.graphics.Color(0xFFE65100),
androidx.compose.ui.graphics.Color(0xFFEDE7F6) to androidx.compose.ui.graphics.Color(0xFF6B4F9B),
)
fun contactInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
return when {
parts.size >= 2 -> "${parts[0].first()}${parts[1].first()}".uppercase()
parts.size == 1 -> parts[0].take(2).uppercase()
else -> "?"
}
}
fun avatarColors(seed: String): Pair<androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color> {
val idx = kotlin.math.abs(seed.hashCode()) % AvatarPalette.size
return AvatarPalette[idx]
}
fun decodeContactPhoto(base64: String): ByteArray? {
if (base64.isBlank()) return null
return runCatching {
android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
}.getOrNull()?.takeIf { it.isNotEmpty() }
}
}
@@ -0,0 +1,29 @@
package ru.forbion.f7cloud.feature.contacts
object ContactRecipientHelper {
fun formatRecipient(displayName: String, email: String): String {
val name = displayName.trim()
val mail = email.trim()
if (mail.isBlank()) return name
if (name.isBlank() || name.equals(mail, ignoreCase = true)) return mail
return "$name <$mail>"
}
fun currentToken(raw: String): String {
val tail = raw.substringAfterLast(',', raw).substringAfterLast(';', raw)
return tail.trim()
}
fun replaceCurrentToken(raw: String, replacement: String): String {
val comma = raw.lastIndexOf(',')
val semicolon = raw.lastIndexOf(';')
val sepIndex = maxOf(comma, semicolon)
if (sepIndex < 0) return replacement
val prefix = raw.substring(0, sepIndex + 1)
return if (prefix.endsWith(' ')) {
"$prefix$replacement"
} else {
"$prefix $replacement"
}
}
}
@@ -0,0 +1,218 @@
package ru.forbion.f7cloud.feature.contacts
import android.content.Context
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.database.ContactEntity
import ru.forbion.f7cloud.core.database.F7Database
import ru.forbion.f7cloud.core.network.CardDavClient
import ru.forbion.f7cloud.core.network.NetworkFactory
data class ContactItem(
val uid: String,
val displayName: String,
val email: String,
val phone: String,
val bookName: String,
val photoBase64: String = "",
val photoMimeType: String = "",
val organization: String = "",
val title: String = "",
val address: String = "",
val website: String = "",
val birthday: String = "",
val emails: String = "",
val phones: String = "",
) {
val emailLines: List<String>
get() = (if (emails.isNotBlank()) emails else email)
.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
val phoneLines: List<String>
get() = (if (phones.isNotBlank()) phones else phone)
.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
val subtitle: String?
get() = when {
title.isNotBlank() && organization.isNotBlank() -> "$title · $organization"
title.isNotBlank() -> title
organization.isNotBlank() -> organization
else -> null
}
}
class ContactsRepository(context: Context) {
private val appContext = context.applicationContext
private val dao = F7Database.get(appContext).contactsDao()
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun observeContacts(session: AuthSession): Flow<List<ContactItem>> {
return dao.observeAll(accountKey(session)).map { entities ->
entities.map { it.toItem() }
}
}
suspend fun getCachedContacts(session: AuthSession): List<ContactItem> {
return dao.getAll(accountKey(session)).map { it.toItem() }
}
suspend fun syncContacts(session: AuthSession, force: Boolean = false): List<ContactItem> {
val key = accountKey(session)
val lastSync = prefs.getLong(lastSyncKey(key), 0L)
val needsPhotoBackfill = !prefs.getBoolean(photoSyncDoneKey(key), false)
val needsDetailsBackfill = !prefs.getBoolean(detailsSyncDoneKey(key), false)
if (!force && !needsPhotoBackfill && !needsDetailsBackfill &&
System.currentTimeMillis() - lastSync < SYNC_INTERVAL_MS
) {
return dao.getAll(key).map { it.toItem() }
}
val remote = fetchRemoteContacts(session)
val entities = remote.map { contact ->
ContactEntity(
accountKey = key,
uid = contact.uid.ifBlank { "${contact.email}|${contact.displayName}" },
displayName = contact.displayName,
email = contact.email,
phone = contact.phone,
bookName = contact.bookName,
photoBase64 = contact.photoBase64,
photoMimeType = contact.photoMimeType,
organization = contact.organization,
title = contact.title,
address = contact.address,
website = contact.website,
birthday = contact.birthday,
emails = contact.emails,
phones = contact.phones,
)
}
dao.replaceAll(key, entities)
prefs.edit()
.putLong(lastSyncKey(key), System.currentTimeMillis())
.putBoolean(photoSyncDoneKey(key), true)
.putBoolean(detailsSyncDoneKey(key), true)
.apply()
return entities.map { it.toItem() }
}
suspend fun createContact(
session: AuthSession,
displayName: String,
email: String,
phone: String = "",
): ContactItem {
val client = authedClient(session)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val created = CardDavClient.createContact(
client = client,
serverUrl = session.serverUrl,
userId = userId,
displayName = displayName,
email = email,
phone = phone,
)
val key = accountKey(session)
val entity = ContactEntity(
accountKey = key,
uid = created.uid,
displayName = created.displayName,
email = created.email,
phone = created.phone,
bookName = created.bookName,
photoBase64 = created.photoBase64,
photoMimeType = created.photoMimeType,
)
dao.insert(entity)
return entity.toItem()
}
fun filterSuggestions(
contacts: List<ContactItem>,
query: String,
limit: Int = 12,
): List<ContactItem> {
val q = query.trim()
if (q.isEmpty()) return emptyList()
return contacts
.filter {
it.displayName.contains(q, ignoreCase = true) ||
it.email.contains(q, ignoreCase = true)
}
.sortedWith(
compareBy<ContactItem> { !it.displayName.startsWith(q, ignoreCase = true) }
.thenBy { !it.email.startsWith(q, ignoreCase = true) }
.thenBy { it.displayName.lowercase() },
)
.take(limit)
}
private suspend fun fetchRemoteContacts(session: AuthSession): List<ContactItem> {
val client = authedClient(session)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
return CardDavClient.listContacts(client, session.serverUrl, userId)
.map {
ContactItem(
uid = it.uid,
displayName = it.displayName,
email = it.email,
phone = it.phone,
bookName = it.bookName,
photoBase64 = it.photoBase64,
photoMimeType = it.photoMimeType,
organization = it.organization,
title = it.title,
address = it.address,
website = it.website,
birthday = it.birthday,
emails = it.emails,
phones = it.phones,
)
}
.sortedBy { it.displayName.lowercase() }
}
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
private fun accountKey(session: AuthSession): String =
"${session.serverUrl}|${session.username}"
private fun lastSyncKey(accountKey: String) = "last_sync_$accountKey"
private fun photoSyncDoneKey(accountKey: String) = "photo_sync_done_$accountKey"
private fun detailsSyncDoneKey(accountKey: String) = "details_sync_done_$accountKey"
private fun ContactEntity.toItem() = ContactItem(
uid = uid,
displayName = displayName,
email = email,
phone = phone,
bookName = bookName,
photoBase64 = photoBase64,
photoMimeType = photoMimeType,
organization = organization,
title = title,
address = address,
website = website,
birthday = birthday,
emails = emails,
phones = phones,
)
companion object {
const val SYNC_INTERVAL_MS = 10 * 60 * 1000L
private const val PREFS_NAME = "contacts_sync"
}
}
@@ -0,0 +1,351 @@
package ru.forbion.f7cloud.feature.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
@Composable
fun ContactsScreen(
session: AuthSession,
modifier: Modifier = Modifier,
createRequest: Int = 0,
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val vm: ContactsViewModel = viewModel(factory = ContactsViewModelFactory(context))
val state by vm.state.collectAsState()
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
vm.load(session)
}
LaunchedEffect(createRequest) {
if (createRequest > 0) vm.openAddSheet()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.contacts.isEmpty(),
error = state.error,
) {
ContactsSearchBar(
serverUrl = session.serverUrl,
query = state.searchQuery,
onQueryChange = vm::setSearchQuery,
)
if (state.syncing && state.contacts.isNotEmpty()) {
Text(
"Обновление…",
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
)
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.filteredContacts, key = { "${it.uid}|${it.email}" }) { contact ->
ContactListRow(
contact = contact,
onClick = { vm.openContact(contact) },
)
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
}
}
}
state.selectedContact?.let { contact ->
ContactDetailSheet(
contact = contact,
onDismiss = vm::closeContactDetail,
)
}
if (state.addSheetOpen) {
AddContactDialog(
saving = state.savingContact,
error = state.addError,
onDismiss = vm::closeAddSheet,
onSave = { name, email, phone ->
vm.createContact(session, name, email, phone)
},
)
}
}
@Composable
private fun AddContactDialog(
saving: Boolean,
error: String?,
onDismiss: () -> Unit,
onSave: (name: String, email: String, phone: String) -> Unit,
) {
var name by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
Dialog(onDismissRequest = { if (!saving) onDismiss() }) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"Новый контакт",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
AddContactField(label = "Имя", value = name, onValueChange = { name = it })
AddContactField(label = "Email", value = email, onValueChange = { email = it })
AddContactField(label = "Телефон", value = phone, onValueChange = { phone = it })
if (!error.isNullOrBlank()) {
Text(error, color = MaterialTheme.colorScheme.error, fontSize = 13.sp)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Отмена",
modifier = Modifier
.clickable(enabled = !saving, onClick = onDismiss)
.padding(8.dp),
color = F7Colors.TextSecondary,
)
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(enabled = !saving) { onSave(name, email, phone) }
.padding(horizontal = 20.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
if (saving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text("Сохранить", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
@Composable
private fun AddContactField(
label: String,
value: String,
onValueChange: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = F7Colors.TextSecondary)
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true,
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
)
}
}
@Composable
fun ContactListRow(
contact: ContactItem,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val context = LocalContext.current
val label = contact.displayName.ifBlank { contact.email }
val (bg, fg) = ContactUi.avatarColors(label)
val initials = ContactUi.contactInitials(label)
val photoBytes = remember(contact.uid, contact.photoBase64) {
ContactUi.decodeContactPhoto(contact.photoBase64)
}
Row(
modifier = modifier
.fillMaxWidth()
.then(
if (onClick != null) {
Modifier.clickable(onClick = onClick)
} else {
Modifier
},
)
.padding(horizontal = 4.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(bg),
contentAlignment = Alignment.Center,
) {
if (photoBytes != null) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(photoBytes)
.crossfade(true)
.build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
initials,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = fg,
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
contact.displayName.ifBlank { contact.email },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (contact.email.isNotBlank() && contact.displayName.isNotBlank()) {
Text(
contact.email,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else if (contact.phone.isNotBlank()) {
Text(
contact.phone,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
private fun ContactsSearchBar(
serverUrl: String,
query: String,
onQueryChange: (String) -> Unit,
) {
val base = serverUrl.trimEnd('/')
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.height(40.dp)
.shadow(2.dp, RoundedCornerShape(100.dp), spotColor = Color(0xFFCBCBCB))
.clip(RoundedCornerShape(100.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.padding(horizontal = 14.dp),
contentAlignment = Alignment.CenterStart,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = "$base/themes/forbion/images/search/searchContacts.svg",
contentDescription = null,
modifier = Modifier.size(16.dp),
)
BasicTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
singleLine = true,
textStyle = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (query.isEmpty()) {
Text(
"Поиск контактов",
style = TextStyle(
fontSize = 14.sp,
color = F7Colors.TextSecondary,
),
)
}
inner()
},
)
}
}
}
@@ -0,0 +1,188 @@
package ru.forbion.f7cloud.feature.contacts
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
data class ContactsUiState(
val loading: Boolean = false,
val syncing: Boolean = false,
val contacts: List<ContactItem> = emptyList(),
val searchQuery: String = "",
val error: String? = null,
val unauthorized: Boolean = false,
val addSheetOpen: Boolean = false,
val savingContact: Boolean = false,
val addError: String? = null,
val selectedContact: ContactItem? = null,
) {
val filteredContacts: List<ContactItem>
get() {
val q = searchQuery.trim()
if (q.isEmpty()) return contacts
return contacts.filter {
it.displayName.contains(q, ignoreCase = true) ||
it.email.contains(q, ignoreCase = true) ||
it.phone.contains(q, ignoreCase = true)
}
}
}
class ContactsViewModel(
private val repository: ContactsRepository,
) : ViewModel() {
private val _state = MutableStateFlow(ContactsUiState())
val state: StateFlow<ContactsUiState> = _state.asStateFlow()
private var periodicSyncJob: Job? = null
private var activeSession: AuthSession? = null
fun setSearchQuery(query: String) {
_state.update { it.copy(searchQuery = query) }
}
fun openAddSheet() {
_state.update { it.copy(addSheetOpen = true, addError = null) }
}
fun closeAddSheet() {
_state.update { it.copy(addSheetOpen = false, addError = null, savingContact = false) }
}
fun openContact(contact: ContactItem) {
_state.update { it.copy(selectedContact = contact) }
}
fun closeContactDetail() {
_state.update { it.copy(selectedContact = null) }
}
fun load(session: AuthSession) {
if (activeSession?.serverUrl == session.serverUrl &&
activeSession?.username == session.username
) {
return
}
activeSession = session
periodicSyncJob?.cancel()
viewModelScope.launch {
repository.observeContacts(session).collect { contacts ->
_state.update {
it.copy(
contacts = contacts,
loading = false,
)
}
}
}
refresh(session, showLoading = true)
periodicSyncJob = viewModelScope.launch {
while (isActive) {
delay(ContactsRepository.SYNC_INTERVAL_MS)
if (AppForegroundTracker.isForeground) {
refresh(session, showLoading = false)
}
}
}
}
fun refresh(session: AuthSession, showLoading: Boolean = false) {
viewModelScope.launch(Dispatchers.IO) {
if (showLoading && _state.value.contacts.isEmpty()) {
_state.update { it.copy(loading = true, error = null) }
} else {
_state.update { it.copy(syncing = true, error = null) }
}
runCatching { repository.syncContacts(session) }
.onFailure { t ->
_state.update {
it.copy(
loading = false,
syncing = false,
error = if (it.contacts.isEmpty()) t.message else null,
unauthorized = t is UnauthorizedException,
)
}
}
.onSuccess {
_state.update {
it.copy(
loading = false,
syncing = false,
error = null,
)
}
}
}
}
fun createContact(
session: AuthSession,
displayName: String,
email: String,
phone: String,
) {
if (_state.value.savingContact) return
val name = displayName.trim()
val mail = email.trim()
if (name.isBlank() && mail.isBlank()) {
_state.update { it.copy(addError = "Укажите имя или email") }
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(savingContact = true, addError = null) }
runCatching {
repository.createContact(
session = session,
displayName = name.ifBlank { mail.substringBefore('@') },
email = mail,
phone = phone.trim(),
)
}.onSuccess {
_state.update {
it.copy(
savingContact = false,
addSheetOpen = false,
addError = null,
)
}
refresh(session, showLoading = false)
}.onFailure { t ->
_state.update {
it.copy(
savingContact = false,
addError = t.message ?: "Не удалось создать контакт",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
}
class ContactsViewModelFactory(
context: Context,
) : ViewModelProvider.Factory {
private val repository = ContactsRepository(context.applicationContext)
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ContactsViewModel::class.java)) {
return ContactsViewModel(repository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
+38
View File
@@ -0,0 +1,38 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.deck'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,121 @@
package ru.forbion.f7cloud.feature.deck
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class DeckRepository {
private fun apiBase(session: AuthSession): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
}
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val json = getJson(client, "${apiBase(session)}/boards")
val array = when (json) {
is JSONArray -> json
is JSONObject -> JSONArray().put(json)
else -> JSONArray()
}
val out = mutableListOf<DeckBoard>()
for (i in 0 until array.length()) {
val board = array.optJSONObject(i) ?: continue
val id = board.optInt("id", 0)
val title = board.optString("title")
if (id > 0 && title.isNotBlank()) {
out += DeckBoard(id = id, title = title, color = board.optString("color"))
}
}
return out
}
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
if (boardId <= 0) error("Карточка не найдена")
return DeckCardDetail(
cardId = cardId,
boardId = boardId,
title = json.optString("title"),
)
}
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks")
val stacksArray = when (stacksJson) {
is JSONArray -> stacksJson
else -> JSONArray()
}
val stacks = mutableListOf<DeckStack>()
for (i in 0 until stacksArray.length()) {
val stack = stacksArray.optJSONObject(i) ?: continue
val stackId = stack.optInt("id", 0)
val title = stack.optString("title")
val cards = mutableListOf<DeckCard>()
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
for (c in 0 until cardsArray.length()) {
val card = cardsArray.optJSONObject(c) ?: continue
val cardTitle = card.optString("title")
if (cardTitle.isNotBlank()) {
cards += DeckCard(
id = card.optInt("id", 0),
title = cardTitle,
done = card.has("done") && !card.isNull("done"),
)
}
}
if (stackId > 0) {
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
}
}
return DeckBoardDetail(boardId = boardId, stacks = stacks)
}
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Deck API HTTP ${response.code}")
}
val body = response.body!!.string().trim()
if (body.startsWith("[")) return JSONArray(body)
if (body.startsWith("{")) return JSONObject(body)
return JSONArray()
}
}
}
data class DeckBoard(
val id: Int,
val title: String,
val color: String,
)
data class DeckStack(
val id: Int,
val title: String,
val cards: List<DeckCard>,
)
data class DeckCard(
val id: Int,
val title: String,
val done: Boolean,
)
data class DeckBoardDetail(
val boardId: Int,
val stacks: List<DeckStack>,
)
data class DeckCardDetail(
val cardId: Int,
val boardId: Int,
val title: String,
)
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7ListCard
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@Composable
fun DeckScreen(
session: AuthSession,
modifier: Modifier = Modifier,
openCardId: Int? = null,
onOpenCardConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: DeckViewModel = viewModel()
val state by vm.state.collectAsState()
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(openCardId) {
val cardId = openCardId ?: return@LaunchedEffect
vm.openCardById(session, cardId)
onOpenCardConsumed()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler(
enabled = state.boardDetail != null,
onDismiss = vm::closeBoard,
)
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.boards.isEmpty() && state.boardDetail == null,
error = state.error,
onRefresh = { vm.load(session) },
headerActions = {
if (state.selectedBoardId != null) {
F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() })
}
},
) {
val detail = state.boardDetail
if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(detail.stacks, key = { it.id }) { stack ->
F7ListCard {
Text(stack.title, style = MaterialTheme.typography.titleSmall)
stack.cards.forEach { card ->
val prefix = if (card.done) "" else ""
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.boards, key = { it.id }) { board ->
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
Text(board.title, style = MaterialTheme.typography.titleSmall)
}
}
}
}
}
}
@@ -0,0 +1,87 @@
package ru.forbion.f7cloud.feature.deck
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
data class DeckUiState(
val loading: Boolean = false,
val boards: List<DeckBoard> = emptyList(),
val selectedBoardId: Int? = null,
val boardDetail: DeckBoardDetail? = null,
val error: String? = null,
val unauthorized: Boolean = false,
)
class DeckViewModel(
private val repository: DeckRepository = DeckRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(DeckUiState())
val state: StateFlow<DeckUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching { repository.loadBoards(session) }
.onSuccess { boards ->
_state.value = _state.value.copy(loading = false, boards = boards, error = null)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun openBoard(session: AuthSession, boardId: Int) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(
loading = true,
selectedBoardId = boardId,
boardDetail = null,
error = null,
)
runCatching { repository.loadBoardDetail(session, boardId) }
.onSuccess { detail ->
_state.value = _state.value.copy(loading = false, boardDetail = detail)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun closeBoard() {
_state.value = _state.value.copy(selectedBoardId = null, boardDetail = null)
}
fun openCardById(session: AuthSession, cardId: Int) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching { repository.loadCard(session, cardId) }
.onSuccess { card ->
openBoard(session, card.boardId)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось открыть карточку",
unauthorized = t is UnauthorizedException,
)
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.f7support'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'io.coil-kt:coil-compose:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,68 @@
package ru.forbion.f7cloud.feature.f7support
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
import java.util.UUID
data class SupportPendingFile(
val localId: String = UUID.randomUUID().toString(),
val fileName: String,
val bytes: ByteArray,
val mimeType: String,
)
internal object SupportFileIO {
fun readUris(context: Context, uris: List<Uri>): List<SupportPendingFile> {
return uris.mapNotNull { uri ->
runCatching { readUri(context, uri) }.getOrNull()
}
}
fun readUri(
context: Context,
uri: Uri,
fallbackName: String = "file.bin",
): SupportPendingFile {
val resolver = context.contentResolver
var name = fallbackName
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
}
}
}
val mime = resolver.getType(uri) ?: "application/octet-stream"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
return SupportPendingFile(fileName = name, bytes = bytes, mimeType = mime)
}
fun openBytes(context: Context, fileName: String, bytes: ByteArray, mimeType: String) {
val safeName = fileName.replace(Regex("[\\\\/:*?\"<>|]"), "_").ifBlank { "file" }
val cacheDir = File(context.cacheDir, "f7support").apply { mkdirs() }
val file = File(cacheDir, safeName)
file.writeBytes(bytes)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType.ifBlank { "application/octet-stream" })
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(Intent.createChooser(intent, "Открыть с помощью"))
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "Нет приложения для открытия этого файла", Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,420 @@
package ru.forbion.f7cloud.feature.f7support
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.ocsData
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonArray
import ru.forbion.f7cloud.core.network.parseJsonObject
import java.net.URI
class SupportRepository {
suspend fun loadConfig(session: AuthSession): SupportConfig {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/f7support/api/v1/mobile-config?format=json")
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Support config HTTP ${response.code}")
}
val data = parseJsonObject(response.body!!.string(), "конфигурация поддержки").ocsData()
val apiBase = data?.optString("supportApiBase").orEmpty().ifBlank {
"https://support.f7cloud.ru"
}
val serverAddress = data?.optString("serverAddress").orEmpty().ifBlank {
hostFromUrl(session.serverUrl)
}
return SupportConfig(
supportApiBase = apiBase.trimEnd('/'),
serverAddress = serverAddress,
clientReadReceipts = data?.optBoolean("clientReadReceipts") == true,
isSupportAdmin = data?.optBoolean("isSupportAdmin") == true,
username = session.username,
serverUrl = session.serverUrl.trimEnd('/'),
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
}
}
suspend fun listTickets(config: SupportConfig): List<SupportTicket> {
val client = supportClient()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets")
.headers(identityHeaders(config))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить обращения (${response.code})")
}
val array = parseJsonArray(response.body!!.string(), "список обращений")
val out = mutableListOf<SupportTicket>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
parseTicket(obj)?.let { out += it }
}
return out
}
}
suspend fun loadMessages(config: SupportConfig, ticketNumber: String): List<SupportMessage> {
val client = supportClient()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/messages")
.headers(identityHeaders(config))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить сообщения (${response.code})")
}
val array = parseJsonArray(response.body!!.string(), "сообщения обращения")
val out = mutableListOf<SupportMessage>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optLong("id", obj.optLong("message_id", 0L))
val role = obj.optString("author_role")
val author = obj.optString("author")
val outgoing = messageIsOutgoing(config, role, author)
out += SupportMessage(
id = id,
text = obj.optString("text"),
createdAt = obj.optString("created_at"),
outgoing = outgoing,
authorLabel = supportSenderLabel(config, role, author, outgoing),
attachments = parseAttachments(obj.optJSONArray("attachments")),
read = obj.optBoolean("read_by_operator", false) ||
obj.optBoolean("is_read", false),
)
}
return out
}
}
suspend fun sendMessage(config: SupportConfig, ticketNumber: String, text: String): Long {
val client = supportClient()
val payload = JSONObject().put("text", text).toString()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/messages")
.headers(identityHeaders(config))
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось отправить сообщение (${response.code})")
}
val json = parseJsonObject(response.body!!.string(), "ответ на сообщение")
return json.optLong("id", json.optLong("message_id", 0L))
}
}
suspend fun uploadAttachment(
config: SupportConfig,
ticketNumber: String,
messageId: Long,
file: SupportPendingFile,
) {
val client = supportClient()
val mediaType = file.mimeType.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType()
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("message_id", messageId.toString())
.addFormDataPart(
"file",
file.fileName,
file.bytes.toRequestBody(mediaType),
)
.build()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/attachments")
.headers(identityHeaders(config))
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Вложение не принято (${response.code})")
}
}
}
suspend fun sendMessageWithAttachments(
config: SupportConfig,
ticketNumber: String,
text: String,
attachments: List<SupportPendingFile>,
) {
val trimmed = text.trim()
val messageText = when {
trimmed.isNotEmpty() -> trimmed
attachments.isNotEmpty() -> MESSAGE_BODY_PLACEHOLDER
else -> error("Введите сообщение или прикрепите файл")
}
val messageId = sendMessage(config, ticketNumber, messageText)
if (attachments.isEmpty()) return
if (messageId <= 0L) {
error("Сообщение создано, но сервер не вернул id — вложения не отправлены")
}
attachments.forEach { file ->
uploadAttachment(config, ticketNumber, messageId, file)
}
}
suspend fun downloadAttachment(
config: SupportConfig,
ticketNumber: String,
attachmentId: Long,
): Pair<ByteArray, String> {
val client = supportClient()
val request = Request.Builder()
.url(
"${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}" +
"/attachments/${encodePath(attachmentId.toString())}",
)
.headers(identityHeaders(config))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось скачать вложение (${response.code})")
}
val mime = response.header("Content-Type")?.substringBefore(';')?.trim()
?: "application/octet-stream"
return response.body!!.bytes() to mime
}
}
suspend fun createTicket(
config: SupportConfig,
subject: String,
body: String,
attachments: List<SupportPendingFile> = emptyList(),
): String {
val client = supportClient()
val payload = JSONObject()
.put("server_address", config.serverAddress)
.put("username", config.username)
.put("subject", subject)
.put("body", TICKET_CREATE_BODY_PLACEHOLDER)
.put("duplicate", 0)
.toString()
val createReq = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets")
.headers(identityHeaders(config))
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
val ticketNumber = client.newCall(createReq).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось создать обращение (${response.code})")
}
parseJsonObject(response.body!!.string(), "создание обращения").optString("ticket_number")
}
if (ticketNumber.isBlank()) {
error("Сервер не вернул номер обращения")
}
val messageId = sendMessage(config, ticketNumber, body.trim())
if (attachments.isNotEmpty()) {
if (messageId <= 0L) {
error("Обращение создано, но вложения не отправлены — откройте чат и прикрепите файлы вручную")
} else {
attachments.forEach { file ->
uploadAttachment(config, ticketNumber, messageId, file)
}
}
}
return ticketNumber
}
suspend fun markRead(config: SupportConfig, ticketNumber: String) {
if (!config.clientReadReceipts) return
val client = supportClient()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/read")
.headers(identityHeaders(config))
.post("".toRequestBody(null))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 404) return
}
}
suspend fun submitComplaint(
config: SupportConfig,
ticketNumber: String,
ticketSubject: String,
text: String,
) {
val client = NetworkFactory.newAuthedClient(config.username, config.appPassword, config.trustAllCerts)
val payload = JSONObject()
.put("ticketNumber", ticketNumber)
.put("ticketSubject", ticketSubject)
.put("text", text)
.toString()
val request = Request.Builder()
.url("${config.serverUrl}/ocs/v2.php/apps/f7support/api/v1/complaint?format=json")
.applyOcsJson()
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
val body = response.body?.string().orEmpty()
val err = runCatching {
parseJsonObject(body, "жалоба").ocsMeta()?.optString("message")
}.getOrNull().orEmpty()
error(err.ifBlank { "Не удалось отправить жалобу (${response.code})" })
}
}
}
private fun parseTicket(obj: JSONObject): SupportTicket? {
val number = obj.optString("ticket_number")
if (number.isBlank()) return null
val bitrixRaw = obj.opt("bitrix_deal_id")
val bitrixDealId = when (bitrixRaw) {
is Number -> bitrixRaw.toLong().takeIf { it > 0 }
else -> bitrixRaw?.toString()?.trim()?.toLongOrNull()?.takeIf { it > 0 }
}
return SupportTicket(
ticketNumber = number,
subject = obj.optString("subject").ifBlank { "" },
status = obj.optString("status"),
preview = obj.optString("preview_text"),
hasUnread = obj.optBoolean("has_unread", false),
activityAt = obj.optString("activity_at").ifBlank { obj.optString("created_at") },
createdAt = obj.optString("created_at"),
bitrixDealId = bitrixDealId,
assignedEmployee = obj.optString("assigned_employee"),
clientUsername = obj.optString("client_username"),
)
}
private fun parseAttachments(array: JSONArray?): List<SupportAttachment> {
if (array == null || array.length() == 0) return emptyList()
val out = mutableListOf<SupportAttachment>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optLong("id", obj.optLong("attachment_id", 0L))
if (id <= 0L) continue
out += SupportAttachment(
id = id,
filename = obj.optString("filename").ifBlank { "file" },
mimeType = obj.optString("mime_type"),
sizeBytes = obj.optLong("size_bytes", -1L).takeIf { it >= 0 },
)
}
return out
}
private fun messageIsOutgoing(config: SupportConfig, role: String, author: String): Boolean {
val r = role.lowercase()
val a = author.lowercase()
val u = config.username.lowercase()
if (config.isSupportAdmin && r == "support" && u.isNotEmpty() && a == u) return true
if (r == "client" || r == "user") return true
return u.isNotEmpty() && a == u
}
private fun supportSenderLabel(
config: SupportConfig,
role: String,
author: String,
outgoing: Boolean,
): String {
if (outgoing) return "Вы"
val name = author.trim()
if (name.isNotEmpty()) return name
return if (role.equals("support", true)) "Поддержка" else "Клиент"
}
private fun supportClient(): OkHttpClient = OkHttpClient.Builder().build()
private fun identityHeaders(config: SupportConfig): okhttp3.Headers {
val builder = okhttp3.Headers.Builder()
.add("X-F7cloud-User", config.username)
.add("X-F7cloud-Server", config.serverAddress)
if (config.isSupportAdmin) {
builder.add("X-F7cloud-Support-Admin", "1")
}
return builder.build()
}
private fun encodePath(segment: String): String {
return java.net.URLEncoder.encode(segment, Charsets.UTF_8.name())
}
private fun hostFromUrl(serverUrl: String): String {
return runCatching { URI(serverUrl.trimEnd('/')).host }.getOrNull().orEmpty()
}
companion object {
private const val MESSAGE_BODY_PLACEHOLDER = "."
private const val TICKET_CREATE_BODY_PLACEHOLDER = "."
}
}
data class SupportConfig(
val supportApiBase: String,
val serverAddress: String,
val clientReadReceipts: Boolean,
val isSupportAdmin: Boolean,
val username: String,
val serverUrl: String,
val appPassword: String,
val trustAllCerts: Boolean,
)
data class SupportTicket(
val ticketNumber: String,
val subject: String,
val status: String,
val preview: String,
val hasUnread: Boolean,
val activityAt: String,
val createdAt: String,
val bitrixDealId: Long? = null,
val assignedEmployee: String = "",
val clientUsername: String = "",
) {
fun displayNumber(): String = bitrixDealId?.toString() ?: ""
fun statusBucket(): SupportStatusBucket = when (status) {
"Закрыт" -> SupportStatusBucket.Closed
"В работе" -> SupportStatusBucket.Progress
"Новый" -> SupportStatusBucket.New
else -> SupportStatusBucket.New
}
}
enum class SupportStatusBucket(val title: String) {
New("Новые"),
Progress("В работе"),
Closed("Закрыт"),
}
data class SupportMessage(
val id: Long,
val text: String,
val createdAt: String,
val outgoing: Boolean,
val authorLabel: String,
val attachments: List<SupportAttachment> = emptyList(),
val read: Boolean = false,
)
data class SupportAttachment(
val id: Long,
val filename: String,
val mimeType: String,
val sizeBytes: Long? = null,
)
@@ -0,0 +1,163 @@
package ru.forbion.f7cloud.feature.f7support
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
@Composable
fun SupportScreen(
session: AuthSession,
modifier: Modifier = Modifier,
createRequest: Int = 0,
openTicketNumber: String? = null,
onOpenTicketConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: SupportViewModel = viewModel()
val state by vm.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val pickCreateFilesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.addCreateFiles(context, uris)
}
}
val pickChatFilesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.addChatFiles(context, uris)
}
}
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(createRequest) {
if (createRequest > 0) {
vm.setShowCreate(true)
}
}
LaunchedEffect(openTicketNumber) {
if (!openTicketNumber.isNullOrBlank()) {
vm.openTicketFromPush(session, openTicketNumber)
onOpenTicketConsumed()
}
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.snackbar) {
val msg = state.snackbar ?: return@LaunchedEffect
snackbarHostState.showSnackbar(msg)
vm.clearSnackbar()
}
LaunchedEffect(state.error) {
val err = state.error ?: return@LaunchedEffect
if (state.selectedTicket != null || state.showCreate || state.showComplaint) {
snackbarHostState.showSnackbar(err)
vm.clearError()
}
}
F7OverlayDismissHandler(
enabled = state.showComplaint,
onDismiss = { vm.setShowComplaint(false) },
)
F7OverlayDismissHandler(
enabled = state.selectedTicket != null,
onDismiss = { vm.closeTicket(session) },
)
F7OverlayDismissHandler(
enabled = state.showCreate,
onDismiss = { vm.setShowCreate(false) },
)
if (state.showCreate) {
SupportCreateDialog(
loading = state.loading,
pendingFiles = state.createPendingFiles,
onDismiss = { vm.setShowCreate(false) },
onPickFiles = { pickCreateFilesLauncher.launch(arrayOf("*/*")) },
onRemoveFile = vm::removeCreateFile,
onSubmit = { subject, body -> vm.createTicket(session, subject, body) },
)
}
state.selectedTicket?.let { ticket ->
SupportChatDialog(
session = session,
ticket = ticket,
messages = state.messages,
loading = state.loading && state.messages.isEmpty(),
sending = state.sending,
pendingFiles = state.chatPendingFiles,
downloadingAttachmentId = state.downloadingAttachmentId,
onDismiss = { vm.closeTicket(session) },
onSend = { text -> vm.sendMessage(session, text) },
onPickFiles = { pickChatFilesLauncher.launch(arrayOf("*/*")) },
onRemoveFile = vm::removeChatFile,
onAttachmentClick = { vm.downloadAttachment(context, it) },
onComplaintClick = { vm.setShowComplaint(true) },
)
}
if (state.showComplaint && state.selectedTicket != null) {
SupportComplaintDialog(
sending = state.complaintSending,
onDismiss = { vm.setShowComplaint(false) },
onSubmit = { vm.submitComplaint(it) },
)
}
Box(modifier = modifier.fillMaxSize()) {
F7ModuleScreen(
modifier = Modifier.fillMaxSize(),
loading = state.loading && state.tickets.isEmpty() && state.selectedTicket == null,
error = if (state.selectedTicket == null) state.error else null,
onRefresh = { vm.load(session) },
) {
if (state.selectedTicket == null) {
SupportHomeContent(
serverUrl = session.serverUrl,
isSupportAdmin = state.config?.isSupportAdmin == true,
ticketsByBucket = state.ticketsByBucket,
hasTickets = state.hasTickets,
onCreateClick = { vm.setShowCreate(true) },
onTicketClick = { vm.openTicket(session, it) },
modifier = Modifier.fillMaxSize(),
)
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
if (state.loading && state.tickets.isNotEmpty() && state.selectedTicket == null && !state.showCreate) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter),
color = F7Colors.Primary,
)
}
}
}
@@ -0,0 +1,384 @@
package ru.forbion.f7cloud.feature.f7support
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
data class SupportUiState(
val loading: Boolean = false,
val config: SupportConfig? = null,
val tickets: List<SupportTicket> = emptyList(),
val selectedTicket: SupportTicket? = null,
val messages: List<SupportMessage> = emptyList(),
val sending: Boolean = false,
val showCreate: Boolean = false,
val showComplaint: Boolean = false,
val complaintSending: Boolean = false,
val createPendingFiles: List<SupportPendingFile> = emptyList(),
val chatPendingFiles: List<SupportPendingFile> = emptyList(),
val downloadingAttachmentId: Long? = null,
val snackbar: String? = null,
val error: String? = null,
val unauthorized: Boolean = false,
) {
val ticketsByBucket: Map<SupportStatusBucket, List<SupportTicket>>
get() = SupportStatusBucket.entries.associateWith { bucket ->
tickets
.filter { it.statusBucket() == bucket }
.sortedByDescending { it.activityAt }
}
val hasTickets: Boolean get() = tickets.isNotEmpty()
}
class SupportViewModel(
private val repository: SupportRepository = SupportRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(SupportUiState())
val state: StateFlow<SupportUiState> = _state.asStateFlow()
private var cachedConfig: SupportConfig? = null
private var pollJob: Job? = null
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
val config = repository.loadConfig(session)
cachedConfig = config
val tickets = repository.listTickets(config)
config to tickets
}.onSuccess { (config, tickets) ->
_state.value = _state.value.copy(
loading = false,
config = config,
tickets = tickets,
error = null,
)
restartPolling(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun refreshTickets(session: AuthSession) {
val config = cachedConfig ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.listTickets(config) }
.onSuccess { tickets ->
val selected = _state.value.selectedTicket
val updatedSelected = selected?.let { sel ->
tickets.find { it.ticketNumber == sel.ticketNumber } ?: sel
}
_state.value = _state.value.copy(
tickets = tickets,
selectedTicket = updatedSelected,
)
}
}
}
fun openTicket(session: AuthSession, ticketNumber: String) {
val config = cachedConfig ?: return
val ticket = _state.value.tickets.find { it.ticketNumber == ticketNumber }
?: SupportTicket(
ticketNumber = ticketNumber,
subject = "",
status = "",
preview = "",
hasUnread = false,
activityAt = "",
createdAt = "",
)
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(
loading = true,
selectedTicket = ticket,
messages = emptyList(),
error = null,
)
runCatching {
repository.loadMessages(config, ticketNumber)
}.onSuccess { messages ->
repository.markRead(config, ticketNumber)
_state.value = _state.value.copy(
loading = false,
messages = messages,
tickets = _state.value.tickets.map {
if (it.ticketNumber == ticketNumber) it.copy(hasUnread = false) else it
},
)
restartPolling(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun closeTicket(session: AuthSession) {
_state.value = _state.value.copy(
selectedTicket = null,
messages = emptyList(),
showComplaint = false,
chatPendingFiles = emptyList(),
)
refreshTickets(session)
restartPolling(session)
}
fun sendMessage(session: AuthSession, text: String) {
val config = cachedConfig ?: return
val ticket = _state.value.selectedTicket ?: return
val trimmed = text.trim()
val files = _state.value.chatPendingFiles
if (trimmed.isEmpty() && files.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(sending = true, error = null)
runCatching {
repository.sendMessageWithAttachments(config, ticket.ticketNumber, trimmed, files)
}.onSuccess {
val messages = repository.loadMessages(config, ticket.ticketNumber)
_state.value = _state.value.copy(
sending = false,
messages = messages,
chatPendingFiles = emptyList(),
)
refreshTickets(session)
}.onFailure { t ->
_state.value = _state.value.copy(
sending = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun setShowCreate(show: Boolean) {
_state.value = _state.value.copy(
showCreate = show,
error = null,
createPendingFiles = if (show) _state.value.createPendingFiles else emptyList(),
)
}
fun setShowComplaint(show: Boolean) {
_state.value = _state.value.copy(showComplaint = show, error = null)
}
fun createTicket(session: AuthSession, subject: String, body: String) {
val config = cachedConfig ?: return
val files = _state.value.createPendingFiles
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
repository.createTicket(config, subject.trim(), body.trim(), files)
}.onSuccess { ticketNumber ->
val tickets = repository.listTickets(config)
_state.value = _state.value.copy(
loading = false,
showCreate = false,
createPendingFiles = emptyList(),
tickets = tickets,
)
openTicket(session, ticketNumber)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun addCreateFiles(context: Context, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { SupportFileIO.readUris(context, uris) }
.onSuccess { files ->
if (files.isEmpty()) return@onSuccess
_state.value = _state.value.copy(
createPendingFiles = _state.value.createPendingFiles + files,
)
}
.onFailure { t ->
_state.value = _state.value.copy(error = t.message)
}
}
}
fun removeCreateFile(localId: String) {
_state.value = _state.value.copy(
createPendingFiles = _state.value.createPendingFiles.filterNot { it.localId == localId },
)
}
fun addChatFiles(context: Context, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { SupportFileIO.readUris(context, uris) }
.onSuccess { files ->
if (files.isEmpty()) return@onSuccess
_state.value = _state.value.copy(
chatPendingFiles = _state.value.chatPendingFiles + files,
)
}
.onFailure { t ->
_state.value = _state.value.copy(error = t.message)
}
}
}
fun removeChatFile(localId: String) {
_state.value = _state.value.copy(
chatPendingFiles = _state.value.chatPendingFiles.filterNot { it.localId == localId },
)
}
fun downloadAttachment(
context: Context,
attachment: SupportAttachment,
) {
val config = cachedConfig ?: return
val ticket = _state.value.selectedTicket ?: return
if (_state.value.downloadingAttachmentId == attachment.id) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(downloadingAttachmentId = attachment.id)
runCatching {
repository.downloadAttachment(config, ticket.ticketNumber, attachment.id)
}.onSuccess { (bytes, mime) ->
SupportFileIO.openBytes(
context = context,
fileName = attachment.filename,
bytes = bytes,
mimeType = mime.ifBlank { attachment.mimeType },
)
_state.value = _state.value.copy(downloadingAttachmentId = null)
}.onFailure { t ->
_state.value = _state.value.copy(
downloadingAttachmentId = null,
error = t.message,
)
}
}
}
fun submitComplaint(text: String) {
val config = cachedConfig ?: return
val ticket = _state.value.selectedTicket ?: return
val trimmed = text.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(complaintSending = true, error = null)
runCatching {
repository.submitComplaint(
config = config,
ticketNumber = ticket.ticketNumber,
ticketSubject = ticket.subject,
text = trimmed,
)
}.onSuccess {
_state.value = _state.value.copy(
complaintSending = false,
showComplaint = false,
snackbar = "Жалоба отправлена",
)
}.onFailure { t ->
_state.value = _state.value.copy(
complaintSending = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun clearSnackbar() {
_state.value = _state.value.copy(snackbar = null)
}
fun clearError() {
_state.value = _state.value.copy(error = null)
}
fun openTicketFromPush(session: AuthSession, ticketNumber: String) {
if (cachedConfig == null) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadConfig(session) }
.onSuccess { config ->
cachedConfig = config
_state.value = _state.value.copy(config = config)
openTicket(session, ticketNumber)
}
}
} else {
openTicket(session, ticketNumber)
}
}
private fun restartPolling(session: AuthSession) {
pollJob?.cancel()
pollJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
delay(pollIntervalMs())
if (!AppForegroundTracker.isForeground) continue
val config = cachedConfig ?: continue
val selected = _state.value.selectedTicket
runCatching {
if (selected != null) {
val messages = repository.loadMessages(config, selected.ticketNumber)
val tickets = repository.listTickets(config)
Pair(messages, tickets)
} else {
Pair(null, repository.listTickets(config))
}
}.onSuccess { (messages, tickets) ->
if (selected != null && messages != null) {
val updatedSelected = tickets.find { it.ticketNumber == selected.ticketNumber }
_state.value = _state.value.copy(
messages = messages,
tickets = tickets,
selectedTicket = updatedSelected ?: selected,
)
} else {
_state.value = _state.value.copy(tickets = tickets)
}
}
}
}
}
private fun pollIntervalMs(): Long =
if (_state.value.selectedTicket != null) POLL_TICKET_MS else POLL_LIST_MS
override fun onCleared() {
pollJob?.cancel()
super.onCleared()
}
companion object {
private const val POLL_LIST_MS = 15_000L
private const val POLL_TICKET_MS = 8_000L
}
}
+45
View File
@@ -0,0 +1,45 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.files'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:database')
implementation project(':core:network')
implementation project(':core:designsystem')
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".ImageViewerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<provider
android:name=".F7FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/f7_file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,33 @@
package ru.forbion.f7cloud.feature.files
import android.webkit.MimeTypeMap
import java.util.Locale
object ArchiveFiles {
private val EXT = setOf(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "tbz", "xz", "txz",
"zst", "tzst", "lzma", "cab", "lz", "lzo",
)
fun isArchive(name: String): Boolean {
val lower = name.lowercase(Locale.ROOT)
val dot = lower.lastIndexOf('.')
if (dot == -1) return false
return lower.substring(dot + 1) in EXT
}
fun mimeType(name: String): String {
val ext = name.lowercase(Locale.ROOT).substringAfterLast('.', missingDelimiterValue = "")
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.let { return it }
return when (ext) {
"rar" -> "application/x-rar-compressed"
"7z" -> "application/x-7z-compressed"
"tar" -> "application/x-tar"
"gz", "tgz" -> "application/gzip"
"bz2", "tbz", "tbz2" -> "application/x-bzip2"
"xz", "txz" -> "application/x-xz"
"zip" -> "application/zip"
else -> "application/octet-stream"
}
}
}
@@ -0,0 +1,6 @@
package ru.forbion.f7cloud.feature.files
import androidx.core.content.FileProvider
/** Distinct FileProvider so manifest merger keeps F7 files separate from talk-android. */
class F7FileProvider : FileProvider()
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import okhttp3.Request
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.davFileUrl
import java.io.File
class FileDownloadRepository(private val context: Context) {
fun download(session: AuthSession, relativePath: String, fileName: String): File {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val url = davFileUrl(session.serverUrl, userId, relativePath)
val request = Request.Builder()
.url(url)
.header("Accept", "*/*")
.get()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось скачать файл (HTTP ${response.code})")
}
val dir = File(context.cacheDir, "f7_files").apply { mkdirs() }
val safeName = fileName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
val out = File(dir, safeName)
response.body!!.byteStream().use { input ->
out.outputStream().use { output -> input.copyTo(output) }
}
return out
}
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.files
object FileIcons {
/** Те же SVG, что отдаёт F7cloud в `iconUrl` виджетов (mimeTypeIcon). */
fun iconUrl(serverUrl: String, name: String, isDirectory: Boolean): String {
val base = "${serverUrl.trimEnd('/')}/core/img/filetypes"
val icon = if (isDirectory) "folder" else iconFileName(name)
return "$base/$icon.svg"
}
private fun iconFileName(name: String): String {
val ext = name.substringAfterLast('.', "").lowercase()
return when (ext) {
"doc", "docx", "dot", "dotx", "odt", "rtf" -> "x-office-document"
"xls", "xlsx", "xlsm", "xlt", "xltx", "ods", "csv" -> "x-office-spreadsheet"
"ppt", "pptx", "pot", "potx", "odp" -> "x-office-presentation"
"odg" -> "x-office-drawing"
"pdf" -> "application-pdf"
"jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "heic" -> "image"
"mp3", "wav", "ogg", "flac", "aac" -> "audio"
"mp4", "mkv", "avi", "mov", "webm" -> "video"
"zip", "rar", "7z", "tar", "gz" -> "package-x-generic"
"txt", "md" -> "text"
else -> "file"
}
}
}
@@ -0,0 +1,12 @@
package ru.forbion.f7cloud.feature.files
data class FileItem(
val name: String,
val isDirectory: Boolean,
val relativePath: String = name,
val fileId: Long? = null,
val lastModified: Long? = null,
val size: Long? = null,
val mimeType: String? = null,
val favorite: Boolean = false,
)
@@ -0,0 +1,6 @@
package ru.forbion.f7cloud.feature.files
sealed class FileOpenAction {
data class Image(val path: String, val title: String) : FileOpenAction()
data class External(val path: String, val mimeType: String, val title: String) : FileOpenAction()
}
@@ -0,0 +1,303 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.isOcsSuccess
import ru.forbion.f7cloud.core.network.ocsData
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonObject
class FilesApiRepository {
fun fetchUserConfig(session: AuthSession): FilesUserConfig {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/configs"
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Files config HTTP ${response.code}")
}
val data = JSONObject(response.body!!.string()).optJSONObject("data") ?: JSONObject()
return FilesUserConfig(
sortFavoritesFirst = data.optBoolean("sort_favorites_first", true),
sortFoldersFirst = data.optBoolean("sort_folders_first", true),
folderTree = data.optBoolean("folder_tree", true),
defaultView = data.optString("default_view", "files"),
showHidden = data.optBoolean("show_hidden", false),
showMimeColumn = data.optBoolean("show_mime_column", false),
showExtensions = data.optBoolean("show_files_extensions", true),
cropImagePreviews = data.optBoolean("crop_image_previews", true),
)
}
}
fun saveUserConfig(session: AuthSession, key: String, value: String) {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/config/$key"
val request = Request.Builder()
.url(url)
.put(value.toRequestBody("text/plain".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Files config save HTTP ${response.code}")
}
}
fun fetchStorageStats(session: AuthSession): FilesStorageStats {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/stats"
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return FilesStorageStats()
}
val data = JSONObject(response.body!!.string()).optJSONObject("data") ?: JSONObject()
return FilesStorageStats(
usedBytes = data.optLong("used", 0L),
totalBytes = data.optLong("total", 0L),
usedLabel = data.optString("usage", ""),
)
}
}
fun fetchFolderTree(session: AuthSession, path: String = "/", depth: Int = 2): List<FilesFolderTreeNode> {
val client = authedClient(session)
val encodedPath = java.net.URLEncoder.encode(path, Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files/api/v1/folder-tree" +
"?path=$encodedPath&depth=$depth&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return emptyList()
}
val root = parseJsonObject(response.body!!.string(), "дерево папок")
val data = root.optJSONObject("ocs")?.opt("data")
val nodes = when (data) {
is JSONArray -> data
else -> JSONArray()
}
return (0 until nodes.length()).mapNotNull { index ->
parseTreeNode(nodes.optJSONObject(index), "")
}
}
}
fun fetchRecentFiles(session: AuthSession): List<FileItem> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/index.php/apps/files/api/v1/recent/"
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Recent files HTTP ${response.code}")
}
val files = JSONObject(response.body!!.string()).optJSONArray("files") ?: JSONArray()
return (0 until files.length()).mapNotNull { index ->
val file = files.optJSONObject(index) ?: return@mapNotNull null
val name = file.optString("name").ifBlank { file.optString("basename") }
val path = file.optString("path", "/").trim('/')
val relative = if (path.isBlank()) name else "$path/$name".trim('/')
FileItem(
name = name,
isDirectory = file.optInt("type") == 2 || file.optString("type") == "dir",
relativePath = relative,
fileId = file.optLong("id").takeIf { it > 0L },
lastModified = file.optLong("mtime").takeIf { it > 0L }?.times(1000L),
size = file.optLong("size").takeIf { it >= 0L },
mimeType = file.optString("mimetype").ifBlank { null },
favorite = file.optBoolean("favorite"),
)
}
}
}
fun searchFiles(session: AuthSession, query: String): List<FilesSearchHit> {
if (query.isBlank()) return emptyList()
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(query, Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/search/providers/files/search" +
"?term=$encoded&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
return emptyList()
}
val data = parseJsonObject(response.body!!.string(), "поиск файлов").ocsData() ?: JSONObject()
val entries = data.optJSONArray("entries") ?: JSONArray()
return (0 until entries.length()).mapNotNull { index ->
val entry = entries.optJSONObject(index) ?: return@mapNotNull null
val title = entry.optString("title").ifBlank { entry.optString("name") }
val path = entry.optJSONObject("attributes")
?.optString("path")
?.trim('/')
.orEmpty()
val relative = when {
path.isBlank() -> title
path.endsWith(title) -> path
else -> "$path/$title".trim('/')
}
FilesSearchHit(
name = title,
path = relative,
isDirectory = entry.optString("type") == "folder",
fileId = entry.optLong("fileId").takeIf { it > 0L },
)
}
}
}
private fun parseTreeNode(json: JSONObject?, parentPath: String): FilesFolderTreeNode? {
if (json == null) return null
val basename = json.optString("basename").ifBlank { json.optString("displayName") }
if (basename.isBlank()) return null
val path = buildRelativePath(parentPath.trim('/'), basename)
val childrenJson = json.optJSONArray("children") ?: JSONArray()
val children = (0 until childrenJson.length()).mapNotNull { index ->
parseTreeNode(childrenJson.optJSONObject(index), path)
}
return FilesFolderTreeNode(
id = json.optLong("id"),
basename = basename,
displayName = json.optString("displayName", basename),
path = path,
children = children,
)
}
fun createPublicShareLink(session: AuthSession, path: String, password: String = ""): String {
val client = authedClient(session)
val normalizedPath = if (path.startsWith("/")) path else "/$path"
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files_sharing/api/v1/shares"
val body = FormBody.Builder()
.add("shareType", "3")
.add("path", normalizedPath)
.add("shareWith", password)
.build()
val request = Request.Builder().url(url).applyOcsJson().post(body).build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось создать ссылку (HTTP ${response.code})")
}
val root = parseJsonObject(response.body!!.string(), "создание ссылки")
val meta = root.ocsMeta()
if (!isOcsSuccess(meta)) {
error(meta?.optString("message") ?: "Не удалось создать ссылку")
}
val shareUrl = root.ocsData()?.optString("url").orEmpty()
if (shareUrl.isBlank()) error("Не удалось создать ссылку")
return shareUrl
}
}
fun fetchShares(session: AuthSession, relativePath: String): List<FileShareEntry> {
val client = authedClient(session)
val path = "/" + relativePath.trim('/')
val encodedPath = java.net.URLEncoder.encode(path, Charsets.UTF_8.name())
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files_sharing/api/v1/shares" +
"?path=$encodedPath&reshares=true&format=json"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) return emptyList()
val root = parseJsonObject(response.body!!.string(), "общий доступ")
val data = root.optJSONObject("ocs")?.opt("data")
val array = when (data) {
is JSONArray -> data
else -> JSONArray()
}
return (0 until array.length()).mapNotNull { index ->
val share = array.optJSONObject(index) ?: return@mapNotNull null
val shareType = share.optInt("share_type", -1)
val label = when (shareType) {
3 -> share.optString("note").ifBlank { "Ссылка" }
0 -> share.optString("share_with_displayname")
.ifBlank { share.optString("share_with") }
else -> share.optString("share_with_displayname")
.ifBlank { share.optString("uid_owner") }
.ifBlank { share.optString("share_with") }
}.ifBlank { "Общий доступ" }
FileShareEntry(
id = share.optLong("id"),
shareType = shareType,
label = label,
permissionsLabel = formatSharePermissions(share.optInt("permissions", 0)),
shareWith = share.optString("share_with"),
)
}
}
}
fun fetchFileActivity(session: AuthSession, fileId: Long): List<FileActivityEntry> {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/activity/api/v2/activity" +
"?format=json&object_type=files&object_id=$fileId&limit=50&sort=desc"
val request = Request.Builder().url(url).applyOcsJson().get().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code == 204 || !response.isSuccessful || response.body == null) return emptyList()
val root = parseJsonObject(response.body!!.string(), "события")
val data = root.optJSONObject("ocs")?.opt("data")
val array = when (data) {
is JSONArray -> data
else -> JSONArray()
}
return (0 until array.length()).mapNotNull { index ->
val activity = array.optJSONObject(index) ?: return@mapNotNull null
val message = activity.optString("message").ifBlank { activity.optString("subject") }
if (message.isBlank()) return@mapNotNull null
FileActivityEntry(
id = activity.optLong("activity_id"),
author = activity.optString("user").ifBlank { "Пользователь" },
message = message,
timestamp = parseActivityTimestamp(activity),
)
}
}
}
private fun parseActivityTimestamp(activity: JSONObject): Long {
val datetime = activity.optString("datetime")
if (datetime.isNotBlank()) {
runCatching {
java.time.Instant.parse(datetime).toEpochMilli()
}.getOrNull()?.let { return it }
}
return activity.optLong("timestamp", 0L).takeIf { it > 0L }?.times(1000L) ?: 0L
}
private fun formatSharePermissions(permissions: Int): String {
val read = permissions and 1 != 0
val update = permissions and 2 != 0
val create = permissions and 4 != 0
val delete = permissions and 8 != 0
val share = permissions and 16 != 0
return when {
update || create || delete -> "Для редактирования"
share && read -> "Для просмотра и обмена"
read -> "Для просмотра"
else -> "Ограниченный доступ"
}
}
private fun buildRelativePath(parent: String, name: String): String {
val base = parent.trim('/')
return if (base.isEmpty()) name else "$base/$name"
}
private fun authedClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
package ru.forbion.f7cloud.feature.files
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.designsystem.F7Colors
enum class FilesCreateAction {
UploadFiles,
UploadFolders,
NewFolder,
FileRequest,
NewDiagram,
NewBoard,
NewPresentation,
NewSpreadsheet,
NewDocument,
NewTextFile,
TemplateFolder,
FolderDescription,
}
@Composable
fun FilesCreateMenu(
serverUrl: String,
visible: Boolean,
onDismiss: () -> Unit,
onAction: (FilesCreateAction) -> Unit,
modifier: Modifier = Modifier,
bottomOffset: androidx.compose.ui.unit.Dp = 88.dp,
) {
if (!visible) return
val base = serverUrl.trimEnd('/')
Box(modifier = modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomOffset)
.background(Color.Black.copy(alpha = 0.18f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(start = 16.dp, end = 16.dp, bottom = bottomOffset)
.navigationBarsPadding()
.widthIn(max = 360.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.shadow(
elevation = 12.dp,
shape = RoundedCornerShape(16.dp),
spotColor = Color.Black.copy(alpha = 0.12f),
)
.clip(RoundedCornerShape(16.dp))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
FilesCreateSectionHeader("Загрузить с устройства")
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/upload-black.svg",
label = "Загрузить файлы",
onClick = { onDismiss(); onAction(FilesCreateAction.UploadFiles) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/folder-black.svg",
label = "Загрузить папки",
onClick = { onDismiss(); onAction(FilesCreateAction.UploadFolders) },
)
FilesCreateDivider()
FilesCreateSectionHeader("Создать новое")
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/add-folder-black.svg",
label = "Новая папка",
onClick = { onDismiss(); onAction(FilesCreateAction.NewFolder) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/plus-black.svg",
label = "Запрос на создание файла",
onClick = { onDismiss(); onAction(FilesCreateAction.FileRequest) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-presentation.svg",
label = "Новая диаграмма",
onClick = { onDismiss(); onAction(FilesCreateAction.NewDiagram) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/local-edit-black.svg",
label = "Новая доска",
onClick = { onDismiss(); onAction(FilesCreateAction.NewBoard) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-presentation.svg",
label = "Новая презентация",
onClick = { onDismiss(); onAction(FilesCreateAction.NewPresentation) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-sheet.svg",
label = "Новая таблица",
onClick = { onDismiss(); onAction(FilesCreateAction.NewSpreadsheet) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/file-doc-docx.svg",
label = "Новый документ",
onClick = { onDismiss(); onAction(FilesCreateAction.NewDocument) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/add-text-file-black.svg",
label = "Новый текстовый файл",
onClick = { onDismiss(); onAction(FilesCreateAction.NewTextFile) },
)
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/plus-black.svg",
label = "Создать папку шаблонов",
onClick = { onDismiss(); onAction(FilesCreateAction.TemplateFolder) },
)
FilesCreateDivider()
FilesCreateMenuItem(
iconUrl = "$base/themes/forbion/images/files/edit-pencil-black.svg",
label = "Добавить описание папки",
onClick = { onDismiss(); onAction(FilesCreateAction.FolderDescription) },
)
}
Box(
modifier = Modifier
.padding(top = 2.dp)
.size(width = 14.dp, height = 8.dp)
.clip(RoundedCornerShape(bottomStart = 2.dp, bottomEnd = 2.dp))
.background(F7Colors.SecondaryButtonBg)
.border(1.dp, F7Colors.Border, RoundedCornerShape(2.dp)),
)
}
}
}
@Composable
private fun FilesCreateSectionHeader(text: String) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
}
@Composable
private fun FilesCreateDivider() {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
color = F7Colors.Border,
)
}
@Composable
private fun FilesCreateMenuItem(
iconUrl: String,
label: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = iconUrl,
contentDescription = null,
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,148 @@
package ru.forbion.f7cloud.feature.files
enum class FilesBrowseMode {
AllFiles,
Personal,
Recent,
Favorites,
}
enum class FilesSortColumn {
Name,
Type,
Modified,
}
enum class FilesSortDirection {
Asc,
Desc,
}
data class FilesUserConfig(
val sortFavoritesFirst: Boolean = true,
val sortFoldersFirst: Boolean = true,
val folderTree: Boolean = true,
val defaultView: String = "files",
val showHidden: Boolean = false,
val showMimeColumn: Boolean = false,
val showExtensions: Boolean = true,
val cropImagePreviews: Boolean = true,
)
data class FilesStorageStats(
val usedBytes: Long = 0L,
val totalBytes: Long = 0L,
val usedLabel: String = "",
)
data class FilesFolderTreeNode(
val id: Long,
val basename: String,
val displayName: String,
val path: String,
val children: List<FilesFolderTreeNode> = emptyList(),
)
data class FilesSearchHit(
val name: String,
val path: String,
val isDirectory: Boolean,
val fileId: Long? = null,
)
enum class FileContextAction {
Favorite,
Details,
Sharing,
Tags,
Rename,
MoveCopy,
Reminder,
OpenLocally,
Download,
Archive,
Delete,
}
enum class FileDetailsTab {
Sharing,
Events,
}
data class FileContextMenuEntry(
val action: FileContextAction,
val label: String,
val iconPath: String,
val showChevron: Boolean = false,
val destructive: Boolean = false,
)
data class FileShareEntry(
val id: Long,
val shareType: Int,
val label: String,
val permissionsLabel: String,
val shareWith: String = "",
)
data class FileActivityEntry(
val id: Long,
val author: String,
val message: String,
val timestamp: Long,
)
data class FileDetailsData(
val shares: List<FileShareEntry> = emptyList(),
val activities: List<FileActivityEntry> = emptyList(),
val error: String? = null,
)
fun buildFileContextMenu(item: FileItem): List<FileContextMenuEntry> = buildList {
add(
FileContextMenuEntry(
action = FileContextAction.Favorite,
label = if (item.favorite) "Удалить из избранного" else "Добавить в избранное",
iconPath = if (item.favorite) "files/star-green-full.svg" else "files/star-gray.svg",
),
)
add(FileContextMenuEntry(FileContextAction.Details, "Подробно", "files/info-icon-black.svg"))
add(FileContextMenuEntry(FileContextAction.Sharing, "Варианты обмена", "files/button-shared-for-files.svg"))
add(FileContextMenuEntry(FileContextAction.Tags, "Управление метками", "files/tags-gray.svg"))
add(FileContextMenuEntry(FileContextAction.Rename, "Переименовать", "files/rename-pencil-gray.svg"))
add(FileContextMenuEntry(FileContextAction.MoveCopy, "Переместить или копировать", "files/copy-move-gray.svg"))
add(
FileContextMenuEntry(
action = FileContextAction.Reminder,
label = "Установить напоминание",
iconPath = "files/grid-files-gray.svg",
showChevron = true,
),
)
if (!item.isDirectory) {
add(FileContextMenuEntry(FileContextAction.OpenLocally, "Открыть локально", "files/local-edit-black.svg"))
add(FileContextMenuEntry(FileContextAction.Download, "Скачать", "files/download-icon-gray.svg"))
}
add(
FileContextMenuEntry(
action = FileContextAction.Archive,
label = "Архивировать в...",
iconPath = "files/folder-gray.svg",
showChevron = true,
),
)
add(
FileContextMenuEntry(
action = FileContextAction.Delete,
label = if (item.isDirectory) "Удалить папку" else "Удалить файл",
iconPath = "files/trash-gray.svg",
destructive = true,
),
)
}
enum class FilesBulkAction {
Favorite,
Download,
Delete,
}
@@ -0,0 +1,170 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.database.F7Database
import ru.forbion.f7cloud.core.database.FileEntity
import ru.forbion.f7cloud.core.network.DavClient
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
class FilesRepository(context: Context) {
private val db = F7Database.get(context)
suspend fun listFolder(session: AuthSession, relativePath: String = ""): List<FileItem> {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
val entries = DavClient.propfind(client, folderUrl)
val items = entries.map {
FileItem(
name = it.name,
isDirectory = it.isDirectory,
relativePath = buildRelativePath(relativePath, it.name),
fileId = it.fileId,
lastModified = it.lastModified,
size = it.size,
mimeType = it.mimeType,
favorite = it.favorite,
)
}
if (relativePath.isBlank()) {
val dao = db.filesDao()
dao.clear(session.serverUrl, session.username)
dao.insertAll(
items.map {
FileEntity(
serverUrl = session.serverUrl,
username = session.username,
name = it.name,
isDirectory = it.isDirectory,
)
},
)
}
return items
}
suspend fun createFolder(session: AuthSession, relativeFolderPath: String, folderName: String) {
ensureFolder(session, buildRelativePath(relativeFolderPath, folderName))
}
suspend fun ensureFolder(session: AuthSession, relativePath: String) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val folderUrl = davFolderUrl(session.serverUrl, userId, relativePath)
runCatching { DavClient.mkcol(client, folderUrl) }
}
suspend fun uploadFile(
session: AuthSession,
relativeFolderPath: String,
fileName: String,
bytes: ByteArray,
mimeType: String?,
) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val path = buildRelativePath(relativeFolderPath, fileName)
val fileUrl = davFileUrl(session.serverUrl, userId, path)
val mediaType = (mimeType?.takeIf { it.isNotBlank() } ?: "application/octet-stream")
.toMediaType()
DavClient.put(client, fileUrl, bytes.toRequestBody(mediaType))
}
suspend fun listCached(session: AuthSession): List<FileItem> {
return db.filesDao()
.list(session.serverUrl, session.username)
.map {
FileItem(
name = it.name,
isDirectory = it.isDirectory,
relativePath = it.name,
)
}
}
fun buildRelativePath(parent: String, name: String): String {
val base = parent.trim('/')
return if (base.isEmpty()) name else "$base/$name"
}
suspend fun deleteItem(session: AuthSession, relativePath: String, isDirectory: Boolean) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val url = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, relativePath)
} else {
davFileUrl(session.serverUrl, userId, relativePath)
}
DavClient.delete(client, url)
}
suspend fun renameItem(session: AuthSession, relativePath: String, newName: String, isDirectory: Boolean) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val parent = relativePath.substringBeforeLast('/', missingDelimiterValue = "")
val destinationPath = buildRelativePath(parent, newName)
val sourceUrl = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, relativePath)
} else {
davFileUrl(session.serverUrl, userId, relativePath)
}
val destinationUrl = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, destinationPath)
} else {
davFileUrl(session.serverUrl, userId, destinationPath)
}
DavClient.move(client, sourceUrl, destinationUrl)
}
suspend fun setFavorite(session: AuthSession, relativePath: String, favorite: Boolean, isDirectory: Boolean) {
val client = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
val url = if (isDirectory) {
davFolderUrl(session.serverUrl, userId, relativePath)
} else {
davFileUrl(session.serverUrl, userId, relativePath)
}
DavClient.setFavorite(client, url, favorite)
}
fun uniqueName(existingNames: Collection<String>, baseName: String, extension: String): String {
val ext = extension.takeIf { it.startsWith('.') } ?: ".$extension"
val stem = baseName.removeSuffix(ext).ifBlank { baseName }
val first = "$stem$ext"
if (first !in existingNames) return first
var index = 1
while ("$stem ($index)$ext" in existingNames) index++
return "$stem ($index)$ext"
}
}
@@ -0,0 +1,432 @@
package ru.forbion.f7cloud.feature.files
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7AlertDialog
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7OutlinedField
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilesScreen(
session: AuthSession,
modifier: Modifier = Modifier,
uploadRequest: Int = 0,
pushRefreshRequest: Int = 0,
openFileId: Long? = null,
sidebarOpen: Boolean = false,
onSidebarOpenChange: (Boolean) -> Unit = {},
settingsOpen: Boolean = false,
onSettingsOpenChange: (Boolean) -> Unit = {},
onOpenFileConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {},
) {
val context = LocalContext.current
val vm: FilesViewModel = viewModel(factory = FilesViewModel.Factory(context))
val state by vm.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showCreateMenu by remember { mutableStateOf(false) }
var showNewFolderDialog by remember { mutableStateOf(false) }
var newFolderName by remember { mutableStateOf("") }
var contextMenuItem by remember { mutableStateOf<FileItem?>(null) }
var contextMenuAnchor by remember { mutableStateOf<Rect?>(null) }
var bulkMenuAnchor by remember { mutableStateOf<Rect?>(null) }
var bulkMenuOpen by remember { mutableStateOf(false) }
var deleteConfirmItem by remember { mutableStateOf<FileItem?>(null) }
var deleteConfirmBulk by remember { mutableStateOf(false) }
F7OverlayDismissHandler(
enabled = state.detailsItem != null,
onDismiss = vm::hideDetails,
)
F7OverlayDismissHandler(
enabled = showCreateMenu,
onDismiss = { showCreateMenu = false },
)
F7OverlayDismissHandler(
enabled = contextMenuItem != null,
onDismiss = { contextMenuItem = null },
)
val uploadFilesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.uploadFromUris(context, session, uris)
}
}
val uploadFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
if (uri != null) {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
vm.uploadFolderTree(context, session, uri)
}
}
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
OfficeWarmup.warm(session)
vm.load(session)
vm.loadSidebarData(session)
}
LaunchedEffect(uploadRequest) {
if (uploadRequest > 0) showCreateMenu = true
}
LaunchedEffect(pushRefreshRequest) {
if (pushRefreshRequest > 0) vm.load(session)
}
LaunchedEffect(openFileId) {
val fileId = openFileId ?: return@LaunchedEffect
vm.openFileById(session, fileId)
onOpenFileConsumed()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.editorLaunch) {
val launch = state.editorLaunch ?: return@LaunchedEffect
onOpenOfficeEditor(launch)
vm.clearEditorLaunch()
}
LaunchedEffect(state.openAction) {
when (val action = state.openAction) {
null -> Unit
is FileOpenAction.Image -> {
context.startActivity(ImageViewerActivity.intent(context, action.path, action.title))
vm.clearOpenAction()
}
is FileOpenAction.External -> {
LocalFileOpener.openExternal(context, File(action.path), action.mimeType)
vm.clearOpenAction()
}
}
}
LaunchedEffect(state.snackbar) {
val msg = state.snackbar ?: return@LaunchedEffect
snackbarHostState.showSnackbar(msg)
vm.clearSnackbar()
}
val displayItems = vm.displayItems(state)
val pathLabel = when (state.browseMode) {
FilesBrowseMode.AllFiles -> if (state.currentPath.isBlank()) "Все файлы" else state.currentPath.replace("/", " ")
FilesBrowseMode.Personal -> "Личные файлы"
FilesBrowseMode.Recent -> "Недавно изменённые"
FilesBrowseMode.Favorites -> "Избранные"
}
val allSelected = displayItems.isNotEmpty() && displayItems.all { it.relativePath in state.selectedPaths }
val selectionMode = state.selectedPaths.isNotEmpty()
val isRefreshing = state.loading && displayItems.isNotEmpty()
val listState = rememberLazyListState()
Box(modifier = modifier.fillMaxSize()) {
F7ModuleScreen(
modifier = Modifier.fillMaxSize(),
loading = state.loading && displayItems.isEmpty(),
error = state.error,
headerActions = {},
) {
Column(modifier = Modifier.fillMaxSize()) {
if (state.currentPath.isNotBlank() && state.browseMode == FilesBrowseMode.AllFiles) {
F7SecondaryButton(
text = "Назад",
onClick = { vm.goUp(session) },
modifier = Modifier.padding(bottom = 8.dp),
)
}
Text(
text = pathLabel,
style = MaterialTheme.typography.titleSmall,
color = F7Colors.TextPrimary,
modifier = Modifier.padding(bottom = 8.dp),
)
FilesSearchBar(
serverUrl = session.serverUrl,
query = state.searchQuery,
onQueryChange = { vm.setSearchQuery(session, it) },
)
if (selectionMode) {
FilesBulkActionsBar(
serverUrl = session.serverUrl,
selectedCount = state.selectedPaths.size,
onActionsClick = { rect ->
bulkMenuAnchor = rect
bulkMenuOpen = true
},
)
} else {
FilesListHeader(
serverUrl = session.serverUrl,
showMimeColumn = state.userConfig.showMimeColumn,
sortColumn = state.sortColumn,
sortDirection = state.sortDirection,
selectionMode = false,
allSelected = allSelected,
onSelectAllToggle = {
if (allSelected) vm.clearSelection() else vm.selectAllVisible()
},
onSortColumnClick = vm::setSortColumn,
)
}
if (state.openingFile != null) {
CircularProgressIndicator(color = F7Colors.Primary)
Text(
text = "Открываем «${state.openingFile}»…",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshCurrent(session) },
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) {
Text(
text = if (state.searchQuery.isNotBlank()) "Ничего не найдено" else "Папка пуста",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
itemsIndexed(displayItems, key = { _, item -> item.relativePath }) { index, item ->
val openable = !item.isDirectory &&
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
FilesListRow(
serverUrl = session.serverUrl,
item = item,
selected = item.relativePath in state.selectedPaths,
showExtensions = state.userConfig.showExtensions,
onSelectedChange = { vm.toggleSelection(item.relativePath) },
onOpenClick = {
when {
item.isDirectory -> vm.openFolder(session, item)
openable -> vm.openItem(session, item)
}
},
onMenuClick = { rect ->
contextMenuItem = item
contextMenuAnchor = rect
},
)
if (index < displayItems.lastIndex) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
thickness = 1.dp,
color = F7Colors.Border.copy(alpha = 0.55f),
)
}
}
}
}
}
}
FilesNavigationSidebar(
serverUrl = session.serverUrl,
visible = sidebarOpen,
browseMode = state.browseMode,
storageStats = state.storageStats,
folderTree = state.folderTree,
expandedTreePaths = state.expandedTreePaths,
expandedSections = state.expandedSidebarSections,
onDismiss = { onSidebarOpenChange(false) },
onBrowseModeClick = { mode ->
vm.navigateBrowseMode(session, mode)
onSidebarOpenChange(false)
},
onFolderPathClick = { path ->
vm.navigateToPath(session, path)
onSidebarOpenChange(false)
},
onToggleTreePath = vm::toggleTreePath,
onToggleSection = vm::toggleSidebarSection,
onWebOnlyClick = { title ->
vm.showWebOnlyMessage(title)
onSidebarOpenChange(false)
},
)
FilesCreateMenu(
serverUrl = session.serverUrl,
visible = showCreateMenu,
onDismiss = { showCreateMenu = false },
onAction = { action ->
when (action) {
FilesCreateAction.UploadFiles -> uploadFilesLauncher.launch(arrayOf("*/*"))
FilesCreateAction.UploadFolders -> uploadFolderLauncher.launch(null)
FilesCreateAction.NewFolder -> {
newFolderName = ""
showNewFolderDialog = true
}
else -> vm.handleCreateAction(session, action)
}
},
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
FilesSettingsSheet(
visible = settingsOpen,
config = state.userConfig,
onDismiss = { onSettingsOpenChange(false) },
onToggle = { key, value -> vm.updateUserConfig(session, key, value) },
onDefaultViewChange = { vm.updateUserConfig(session, "default_view", it) },
)
FilesDetailsSheet(
serverUrl = session.serverUrl,
ownerLabel = session.username,
item = state.detailsItem,
tab = state.detailsTab,
details = state.fileDetails,
loading = state.detailsLoading,
onDismiss = vm::hideDetails,
onTabChange = vm::setDetailsTab,
onWebOnlyAction = vm::showWebOnlyMessage,
)
FilesRenameDialog(
item = state.renameItem,
onDismiss = vm::hideRename,
onConfirm = { newName ->
state.renameItem?.let { vm.renameItem(session, it, newName) }
},
)
val contextItem = contextMenuItem
if (contextItem != null) {
FileActionMenuPopup(
expanded = true,
serverUrl = session.serverUrl,
anchorBounds = contextMenuAnchor,
entries = buildFileContextMenu(contextItem),
onDismiss = { contextMenuItem = null },
onAction = { action ->
when (action) {
FileContextAction.Favorite -> vm.toggleFavorite(session, contextItem)
FileContextAction.Details -> vm.showDetails(session, contextItem)
FileContextAction.Sharing -> vm.showDetails(session, contextItem, FileDetailsTab.Sharing)
FileContextAction.Tags -> vm.showWebOnlyMessage("Управление метками")
FileContextAction.Rename -> vm.showRename(contextItem)
FileContextAction.MoveCopy -> vm.showWebOnlyMessage("Перемещение и копирование")
FileContextAction.Reminder -> vm.showWebOnlyMessage("Напоминания")
FileContextAction.OpenLocally -> vm.showWebOnlyMessage("Открытие локально")
FileContextAction.Download -> vm.downloadItem(session, contextItem, context)
FileContextAction.Archive -> vm.showWebOnlyMessage("Архивирование")
FileContextAction.Delete -> deleteConfirmItem = contextItem
}
},
)
}
FilesBulkActionMenuPopup(
expanded = bulkMenuOpen,
serverUrl = session.serverUrl,
anchorBounds = bulkMenuAnchor,
onDismiss = { bulkMenuOpen = false },
onAction = { action ->
when (action) {
FilesBulkAction.Favorite -> vm.favoriteSelected(session)
FilesBulkAction.Download -> vm.showWebOnlyMessage("Массовое скачивание")
FilesBulkAction.Delete -> deleteConfirmBulk = true
}
},
)
deleteConfirmItem?.let { item ->
F7AlertDialog(
title = if (item.isDirectory) "Удалить папку?" else "Удалить файл?",
onDismiss = { deleteConfirmItem = null },
confirmText = "Удалить",
onConfirm = {
deleteConfirmItem = null
vm.deleteItem(session, item)
},
) {
Text("«${item.name}» будет удалён.")
}
}
if (deleteConfirmBulk) {
F7AlertDialog(
title = "Удалить выбранные элементы?",
onDismiss = { deleteConfirmBulk = false },
confirmText = "Удалить",
onConfirm = {
deleteConfirmBulk = false
vm.deleteSelected(session)
},
) {
Text("Будет удалено элементов: ${state.selectedPaths.size}")
}
}
if (showNewFolderDialog) {
F7AlertDialog(
title = "Новая папка",
onDismiss = { showNewFolderDialog = false },
confirmText = "Создать",
onConfirm = {
showNewFolderDialog = false
vm.createFolder(session, newFolderName)
},
confirmEnabled = newFolderName.trim().isNotBlank(),
) {
F7OutlinedField(
value = newFolderName,
onValueChange = { newFolderName = it },
label = "Имя папки",
)
}
}
}
@@ -0,0 +1,105 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.FormBody
import okhttp3.Request
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class FilesTemplatesRepository {
data class CreatedFile(
val fileId: Long,
val name: String,
val relativePath: String,
)
fun createFromTemplate(session: AuthSession, relativeFilePath: String): CreatedFile {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files/api/v1/templates/create?format=json"
val body = FormBody.Builder()
.add("filePath", relativeFilePath)
.add("templatePath", "")
.add("templateType", "user")
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data ->
CreatedFile(
fileId = data.optLong("fileid"),
name = data.optString("basename"),
relativePath = data.optString("filename"),
)
}
}
}
fun initializeTemplateDirectory(session: AuthSession): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files/api/v1/templates/path?format=json"
val body = FormBody.Builder()
.add("templatePath", "")
.add("copySystemTemplates", "true")
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data ->
data.optString("template_path")
}
}
}
fun openFolderDescription(session: AuthSession, relativeFolderPath: String): String {
val client = authedClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/text/api/v1/workspace/direct?format=json"
val body = FormBody.Builder()
.add("path", relativeFolderPath.ifBlank { "/" })
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
parseOcsData(response) { data ->
data.optString("url").ifBlank {
error("Не удалось открыть описание папки")
}
}
}
}
private fun <T> parseOcsData(response: okhttp3.Response, block: (JSONObject) -> T): T {
if (!response.isSuccessful || response.body == null) {
error("HTTP ${response.code}")
}
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
?: error("Некорректный ответ сервера")
val meta = ocs.optJSONObject("meta")
if (meta?.optString("status").equals("failure", ignoreCase = true)) {
error(meta?.optString("message").orEmpty().ifBlank { "Ошибка сервера" })
}
val data = ocs.optJSONObject("data") ?: JSONObject()
return block(data)
}
private fun authedClient(session: AuthSession) = NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
@@ -0,0 +1,40 @@
package ru.forbion.f7cloud.feature.files
data class OfficeEditorLaunch(
val url: String,
val title: String,
val username: String,
val password: String,
val trustAllCerts: Boolean,
val serverUrl: String = "",
val collaboraBaseUrl: String = "",
)
data class FilesUiState(
val loading: Boolean = false,
val items: List<FileItem> = emptyList(),
val currentPath: String = "",
val browseMode: FilesBrowseMode = FilesBrowseMode.AllFiles,
val error: String? = null,
val unauthorized: Boolean = false,
val openingFile: String? = null,
val editorLaunch: OfficeEditorLaunch? = null,
val openAction: FileOpenAction? = null,
val searchQuery: String = "",
val searchResults: List<FilesSearchHit> = emptyList(),
val searchActive: Boolean = false,
val selectedPaths: Set<String> = emptySet(),
val sortColumn: FilesSortColumn = FilesSortColumn.Name,
val sortDirection: FilesSortDirection = FilesSortDirection.Asc,
val userConfig: FilesUserConfig = FilesUserConfig(),
val storageStats: FilesStorageStats = FilesStorageStats(),
val folderTree: List<FilesFolderTreeNode> = emptyList(),
val expandedTreePaths: Set<String> = emptySet(),
val expandedSidebarSections: Set<String> = setOf("sharing"),
val detailsItem: FileItem? = null,
val detailsTab: FileDetailsTab = FileDetailsTab.Sharing,
val fileDetails: FileDetailsData? = null,
val detailsLoading: Boolean = false,
val renameItem: FileItem? = null,
val snackbar: String? = null,
)
@@ -0,0 +1,814 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
class FilesViewModel(
private val repository: FilesRepository,
private val downloadRepository: FileDownloadRepository,
private val apiRepository: FilesApiRepository = FilesApiRepository(),
private val templatesRepository: FilesTemplatesRepository = FilesTemplatesRepository(),
private val officeFileOpener: OfficeFileOpener = OfficeFileOpener(),
) : ViewModel() {
private val _state = MutableStateFlow(FilesUiState())
val state: StateFlow<FilesUiState> = _state.asStateFlow()
private var searchJob: Job? = null
fun load(session: AuthSession, path: String = _state.value.currentPath) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(
loading = true,
error = null,
currentPath = path,
browseMode = FilesBrowseMode.AllFiles,
selectedPaths = emptySet(),
)
runCatching { repository.listFolder(session, path) }
.onSuccess { items ->
_state.value = _state.value.copy(
loading = false,
items = applySorting(items, _state.value),
currentPath = path,
error = null,
)
}
.onFailure { t ->
val cached = if (path.isBlank()) {
runCatching { repository.listCached(session) }.getOrDefault(emptyList())
} else {
emptyList()
}
_state.value = _state.value.copy(
loading = false,
items = applySorting(cached, _state.value),
currentPath = path,
error = t.message ?: "Не удалось загрузить файлы",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun loadSidebarData(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
val config = apiRepository.fetchUserConfig(session)
val stats = apiRepository.fetchStorageStats(session)
val tree = if (config.folderTree) {
apiRepository.fetchFolderTree(session, depth = 2)
} else {
emptyList()
}
_state.value = _state.value.copy(
userConfig = config,
storageStats = stats,
folderTree = tree,
items = applySorting(_state.value.items, _state.value.copy(userConfig = config)),
)
}
}
}
fun setSearchActive(active: Boolean) {
_state.value = _state.value.copy(searchActive = active)
if (!active) {
_state.value = _state.value.copy(searchQuery = "", searchResults = emptyList())
}
}
fun setSearchQuery(session: AuthSession, query: String) {
_state.value = _state.value.copy(searchQuery = query)
searchJob?.cancel()
if (query.isBlank()) {
_state.value = _state.value.copy(searchResults = emptyList())
return
}
searchJob = viewModelScope.launch(Dispatchers.IO) {
delay(300)
runCatching { apiRepository.searchFiles(session, query) }
.onSuccess { hits ->
if (_state.value.searchQuery == query) {
_state.value = _state.value.copy(searchResults = hits)
}
}
.onFailure { t ->
_state.value = _state.value.copy(
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun navigateBrowseMode(session: AuthSession, mode: FilesBrowseMode) {
when (mode) {
FilesBrowseMode.AllFiles -> load(session, "")
FilesBrowseMode.Personal -> load(session, "")
FilesBrowseMode.Recent -> loadRecent(session)
FilesBrowseMode.Favorites -> loadFavorites(session)
}
}
private fun loadRecent(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null, selectedPaths = emptySet())
runCatching { apiRepository.fetchRecentFiles(session) }
.onSuccess { items ->
_state.value = _state.value.copy(
loading = false,
items = applySorting(items, _state.value),
currentPath = "",
browseMode = FilesBrowseMode.Recent,
)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить недавние файлы",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun loadFavorites(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null, selectedPaths = emptySet())
runCatching {
repository.listFolder(session, "").filter { it.favorite }
}.onSuccess { items ->
_state.value = _state.value.copy(
loading = false,
items = applySorting(items, _state.value),
currentPath = "",
browseMode = FilesBrowseMode.Favorites,
)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить избранное",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun navigateToPath(session: AuthSession, path: String) {
load(session, path)
}
fun toggleSelection(path: String) {
val selected = _state.value.selectedPaths.toMutableSet()
if (path in selected) selected.remove(path) else selected.add(path)
_state.value = _state.value.copy(selectedPaths = selected)
}
fun selectAllVisible() {
val paths = displayItems(_state.value).map { it.relativePath }.toSet()
_state.value = _state.value.copy(selectedPaths = paths)
}
fun clearSelection() {
_state.value = _state.value.copy(selectedPaths = emptySet())
}
fun setSortColumn(column: FilesSortColumn) {
val current = _state.value
val direction = if (current.sortColumn == column && current.sortDirection == FilesSortDirection.Asc) {
FilesSortDirection.Desc
} else {
FilesSortDirection.Asc
}
_state.value = current.copy(
sortColumn = column,
sortDirection = direction,
items = applySorting(current.items, current.copy(sortColumn = column, sortDirection = direction)),
)
}
fun toggleTreePath(path: String) {
val expanded = _state.value.expandedTreePaths.toMutableSet()
if (path in expanded) expanded.remove(path) else expanded.add(path)
_state.value = _state.value.copy(expandedTreePaths = expanded)
}
fun toggleSidebarSection(sectionId: String) {
val expanded = _state.value.expandedSidebarSections.toMutableSet()
if (sectionId in expanded) expanded.remove(sectionId) else expanded.add(sectionId)
_state.value = _state.value.copy(expandedSidebarSections = expanded)
}
fun refreshCurrent(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
loadSidebarData(session)
reloadCurrent(session)
}
}
fun showDetails(session: AuthSession, item: FileItem, tab: FileDetailsTab = FileDetailsTab.Sharing) {
_state.value = _state.value.copy(
detailsItem = item,
detailsTab = tab,
fileDetails = null,
detailsLoading = true,
)
loadFileDetails(session, item)
}
fun setDetailsTab(tab: FileDetailsTab) {
_state.value = _state.value.copy(detailsTab = tab)
}
fun hideDetails() {
_state.value = _state.value.copy(
detailsItem = null,
fileDetails = null,
detailsLoading = false,
)
}
private fun loadFileDetails(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
val details = runCatching {
FileDetailsData(
shares = apiRepository.fetchShares(session, item.relativePath),
activities = item.fileId?.let { apiRepository.fetchFileActivity(session, it) }.orEmpty(),
)
}.getOrElse { FileDetailsData(error = it.message) }
if (_state.value.detailsItem?.relativePath == item.relativePath) {
_state.value = _state.value.copy(
fileDetails = details,
detailsLoading = false,
)
}
}
}
fun showRename(item: FileItem) {
_state.value = _state.value.copy(renameItem = item)
}
fun hideRename() {
_state.value = _state.value.copy(renameItem = null)
}
fun renameItem(session: AuthSession, item: FileItem, newName: String) {
val trimmed = newName.trim()
if (trimmed.isBlank() || trimmed == item.name) {
hideRename()
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, renameItem = null)
runCatching {
repository.renameItem(session, item.relativePath, trimmed, item.isDirectory)
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось переименовать",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun deleteItem(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true)
runCatching {
repository.deleteItem(session, item.relativePath, item.isDirectory)
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось удалить",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun deleteSelected(session: AuthSession) {
val items = displayItems(_state.value).filter { it.relativePath in _state.value.selectedPaths }
if (items.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true)
runCatching {
items.forEach { item ->
repository.deleteItem(session, item.relativePath, item.isDirectory)
}
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось удалить выбранные элементы",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun toggleFavorite(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
runCatching {
repository.setFavorite(session, item.relativePath, !item.favorite, item.isDirectory)
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
snackbar = t.message ?: "Не удалось изменить избранное",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun favoriteSelected(session: AuthSession) {
val items = displayItems(_state.value).filter { it.relativePath in _state.value.selectedPaths }
viewModelScope.launch(Dispatchers.IO) {
runCatching {
items.filter { !it.favorite }.forEach { item ->
repository.setFavorite(session, item.relativePath, true, item.isDirectory)
}
}.onSuccess {
reloadCurrent(session)
}.onFailure { t ->
_state.value = _state.value.copy(
snackbar = t.message ?: "Не удалось добавить в избранное",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun downloadItem(session: AuthSession, item: FileItem, context: Context) {
if (item.isDirectory) {
_state.value = _state.value.copy(snackbar = "Скачивание папок пока доступно в веб-версии")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(
openingFile = null,
openAction = FileOpenAction.External(
path = file.absolutePath,
mimeType = item.mimeType ?: "application/octet-stream",
title = item.name,
),
)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось скачать файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun updateUserConfig(session: AuthSession, key: String, value: String) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { apiRepository.saveUserConfig(session, key, value) }
.onSuccess { loadSidebarData(session) }
.onFailure { t ->
_state.value = _state.value.copy(
snackbar = t.message ?: "Не удалось сохранить настройку",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun showWebOnlyMessage(message: String) {
_state.value = _state.value.copy(snackbar = "$message — доступно в веб-версии")
}
fun clearSnackbar() {
_state.value = _state.value.copy(snackbar = null)
}
fun displayItems(state: FilesUiState = _state.value): List<FileItem> {
if (state.searchQuery.isNotBlank()) {
return state.searchResults.map { hit ->
FileItem(
name = hit.name,
isDirectory = hit.isDirectory,
relativePath = hit.path,
fileId = hit.fileId,
)
}
}
return state.items
}
private fun reloadCurrent(session: AuthSession) {
when (_state.value.browseMode) {
FilesBrowseMode.Recent -> loadRecent(session)
FilesBrowseMode.Favorites -> loadFavorites(session)
else -> load(session, _state.value.currentPath)
}
}
private fun applySorting(items: List<FileItem>, state: FilesUiState): List<FileItem> {
var filtered = items
if (!state.userConfig.showHidden) {
filtered = filtered.filter { !it.name.startsWith('.') }
}
val direction = state.sortDirection
val sorted = when (state.sortColumn) {
FilesSortColumn.Name -> filtered.sortedBy { it.name.lowercase() }
FilesSortColumn.Type -> filtered.sortedBy { typeKey(it) }
FilesSortColumn.Modified -> filtered.sortedBy { it.lastModified ?: 0L }
}
val withFolders = if (state.userConfig.sortFoldersFirst) {
sorted.sortedByDescending { it.isDirectory }
} else {
sorted
}
val withFavorites = if (state.userConfig.sortFavoritesFirst) {
withFolders.sortedByDescending { it.favorite }
} else {
withFolders
}
return if (direction == FilesSortDirection.Desc) withFavorites.reversed() else withFavorites
}
private fun typeKey(item: FileItem): String = when {
item.isDirectory -> "0_folder"
else -> item.mimeType ?: item.name.substringAfterLast('.', "file")
}
// --- existing methods below ---
fun openFileById(session: AuthSession, fileId: Long, title: String = "Файл") {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = title, error = null)
runCatching { officeFileOpener.prepareLaunch(session, fileId, title) }
.onSuccess { launch ->
_state.value = _state.value.copy(openingFile = null, editorLaunch = launch)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun openFolder(session: AuthSession, item: FileItem) {
if (!item.isDirectory) return
load(session, item.relativePath)
}
fun openItem(session: AuthSession, item: FileItem) {
if (item.isDirectory) return
when {
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
else -> _state.value = _state.value.copy(
error = "Этот тип файла пока не поддерживается",
)
}
}
private fun openOfficeFile(session: AuthSession, item: FileItem) {
val fileId = item.fileId ?: run {
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { officeFileOpener.prepareLaunch(session, fileId, item.name) }
.onSuccess { launch ->
_state.value = _state.value.copy(openingFile = null, editorLaunch = launch)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть документ",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openArchiveFile(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(
openingFile = null,
openAction = FileOpenAction.External(
path = file.absolutePath,
mimeType = ArchiveFiles.mimeType(item.name),
title = item.name,
),
)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось скачать архив",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openImageFile(session: AuthSession, item: FileItem) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = item.name, error = null)
runCatching { downloadRepository.download(session, item.relativePath, item.name) }
.onSuccess { file ->
_state.value = _state.value.copy(
openingFile = null,
openAction = FileOpenAction.Image(file.absolutePath, item.name),
)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun clearEditorLaunch() {
_state.value = _state.value.copy(editorLaunch = null)
}
fun clearOpenAction() {
_state.value = _state.value.copy(openAction = null)
}
fun uploadFromUris(context: Context, session: AuthSession, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
val path = _state.value.currentPath
runCatching {
uris.forEach { uri ->
val (name, bytes, mime) = readUriPayload(context, uri)
repository.uploadFile(session, path, name, bytes, mime)
}
}.onSuccess {
load(session, path)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить файлы",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun uploadFolderTree(context: Context, session: AuthSession, treeUri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
val basePath = _state.value.currentPath
runCatching {
val root = DocumentFile.fromTreeUri(context, treeUri)
?: error("Не удалось открыть папку")
uploadDocumentNode(context, session, root, basePath, preserveRoot = true)
}.onSuccess {
load(session, basePath)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось загрузить папку",
unauthorized = t is UnauthorizedException,
)
}
}
}
private suspend fun uploadDocumentNode(
context: Context,
session: AuthSession,
node: DocumentFile,
parentPath: String,
preserveRoot: Boolean = false,
) {
if (node.isDirectory) {
val folderName = node.name?.takeIf { it.isNotBlank() } ?: return
val nextPath = if (preserveRoot) {
parentPath
} else {
val built = repository.buildRelativePath(parentPath, folderName)
repository.ensureFolder(session, built)
built
}
node.listFiles().forEach { child ->
uploadDocumentNode(context, session, child, nextPath)
}
return
}
val name = node.name?.takeIf { it.isNotBlank() } ?: return
val uri = node.uri
val (resolvedName, bytes, mime) = readUriPayload(context, uri, fallbackName = name)
repository.uploadFile(session, parentPath, resolvedName, bytes, mime)
}
fun createFolder(session: AuthSession, folderName: String) {
val trimmed = folderName.trim()
if (trimmed.isBlank()) {
_state.value = _state.value.copy(error = "Введите имя папки")
return
}
viewModelScope.launch(Dispatchers.IO) {
val path = _state.value.currentPath
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
repository.createFolder(session, path, trimmed)
}.onSuccess {
load(session, path)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось создать папку",
unauthorized = t is UnauthorizedException,
)
}
}
}
fun handleCreateAction(session: AuthSession, action: FilesCreateAction) {
when (action) {
FilesCreateAction.UploadFiles,
FilesCreateAction.UploadFolders,
FilesCreateAction.NewFolder,
-> Unit
FilesCreateAction.FileRequest -> {
_state.value = _state.value.copy(
error = "Запрос на создание файла пока доступен в веб-версии",
)
}
FilesCreateAction.NewDiagram -> createTemplateFile(session, "Новая диаграмма", ".odg", openAfterCreate = true)
FilesCreateAction.NewBoard -> createTemplateFile(session, "Новая доска", ".whiteboard", openAfterCreate = false)
FilesCreateAction.NewPresentation -> createTemplateFile(session, "Новая презентация", ".pptx", openAfterCreate = true)
FilesCreateAction.NewSpreadsheet -> createTemplateFile(session, "Новая таблица", ".xlsx", openAfterCreate = true)
FilesCreateAction.NewDocument -> createTemplateFile(session, "Новый документ", ".docx", openAfterCreate = true)
FilesCreateAction.NewTextFile -> createTemplateFile(session, "Новый текстовый файл", ".txt", openAfterCreate = false)
FilesCreateAction.TemplateFolder -> initializeTemplateFolder(session)
FilesCreateAction.FolderDescription -> openFolderDescription(session)
}
}
private fun createTemplateFile(
session: AuthSession,
baseName: String,
extension: String,
openAfterCreate: Boolean,
) {
viewModelScope.launch(Dispatchers.IO) {
val folderPath = _state.value.currentPath
val existing = _state.value.items.map { it.name }.toSet()
val fileName = repository.uniqueName(existing, baseName, extension)
val relativeFilePath = repository.buildRelativePath(folderPath, fileName)
_state.value = _state.value.copy(loading = true, error = null, openingFile = fileName)
runCatching {
templatesRepository.createFromTemplate(session, relativeFilePath)
}.onSuccess { created ->
load(session, folderPath)
if (openAfterCreate && created.fileId > 0L) {
runCatching { officeFileOpener.prepareLaunch(session, created.fileId, created.name) }
.onSuccess { launch ->
_state.value = _state.value.copy(openingFile = null, editorLaunch = launch)
}
.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Файл создан, но не удалось открыть редактор",
)
}
} else {
_state.value = _state.value.copy(openingFile = null)
}
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
openingFile = null,
error = t.message ?: "Не удалось создать файл",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun initializeTemplateFolder(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
templatesRepository.initializeTemplateDirectory(session)
}.onSuccess {
load(session, _state.value.currentPath)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось создать папку шаблонов",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun openFolderDescription(session: AuthSession) {
val folderPath = _state.value.currentPath
if (folderPath.isBlank()) {
_state.value = _state.value.copy(error = "Откройте папку, чтобы добавить описание")
return
}
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(openingFile = "Описание папки", error = null)
runCatching {
val url = templatesRepository.openFolderDescription(session, folderPath)
val collaboraBaseUrl = OfficeWarmup.getCachedCollaboraUrl(session.serverUrl)
.ifBlank { RichdocumentsRepository().fetchCollaboraPublicUrl(session) }
OfficeEditorLaunch(
url = url,
title = "Описание папки",
username = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
serverUrl = session.serverUrl,
collaboraBaseUrl = collaboraBaseUrl,
)
}.onSuccess { launch ->
_state.value = _state.value.copy(openingFile = null, editorLaunch = launch)
}.onFailure { t ->
_state.value = _state.value.copy(
openingFile = null,
error = t.message ?: "Не удалось открыть описание папки",
unauthorized = t is UnauthorizedException,
)
}
}
}
private fun readUriPayload(
context: Context,
uri: Uri,
fallbackName: String = "upload.bin",
): Triple<String, ByteArray, String?> {
val resolver = context.contentResolver
var name = fallbackName
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
}
}
}
val mime = resolver.getType(uri)
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
return Triple(name, bytes, mime)
}
fun goUp(session: AuthSession) {
val path = _state.value.currentPath.trim('/')
if (path.isEmpty()) return
val parent = path.substringBeforeLast('/', missingDelimiterValue = "")
load(session, parent)
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val appContext = context.applicationContext
return FilesViewModel(
FilesRepository(appContext),
FileDownloadRepository(appContext),
) as T
}
}
}
@@ -0,0 +1,98 @@
package ru.forbion.f7cloud.feature.files
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.designsystem.F7Theme
import java.io.File
class ImageViewerActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val path = intent.getStringExtra(EXTRA_PATH).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
if (path.isBlank()) {
finish()
return
}
setContent {
F7Theme {
ImageViewerScreen(
path = path,
title = title,
onClose = { finish() },
)
}
}
}
companion object {
private const val EXTRA_PATH = "path"
private const val EXTRA_TITLE = "title"
fun intent(context: Context, path: String, title: String): Intent =
Intent(context, ImageViewerActivity::class.java).apply {
putExtra(EXTRA_PATH, path)
putExtra(EXTRA_TITLE, title)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ImageViewerScreen(
path: String,
title: String,
onClose: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
) {
AsyncImage(
model = File(path),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
)
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Text("", color = Color.White, modifier = Modifier.padding(8.dp))
}
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.files
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
object LocalFileOpener {
fun openExternal(context: Context, file: File, mimeType: String) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(Intent.createChooser(intent, "Открыть с помощью"))
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "Нет приложения для открытия этого файла", Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.feature.files
import java.net.URI
object OfficeFileLinks {
fun parseFileId(link: String, serverUrl: String): Long? {
if (link.isBlank()) return null
val base = serverUrl.trimEnd('/')
val candidates = listOf(
Regex("""${Regex.escape(base)}/index\.php/f/(\d+)""", RegexOption.IGNORE_CASE),
Regex("""${Regex.escape(base)}/f/(\d+)""", RegexOption.IGNORE_CASE),
Regex("""/index\.php/f/(\d+)""", RegexOption.IGNORE_CASE),
Regex("""/f/(\d+)""", RegexOption.IGNORE_CASE),
)
for (pattern in candidates) {
val match = pattern.find(link) ?: continue
return match.groupValues[1].toLongOrNull()
}
return runCatching {
val uri = URI(link)
val path = uri.path.orEmpty()
Regex("""/f/(\d+)""").find(path)?.groupValues?.get(1)?.toLongOrNull()
}.getOrNull()
}
fun titleFromLink(link: String, fallback: String): String {
return fallback.ifBlank {
link.substringAfterLast('/').substringBefore('?').ifBlank { "Документ" }
}
}
}
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.files
import ru.forbion.f7cloud.core.auth.AuthSession
class OfficeFileOpener(
private val richdocumentsRepository: RichdocumentsRepository = RichdocumentsRepository(),
) {
fun prepareLaunch(session: AuthSession, fileId: Long, title: String): OfficeEditorLaunch {
val collaboraBaseUrl = OfficeWarmup.getCachedCollaboraUrl(session.serverUrl)
.ifBlank { richdocumentsRepository.fetchCollaboraPublicUrl(session) }
val url = richdocumentsRepository.createDirectUrl(session, fileId)
return OfficeEditorLaunch(
url = url,
title = title,
username = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
serverUrl = session.serverUrl,
collaboraBaseUrl = collaboraBaseUrl,
)
}
fun prepareLaunchFromLink(session: AuthSession, link: String, title: String): OfficeEditorLaunch {
val fileId = OfficeFileLinks.parseFileId(link, session.serverUrl)
?: error("Не удалось определить файл по ссылке")
return prepareLaunch(session, fileId, OfficeFileLinks.titleFromLink(link, title))
}
}
@@ -0,0 +1,16 @@
package ru.forbion.f7cloud.feature.files
object OfficeFiles {
private val EXTENSIONS = setOf(
"doc", "docx", "dot", "dotx",
"xls", "xlsx", "xlsm", "xlt", "xltx",
"ppt", "pptx", "pot", "potx",
"odt", "ods", "odp", "odg",
"csv", "rtf",
)
fun isOfficeFile(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase()
return ext in EXTENSIONS
}
}
@@ -0,0 +1,92 @@
package ru.forbion.f7cloud.feature.files
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Request
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
/**
* Прогрев Collabora / Richdocuments: кэш public WOPI URL и «холодный» TCP/HTTP к серверу офиса.
* Сам документ каждый раз открывается по новой direct-ссылке (одноразовый токен F7cloud).
*/
object OfficeWarmup {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
@Volatile
private var cachedServerUrl: String? = null
@Volatile
private var cachedCollaboraUrl: String? = null
@Volatile
private var lastWarmupAtMs: Long = 0L
private const val WARMUP_INTERVAL_MS = 10 * 60 * 1000L
fun getCachedCollaboraUrl(serverUrl: String): String {
if (cachedServerUrl == serverUrl.trimEnd('/')) {
return cachedCollaboraUrl.orEmpty()
}
return ""
}
/**
* Вызывать при входе в «Файлы» или после логина — не блокирует UI.
*/
fun warm(session: AuthSession, repository: RichdocumentsRepository = RichdocumentsRepository()) {
val serverKey = session.serverUrl.trimEnd('/')
val now = System.currentTimeMillis()
if (cachedServerUrl == serverKey &&
cachedCollaboraUrl?.isNotBlank() == true &&
now - lastWarmupAtMs < WARMUP_INTERVAL_MS
) {
return
}
scope.launch {
mutex.withLock {
runCatching {
val collabora = repository.fetchCollaboraPublicUrl(session).trim().trimEnd('/')
if (collabora.isNotBlank()) {
cachedServerUrl = serverKey
cachedCollaboraUrl = collabora
lastWarmupAtMs = System.currentTimeMillis()
pingCollabora(session, collabora)
}
}
}
}
}
fun clear() {
cachedServerUrl = null
cachedCollaboraUrl = null
lastWarmupAtMs = 0L
}
private fun pingCollabora(session: AuthSession, collaboraBase: String) {
val client = NetworkFactory.newAuthedClientForOffice(
session.username,
session.appPassword,
session.trustAllCerts,
)
val discoveryUrl = "$collaboraBase/hosting/discovery"
runCatching {
client.newCall(
Request.Builder()
.url(discoveryUrl)
.header("User-Agent", OFFICE_WARMUP_UA)
.get()
.build(),
).execute().use { /* прогрев соединения */ }
}
}
private const val OFFICE_WARMUP_UA =
"F7cloud-Mobile/1.0 (OfficeWarmup)"
}
@@ -0,0 +1,26 @@
package ru.forbion.f7cloud.feature.files
import java.util.Locale
enum class OpenableKind {
IMAGE,
}
object OpenableFiles {
private val IMAGE_EXT = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "heic", "heif")
fun kind(name: String): OpenableKind? {
val ext = name.lowercase(Locale.ROOT).substringAfterLast('.', missingDelimiterValue = "")
return if (ext in IMAGE_EXT) OpenableKind.IMAGE else null
}
fun isOpenable(name: String): Boolean = kind(name) != null || ArchiveFiles.isArchive(name)
fun isImage(name: String): Boolean = kind(name) == OpenableKind.IMAGE
fun hint(name: String): String? = when {
isImage(name) -> "Просмотр"
ArchiveFiles.isArchive(name) -> "Открыть с помощью…"
else -> null
}
}
@@ -0,0 +1,79 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.FormBody
import okhttp3.Request
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class RichdocumentsRepository {
/**
* Создаёт одноразовую ссылку Direct Editing (Collabora / Richdocuments).
* @see OCA\Richdocuments\Controller\OCSController::createDirect
*/
fun createDirectUrl(session: AuthSession, fileId: Long): String {
val client = officeClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/richdocuments/api/v1/document?format=json"
val body = FormBody.Builder()
.add("fileId", fileId.toString())
.build()
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Richdocuments HTTP ${response.code}")
}
val ocs = JSONObject(response.body!!.string()).optJSONObject("ocs")
?: error("Некорректный ответ Richdocuments")
val meta = ocs.optJSONObject("meta")
if (meta?.optString("status").equals("failure", ignoreCase = true)) {
error(meta?.optString("message").orEmpty().ifBlank { "Richdocuments error" })
}
val editorUrl = ocs.optJSONObject("data")?.optString("url").orEmpty()
if (editorUrl.isBlank()) error("Richdocuments не вернул URL редактора")
return editorUrl
}
}
/**
* Public Collabora URL from F7cloud capabilities (used for WebView intercept + auth hosts).
*/
fun fetchCollaboraPublicUrl(session: AuthSession): String {
val client = officeClient(session)
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/capabilities?format=json"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.get()
.build()
return runCatching {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) return ""
val data = JSONObject(response.body!!.string())
.optJSONObject("ocs")
?.optJSONObject("data")
?: return ""
data.optJSONObject("capabilities")
?.optJSONObject("richdocuments")
?.optJSONObject("config")
?.optString("public_wopi_url")
.orEmpty()
.trim()
}
}.getOrDefault("")
}
private fun officeClient(session: AuthSession) =
NetworkFactory.newAuthedClientForOffice(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="f7_files" path="f7_files/" />
</paths>
+44
View File
@@ -0,0 +1,44 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.mail'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation project(':feature:files')
implementation project(':feature:contacts')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.foundation:foundation-layout'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".MailComposeActivity"
android:exported="false"
android:hardwareAccelerated="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".MailFilesPickerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
internal object MailAttachmentIO {
fun readUri(
context: Context,
uri: Uri,
fallbackName: String = "attachment.bin",
): Triple<String, ByteArray, String> {
val resolver = context.contentResolver
var name = fallbackName
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
}
}
}
val mime = resolver.getType(uri) ?: "application/octet-stream"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
return Triple(name, bytes, mime)
}
}
@@ -0,0 +1,107 @@
package ru.forbion.f7cloud.feature.mail
data class MailBackStackState(
val searchFilterOpen: Boolean,
val snoozeSheetOpen: Boolean,
val tagsSheetOpen: Boolean,
val moveSheetOpen: Boolean,
val settingsOpen: Boolean,
val editingAccountId: Int?,
val sidebarOpen: Boolean,
val messageOpen: Boolean,
)
fun MailBackStackState.canGoBack(): Boolean =
searchFilterOpen ||
snoozeSheetOpen ||
tagsSheetOpen ||
moveSheetOpen ||
settingsOpen ||
sidebarOpen ||
messageOpen
fun navigateMailBack(
state: MailBackStackState,
closeSearchFilter: () -> Unit,
closeSnoozeSheet: () -> Unit,
closeTagsSheet: () -> Unit,
closeMoveSheet: () -> Unit,
closeAccountSettings: () -> Unit,
closeSettings: () -> Unit,
closeSidebar: () -> Unit,
closeMessage: () -> Unit,
): Boolean = when {
state.searchFilterOpen -> {
closeSearchFilter()
true
}
state.snoozeSheetOpen -> {
closeSnoozeSheet()
true
}
state.tagsSheetOpen -> {
closeTagsSheet()
true
}
state.moveSheetOpen -> {
closeMoveSheet()
true
}
state.settingsOpen && state.editingAccountId != null -> {
closeAccountSettings()
true
}
state.settingsOpen -> {
closeSettings()
true
}
state.sidebarOpen -> {
closeSidebar()
true
}
state.messageOpen -> {
closeMessage()
true
}
else -> false
}
data class MailComposeBackStackState(
val attachmentMenuOpen: Boolean,
val sendLaterMenuOpen: Boolean,
val moreMenuOpen: Boolean,
val toolbarVisible: Boolean,
)
fun MailComposeBackStackState.canGoBack(): Boolean =
attachmentMenuOpen || sendLaterMenuOpen || moreMenuOpen || toolbarVisible
fun navigateMailComposeBack(
state: MailComposeBackStackState,
closeAttachmentMenu: () -> Unit,
closeSendLaterMenu: () -> Unit,
closeMoreMenu: () -> Unit,
closeToolbar: () -> Unit,
closeScreen: () -> Unit,
): Boolean = when {
state.attachmentMenuOpen -> {
closeAttachmentMenu()
true
}
state.sendLaterMenuOpen -> {
closeSendLaterMenu()
true
}
state.moreMenuOpen -> {
closeMoreMenu()
true
}
state.toolbarVisible -> {
closeToolbar()
true
}
else -> {
closeScreen()
true
}
}
@@ -0,0 +1,178 @@
package ru.forbion.f7cloud.feature.mail
import android.net.Uri
/** Shared HTML normalization and reader styles for mail message bodies. */
object MailBodyHtml {
val READER_CSS = """
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
word-wrap: break-word;
overflow-wrap: anywhere;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.45;
color: #1a1a1a;
padding: 8px;
}
body, p, div, span, td, th, li, blockquote {
white-space: normal !important;
}
img, table, video {
max-width: 100% !important;
height: auto !important;
}
a:has(img) {
pointer-events: none !important;
cursor: default !important;
}
table {
table-layout: fixed;
width: 100%;
}
p {
margin: 0 0 0.35em !important;
}
p:last-child {
margin-bottom: 0 !important;
}
p:empty,
p:has(br:only-child) {
margin: 0 !important;
line-height: 0 !important;
font-size: 0 !important;
}
pre, code {
white-space: pre-wrap !important;
word-break: break-word;
}
blockquote,
.quote,
details.quoted-text,
.gmail_quote,
.gmail_extra,
.moz-cite-prefix,
#divRplyFwdMsg {
margin: 2px 0 !important;
margin-left: 0 !important;
padding: 0 0 0 6px !important;
border: none !important;
border-left: 1px solid #70B62B !important;
max-width: none !important;
}
blockquote blockquote,
blockquote .quote,
.quote blockquote,
.quote .quote,
details.quoted-text details.quoted-text {
margin-left: 0 !important;
padding-left: 6px !important;
}
blockquote [style*="margin-left"],
blockquote [style*="padding-left"],
.quote [style*="margin-left"],
.quote [style*="padding-left"],
.gmail_quote [style*="margin-left"],
.gmail_quote [style*="padding-left"] {
margin-left: 0 !important;
padding-left: 0 !important;
}
""".trimIndent()
private val EMPTY_PARAGRAPH = Regex(
"""<p[^>]*>(?:\s|&nbsp;|&#160;|&#xA0;|<br\s*/?>)*</p>""",
RegexOption.IGNORE_CASE,
)
private val NBSP_RUN = Regex("""(?:&nbsp;|&#160;|&#xA0;|\u00A0){2,}""", RegexOption.IGNORE_CASE)
private val SPACE_RUN = Regex(""" {2,}""")
private val MAIL_PROXY_URL = Regex(
"""https?://[^"'\s<>)]+/apps/mail/proxy\?[^"'\s<>)]+""",
RegexOption.IGNORE_CASE,
)
private val CID_REFERENCE = Regex("""cid:([^"'\s>)]+)""", RegexOption.IGNORE_CASE)
fun normalizeForDisplay(html: String): String {
var normalized = MailWebBranding.replaceInText(html)
normalized = normalized.replace(EMPTY_PARAGRAPH, "")
normalized = normalized.replace(NBSP_RUN, "&nbsp;")
// Only collapse spaces between tags to avoid breaking preformatted text nodes.
normalized = normalized.replace(Regex(">(\\s{2,})<")) { match ->
">" + match.groupValues[1].replace(SPACE_RUN, " ") + "<"
}
return normalized
}
/**
* Rewrites image sources for mobile WebView:
* - unwraps Nextcloud mail proxy URLs (proxy requires session cookies, not Basic auth)
* - resolves cid: inline images to authenticated API attachment URLs
*/
fun rewriteImageSources(
html: String,
messageId: Int,
apiBase: String,
attachments: List<MailAttachment>,
): String {
if (html.isBlank()) return html
var result = unwrapMailProxyUrls(html)
if (messageId > 0 && result.contains("cid:", ignoreCase = true)) {
result = rewriteCidReferences(result, messageId, apiBase, attachments)
}
return result
}
fun unwrapMailProxyUrls(html: String): String =
MAIL_PROXY_URL.replace(html) { match -> unwrapProxyUrl(match.value) }
fun unwrapProxyUrl(url: String): String {
val normalized = url.replace("&amp;", "&")
return runCatching {
Uri.parse(normalized).getQueryParameter("src")?.takeIf { it.isNotBlank() }
}.getOrNull() ?: url
}
private fun rewriteCidReferences(
html: String,
messageId: Int,
apiBase: String,
attachments: List<MailAttachment>,
): String {
val byCid = attachments.mapNotNull { attachment ->
attachment.cid
?.trim('<', '>')
?.takeIf { it.isNotBlank() }
?.let { it to attachment }
}.toMap()
val base = apiBase.trimEnd('/')
return CID_REFERENCE.replace(html) { match ->
val cid = match.groupValues[1].trim('<', '>')
val attachment = byCid[cid]
if (attachment != null) {
"$base/messages/$messageId/attachment/${attachment.id}"
} else {
match.value
}
}
}
fun normalizePlainForDisplay(text: String): String =
text.lines()
.joinToString("\n") { line ->
line.replace(SPACE_RUN, " ").trimEnd()
}
.trimEnd()
fun formatPreviewText(raw: String): String {
if (raw.isBlank()) return ""
val withoutTags = raw.replace(Regex("<[^>]+>"), " ")
val decoded = runCatching {
android.text.Html.fromHtml(withoutTags, android.text.Html.FROM_HTML_MODE_LEGACY).toString()
}.getOrDefault(withoutTags)
return normalizePlainForDisplay(decoded).replace('\n', ' ')
}
}
@@ -0,0 +1,381 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import java.io.File
import java.security.MessageDigest
class MailCacheRepository(context: Context) {
private val rootDir = File(context.filesDir, "mail-cache")
suspend fun loadBootstrap(session: AuthSession): MailBootstrap? = withContext(Dispatchers.IO) {
readJson(session, "bootstrap.json")?.let(::parseBootstrap)
}
suspend fun saveBootstrap(session: AuthSession, bootstrap: MailBootstrap) = withContext(Dispatchers.IO) {
writeJson(session, "bootstrap.json", bootstrapToJson(bootstrap))
}
suspend fun loadFolderPage(session: AuthSession, folder: MailFolderEntry): MailMessagesPage? =
withContext(Dispatchers.IO) {
readJson(session, "folders/${folder.cacheKey()}.json")?.let(::parseFolderPage)
}
suspend fun saveFolderPage(session: AuthSession, folder: MailFolderEntry, page: MailMessagesPage) =
withContext(Dispatchers.IO) {
writeJson(session, "folders/${folder.cacheKey()}.json", folderPageToJson(page))
}
suspend fun folderSyncedAt(session: AuthSession, folder: MailFolderEntry): Long? =
withContext(Dispatchers.IO) {
readJson(session, "folders/${folder.cacheKey()}.json")?.optLong("syncedAt")?.takeIf { it > 0L }
}
suspend fun loadMessageDetail(session: AuthSession, messageId: Int): MailMessageDetail? =
withContext(Dispatchers.IO) {
readJson(session, "messages/$messageId.json")?.let(::parseMessageDetail)
}
suspend fun isMessageDetailCached(session: AuthSession, messageId: Int): Boolean =
withContext(Dispatchers.IO) {
val file = File(accountDir(session), "messages/$messageId.json")
if (!file.exists()) return@withContext false
runCatching {
parseMessageDetail(JSONObject(file.readText())).hasBodyContent()
}.getOrDefault(false)
}
suspend fun saveMessageDetail(session: AuthSession, detail: MailMessageDetail) =
withContext(Dispatchers.IO) {
writeJson(session, "messages/${detail.id}.json", messageDetailToJson(detail))
}
suspend fun removeMessage(session: AuthSession, messageId: Int) = withContext(Dispatchers.IO) {
File(accountDir(session), "messages/$messageId.json").delete()
}
suspend fun updateMessageInFolders(session: AuthSession, messageId: Int, updater: (MailMessage) -> MailMessage) =
withContext(Dispatchers.IO) {
val foldersDir = File(accountDir(session), "folders")
if (!foldersDir.exists()) return@withContext
foldersDir.listFiles()?.forEach { file ->
if (!file.name.endsWith(".json")) return@forEach
runCatching {
val json = JSONObject(file.readText())
val messages = json.optJSONArray("messages") ?: return@forEach
var changed = false
for (i in 0 until messages.length()) {
val obj = messages.optJSONObject(i) ?: continue
if (obj.optInt("id") != messageId) continue
val updated = updater(parseMessage(obj))
messages.put(i, messageToJson(updated))
changed = true
}
if (changed) file.writeText(json.toString())
}
}
}
suspend fun removeMessageFromFolders(session: AuthSession, messageId: Int) = withContext(Dispatchers.IO) {
val foldersDir = File(accountDir(session), "folders")
if (!foldersDir.exists()) return@withContext
foldersDir.listFiles()?.forEach { file ->
if (!file.name.endsWith(".json")) return@forEach
runCatching {
val json = JSONObject(file.readText())
val messages = json.optJSONArray("messages") ?: return@forEach
val filtered = JSONArray()
for (i in 0 until messages.length()) {
val obj = messages.optJSONObject(i) ?: continue
if (obj.optInt("id") != messageId) filtered.put(obj)
}
if (filtered.length() != messages.length()) {
json.put("messages", filtered)
file.writeText(json.toString())
}
}
}
}
private fun accountDir(session: AuthSession): File {
val key = accountKey(session)
return File(rootDir, key).also { it.mkdirs() }
}
private fun accountKey(session: AuthSession): String {
val raw = "${session.serverUrl.trimEnd('/')}|${session.username.trim().lowercase()}"
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
private fun readJson(session: AuthSession, relativePath: String): JSONObject? {
val file = File(accountDir(session), relativePath)
if (!file.exists()) return null
return runCatching { JSONObject(file.readText()) }.getOrNull()
}
private fun writeJson(session: AuthSession, relativePath: String, json: JSONObject) {
val file = File(accountDir(session), relativePath)
file.parentFile?.mkdirs()
file.writeText(json.toString())
}
private fun bootstrapToJson(bootstrap: MailBootstrap): JSONObject =
JSONObject()
.put("accounts", accountsToJson(bootstrap.accounts))
.put("folders", foldersToJson(bootstrap.folders))
.put(
"selectedFolder",
bootstrap.selectedFolder?.let { folderToJson(it) },
)
.put("syncedAt", System.currentTimeMillis())
private fun parseBootstrap(json: JSONObject): MailBootstrap {
val accounts = parseAccounts(json.optJSONArray("accounts"))
val folders = parseFolders(json.optJSONArray("folders"))
val selected = json.optJSONObject("selectedFolder")?.let(::parseFolder)
return MailBootstrap(accounts, emptyList(), folders, selected)
}
private fun folderPageToJson(page: MailMessagesPage): JSONObject =
JSONObject()
.put("messages", messagesToJson(page.messages))
.put("nextCursor", page.nextCursor ?: JSONObject.NULL)
.put("syncedAt", System.currentTimeMillis())
private fun parseFolderPage(json: JSONObject): MailMessagesPage =
MailMessagesPage(
messages = parseMessages(json.optJSONArray("messages")),
nextCursor = json.opt("nextCursor").takeUnless { it == JSONObject.NULL } as? Int,
)
private fun messageDetailToJson(detail: MailMessageDetail): JSONObject =
JSONObject()
.put("id", detail.id)
.put("subject", detail.subject)
.put("from", detail.from)
.put("fromEmail", detail.fromEmail)
.put("to", detail.to)
.put("cc", detail.cc)
.put("dateInt", detail.dateInt)
.put("bodyHtml", detail.bodyHtml)
.put("bodyPlain", detail.bodyPlain)
.put("hasHtmlBody", detail.hasHtmlBody)
.put("flags", flagsToJson(detail.flags))
.put("attachments", attachmentsToJson(detail.attachments))
.put("syncedAt", System.currentTimeMillis())
private fun parseMessageDetail(json: JSONObject): MailMessageDetail =
MailMessageDetail(
id = json.optInt("id"),
subject = json.optString("subject"),
from = json.optString("from"),
fromEmail = json.optString("fromEmail"),
to = json.optString("to"),
cc = json.optString("cc"),
dateInt = json.optLong("dateInt"),
bodyHtml = json.optString("bodyHtml"),
bodyPlain = json.optString("bodyPlain"),
hasHtmlBody = json.optBoolean("hasHtmlBody"),
flags = parseFlags(json.optJSONObject("flags")),
attachments = parseAttachments(json.optJSONArray("attachments")),
)
private fun accountsToJson(accounts: List<MailAccount>): JSONArray =
JSONArray().apply {
accounts.forEach { account ->
put(
JSONObject()
.put("id", account.id)
.put("email", account.email)
.put("name", account.name)
.put("draftsMailboxId", account.draftsMailboxId ?: JSONObject.NULL)
.put("sentMailboxId", account.sentMailboxId ?: JSONObject.NULL)
.put("trashMailboxId", account.trashMailboxId ?: JSONObject.NULL)
.put("archiveMailboxId", account.archiveMailboxId ?: JSONObject.NULL)
.put("junkMailboxId", account.junkMailboxId ?: JSONObject.NULL),
)
}
}
private fun parseAccounts(array: JSONArray?): List<MailAccount> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
add(
MailAccount(
id = obj.optInt("id"),
email = obj.optString("email"),
name = obj.optString("name"),
draftsMailboxId = obj.opt("draftsMailboxId").nullInt(),
sentMailboxId = obj.opt("sentMailboxId").nullInt(),
trashMailboxId = obj.opt("trashMailboxId").nullInt(),
archiveMailboxId = obj.opt("archiveMailboxId").nullInt(),
junkMailboxId = obj.opt("junkMailboxId").nullInt(),
),
)
}
}
}
private fun foldersToJson(folders: List<MailFolderEntry>): JSONArray =
JSONArray().apply { folders.forEach { put(folderToJson(it)) } }
private fun folderToJson(folder: MailFolderEntry): JSONObject =
JSONObject()
.put("mailboxId", folder.mailboxId)
.put("accountId", folder.accountId)
.put("title", folder.title)
.put("specialRole", folder.specialRole ?: JSONObject.NULL)
.put("filter", folder.filter.name)
.put("unread", folder.unread)
private fun parseFolders(array: JSONArray?): List<MailFolderEntry> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
array.optJSONObject(i)?.let { add(parseFolder(it)) }
}
}
}
private fun parseFolder(obj: JSONObject): MailFolderEntry =
MailFolderEntry(
mailboxId = obj.optInt("mailboxId"),
accountId = obj.optInt("accountId"),
title = obj.optString("title"),
specialRole = obj.opt("specialRole").nullString(),
filter = runCatching { MailListFilter.valueOf(obj.optString("filter")) }
.getOrDefault(MailListFilter.ALL),
unread = obj.optInt("unread"),
)
private fun messagesToJson(messages: List<MailMessage>): JSONArray =
JSONArray().apply { messages.forEach { put(messageToJson(it)) } }
private fun messageToJson(message: MailMessage): JSONObject =
JSONObject()
.put("id", message.id)
.put("subject", message.subject)
.put("from", message.from)
.put("fromEmail", message.fromEmail)
.put("preview", message.preview)
.put("dateInt", message.dateInt)
.put("flags", flagsToJson(message.flags))
.put("tags", tagsToJson(message.tags))
private fun tagsToJson(tags: List<MailTag>): JSONArray =
JSONArray().apply {
tags.forEach { tag ->
put(
JSONObject()
.put("id", tag.id)
.put("displayName", tag.displayName)
.put("colorHex", tag.colorHex)
.put("imapLabel", tag.imapLabel),
)
}
}
private fun parseTags(array: JSONArray?): List<MailTag> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val name = obj.optString("displayName").trim()
if (name.isEmpty()) continue
add(
MailTag(
id = obj.optLong("id"),
displayName = name,
colorHex = obj.optString("colorHex"),
imapLabel = obj.optString("imapLabel"),
),
)
}
}
}
private fun parseMessages(array: JSONArray?): List<MailMessage> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
array.optJSONObject(i)?.let { add(parseMessage(it)) }
}
}
}
private fun parseMessage(obj: JSONObject): MailMessage =
MailMessage(
id = obj.optInt("id"),
subject = obj.optString("subject"),
from = obj.optString("from"),
fromEmail = obj.optString("fromEmail"),
preview = obj.optString("preview"),
dateInt = obj.optLong("dateInt"),
flags = parseFlags(obj.optJSONObject("flags")),
tags = parseTags(obj.optJSONArray("tags")),
)
private fun flagsToJson(flags: MailMessageFlags): JSONObject =
JSONObject()
.put("seen", flags.seen)
.put("flagged", flags.flagged)
.put("hasAttachments", flags.hasAttachments)
.put("answered", flags.answered)
.put("important", flags.important)
private fun parseFlags(obj: JSONObject?): MailMessageFlags =
MailMessageFlags(
seen = obj?.optBoolean("seen", true) ?: true,
flagged = obj?.optBoolean("flagged", false) ?: false,
hasAttachments = obj?.optBoolean("hasAttachments", false) ?: false,
answered = obj?.optBoolean("answered", false) ?: false,
important = obj?.optBoolean("important", false) ?: false,
)
private fun attachmentsToJson(attachments: List<MailAttachment>): JSONArray =
JSONArray().apply {
attachments.forEach { attachment ->
put(
JSONObject()
.put("id", attachment.id)
.put("fileName", attachment.fileName)
.put("mime", attachment.mime)
.put("size", attachment.size)
.put("cid", attachment.cid)
.put("downloadUrl", attachment.downloadUrl),
)
}
}
private fun parseAttachments(array: JSONArray?): List<MailAttachment> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
add(
MailAttachment(
id = obj.optString("id"),
fileName = obj.optString("fileName"),
mime = obj.optString("mime"),
size = obj.optLong("size"),
cid = obj.optString("cid").ifBlank { null },
downloadUrl = obj.optString("downloadUrl").ifBlank { null },
),
)
}
}
}
private fun Any?.nullInt(): Int? =
if (this == null || this == JSONObject.NULL) null else (this as? Number)?.toInt()
private fun Any?.nullString(): String? =
if (this == null || this == JSONObject.NULL) null else this.toString()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.feature.contacts.ContactsRepository
class MailComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val launch = readLaunch() ?: run {
finish()
return
}
val session = AuthSession(
serverUrl = launch.serverUrl,
username = launch.username,
appPassword = launch.password,
trustAllCerts = launch.trustAllCerts,
)
val vm = MailComposeViewModel(
launch = launch,
contactsRepository = ContactsRepository(applicationContext),
)
setContent {
F7Theme {
MailComposeScreen(
session = session,
launch = launch,
vm = vm,
onClose = { finish() },
onUnauthorized = { finish() },
modifier = Modifier.fillMaxSize(),
)
}
}
}
private fun readLaunch(): MailComposeLaunch? {
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty()
val accountId = intent.getIntExtra(EXTRA_ACCOUNT_ID, 0)
val accountEmail = intent.getStringExtra(EXTRA_ACCOUNT_EMAIL).orEmpty()
if (username.isBlank() || serverUrl.isBlank() || accountId <= 0 || accountEmail.isBlank()) {
return null
}
return MailComposeLaunch(
username = username,
password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
serverUrl = serverUrl,
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
accountId = accountId,
accountEmail = accountEmail,
accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME),
mailboxId = intent.getIntExtra(EXTRA_MAILBOX_ID, 0).takeIf { it > 0 },
mode = runCatching {
MailComposeMode.valueOf(intent.getStringExtra(EXTRA_MODE) ?: MailComposeMode.NEW.name)
}.getOrDefault(MailComposeMode.NEW),
initialTo = intent.getStringExtra(EXTRA_INITIAL_TO).orEmpty(),
initialCc = intent.getStringExtra(EXTRA_INITIAL_CC).orEmpty(),
initialSubject = intent.getStringExtra(EXTRA_INITIAL_SUBJECT).orEmpty(),
initialBodyHtml = intent.getStringExtra(EXTRA_INITIAL_BODY_HTML).orEmpty(),
showCcBcc = intent.getBooleanExtra(EXTRA_SHOW_CC_BCC, false),
initialSendPreset = runCatching {
MailSendLaterPreset.valueOf(
intent.getStringExtra(EXTRA_INITIAL_SEND_PRESET) ?: MailSendLaterPreset.NOW.name,
)
}.getOrDefault(MailSendLaterPreset.NOW),
initialCustomSendAtEpochSeconds = intent.getLongExtra(EXTRA_INITIAL_CUSTOM_SEND_AT, 0L)
.takeIf { it > 0L },
)
}
companion object {
private const val EXTRA_USERNAME = "username"
private const val EXTRA_PASSWORD = "password"
private const val EXTRA_SERVER_URL = "server_url"
private const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
private const val EXTRA_ACCOUNT_ID = "account_id"
private const val EXTRA_ACCOUNT_EMAIL = "account_email"
private const val EXTRA_ACCOUNT_NAME = "account_name"
private const val EXTRA_MAILBOX_ID = "mailbox_id"
private const val EXTRA_MODE = "compose_mode"
private const val EXTRA_INITIAL_TO = "initial_to"
private const val EXTRA_INITIAL_CC = "initial_cc"
private const val EXTRA_INITIAL_SUBJECT = "initial_subject"
private const val EXTRA_INITIAL_BODY_HTML = "initial_body_html"
private const val EXTRA_SHOW_CC_BCC = "show_cc_bcc"
private const val EXTRA_INITIAL_SEND_PRESET = "initial_send_preset"
private const val EXTRA_INITIAL_CUSTOM_SEND_AT = "initial_custom_send_at"
fun intent(context: Context, launch: MailComposeLaunch): Intent =
Intent(context, MailComposeActivity::class.java).apply {
putExtra(EXTRA_USERNAME, launch.username)
putExtra(EXTRA_PASSWORD, launch.password)
putExtra(EXTRA_SERVER_URL, launch.serverUrl)
putExtra(EXTRA_TRUST_ALL_CERTS, launch.trustAllCerts)
putExtra(EXTRA_ACCOUNT_ID, launch.accountId)
putExtra(EXTRA_ACCOUNT_EMAIL, launch.accountEmail)
putExtra(EXTRA_ACCOUNT_NAME, launch.accountName)
launch.mailboxId?.let { putExtra(EXTRA_MAILBOX_ID, it) }
putExtra(EXTRA_MODE, launch.mode.name)
putExtra(EXTRA_INITIAL_TO, launch.initialTo)
putExtra(EXTRA_INITIAL_CC, launch.initialCc)
putExtra(EXTRA_INITIAL_SUBJECT, launch.initialSubject)
putExtra(EXTRA_INITIAL_BODY_HTML, launch.initialBodyHtml)
putExtra(EXTRA_SHOW_CC_BCC, launch.showCcBcc)
putExtra(EXTRA_INITIAL_SEND_PRESET, launch.initialSendPreset.name)
launch.initialCustomSendAtEpochSeconds?.let { putExtra(EXTRA_INITIAL_CUSTOM_SEND_AT, it) }
}
fun launch(context: Context, session: AuthSession, accountId: Int, accountEmail: String, mailboxId: Int? = null) {
context.startActivity(
intent(
context,
MailComposeLaunch(
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
accountId = accountId,
accountEmail = accountEmail,
mailboxId = mailboxId,
),
),
)
}
}
}
@@ -0,0 +1,51 @@
package ru.forbion.f7cloud.feature.mail
enum class MailComposeMode {
NEW,
REPLY,
REPLY_ALL,
FORWARD,
}
data class MailComposeLaunch(
val username: String,
val password: String,
val serverUrl: String,
val trustAllCerts: Boolean,
val accountId: Int,
val accountEmail: String,
val accountName: String? = null,
val mailboxId: Int? = null,
val mode: MailComposeMode = MailComposeMode.NEW,
val initialTo: String = "",
val initialCc: String = "",
val initialSubject: String = "",
val initialBodyHtml: String = "",
val showCcBcc: Boolean = false,
val initialSendPreset: MailSendLaterPreset = MailSendLaterPreset.NOW,
val initialCustomSendAtEpochSeconds: Long? = null,
)
data class MailRecipient(
val email: String,
val label: String = email,
)
enum class MailComposeAttachmentType {
LOCAL,
CLOUD,
}
data class MailComposeAttachment(
val id: Int,
val fileName: String,
val mimeType: String,
val type: MailComposeAttachmentType = MailComposeAttachmentType.LOCAL,
val cloudPath: String? = null,
val size: Long? = null,
)
enum class MailFilesPickMode {
ATTACHMENT,
SHARE_LINK,
}
@@ -0,0 +1,85 @@
package ru.forbion.f7cloud.feature.mail
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
enum class MailSendLaterPreset {
NOW,
TOMORROW_MORNING,
TOMORROW_AFTERNOON,
MONDAY_MORNING,
CUSTOM,
}
data class MailSendLaterOption(
val preset: MailSendLaterPreset,
val title: String,
val sendAtEpochSeconds: Long?,
)
object MailComposeSchedule {
private val dateLabelFormatter = DateTimeFormatter.ofPattern("d MMM", Locale("ru"))
private val customFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale("ru"))
fun options(now: LocalDateTime = LocalDateTime.now()): List<MailSendLaterOption> {
val zone = ZoneId.systemDefault()
val tomorrow = now.toLocalDate().plusDays(1)
val mondayMorning = nextMondayMorning(now.toLocalDate())
return listOf(
MailSendLaterOption(MailSendLaterPreset.NOW, "Отправить сейчас", null),
MailSendLaterOption(
MailSendLaterPreset.TOMORROW_MORNING,
"Завтра утром - ${formatSlot(tomorrow, LocalTime.of(9, 0))}",
epochSeconds(tomorrow, LocalTime.of(9, 0), zone),
),
MailSendLaterOption(
MailSendLaterPreset.TOMORROW_AFTERNOON,
"Завтра днем - ${formatSlot(tomorrow, LocalTime.of(14, 0))}",
epochSeconds(tomorrow, LocalTime.of(14, 0), zone),
),
MailSendLaterOption(
MailSendLaterPreset.MONDAY_MORNING,
"В понедельник утром - ${formatSlot(mondayMorning, LocalTime.of(9, 0))}",
epochSeconds(mondayMorning, LocalTime.of(9, 0), zone),
),
MailSendLaterOption(
MailSendLaterPreset.CUSTOM,
"Настроить дату и время",
epochSeconds(now.toLocalDate(), now.toLocalTime().withSecond(0).withNano(0), zone),
),
)
}
fun formatCustom(dateTime: LocalDateTime): String = customFormatter.format(dateTime)
fun parseCustom(value: String): LocalDateTime? =
runCatching { LocalDateTime.parse(value.trim(), customFormatter) }.getOrNull()
private fun formatSlot(date: LocalDate, time: LocalTime): String {
val datePart = dateLabelFormatter.format(date).replace(".", "")
return "$datePart, ${time.format(DateTimeFormatter.ofPattern("HH:mm"))}"
}
private fun epochSeconds(date: LocalDate, time: LocalTime, zone: ZoneId): Long =
LocalDateTime.of(date, time).atZone(zone).toEpochSecond()
private fun nextMondayMorning(today: LocalDate): LocalDate {
var date = today
if (today.dayOfWeek == DayOfWeek.MONDAY) {
return today
}
while (date.dayOfWeek != DayOfWeek.MONDAY) {
date = date.plusDays(1)
}
return date
}
fun toInstant(epochSeconds: Long?): Instant? =
epochSeconds?.let { Instant.ofEpochSecond(it) }
}
@@ -0,0 +1,889 @@
package ru.forbion.f7cloud.feature.mail
import android.app.Activity
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
import java.time.LocalDateTime
@Composable
fun MailComposeScreen(
session: AuthSession,
launch: MailComposeLaunch,
vm: MailComposeViewModel,
onClose: () -> Unit,
onUnauthorized: () -> Unit,
modifier: Modifier = Modifier,
) {
val state by vm.state.collectAsState()
val context = LocalContext.current
val editor = remember { MailRichTextEditorController() }
val base = session.serverUrl.trimEnd('/')
val pickAttachmentsLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.addAttachmentsFromUris(context, session, uris, onUnauthorized)
}
}
val filesPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { data ->
vm.handleFilesPickResult(data, session, editor, onUnauthorized)
}
} else {
vm.cancelFilesPick()
}
}
LaunchedEffect(session.serverUrl, session.username) {
vm.initContacts(session)
}
LaunchedEffect(state.finished) {
if (state.finished) {
Toast.makeText(context, "Письмо отправлено", Toast.LENGTH_SHORT).show()
onClose()
}
}
LaunchedEffect(launch.initialBodyHtml) {
if (launch.initialBodyHtml.isNotBlank()) {
editor.setHtml(launch.initialBodyHtml)
}
}
LaunchedEffect(state.error) {
state.error?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
val composeNavigateBack: () -> Boolean = {
navigateMailComposeBack(
state = MailComposeBackStackState(
attachmentMenuOpen = state.attachmentMenuOpen,
sendLaterMenuOpen = state.sendLaterMenuOpen,
moreMenuOpen = state.moreMenuOpen,
toolbarVisible = state.toolbarVisible,
),
closeAttachmentMenu = { vm.setAttachmentMenuOpen(false) },
closeSendLaterMenu = { vm.setSendLaterMenuOpen(false) },
closeMoreMenu = { vm.setMoreMenuOpen(false) },
closeToolbar = { vm.toggleToolbar() },
closeScreen = onClose,
)
}
BackHandler { composeNavigateBack() }
Column(
modifier = modifier
.fillMaxSize()
.background(F7Colors.Surface)
.f7SwipeFromRightToDismiss(onDismiss = { composeNavigateBack() }),
) {
MailComposeHeader(
senderLabel = vm.senderLabel,
title = when (launch.mode) {
MailComposeMode.REPLY -> "Ответить на сообщение"
MailComposeMode.REPLY_ALL -> "Ответить всем"
MailComposeMode.FORWARD -> "Переслать сообщение"
MailComposeMode.NEW -> "Новое сообщение"
},
serverUrl = session.serverUrl,
onClose = onClose,
)
MailRecipientComposeField(
label = "Кому:",
value = state.to,
onValueChange = vm::setTo,
suggestions = if (state.activeRecipientField == MailRecipientField.TO) {
state.recipientSuggestions
} else {
emptyList()
},
showSuggestions = state.activeRecipientField == MailRecipientField.TO,
onSuggestionClick = { vm.selectSuggestion(MailRecipientField.TO, it) },
onDismissSuggestions = vm::dismissSuggestions,
trailingIconUrl = "$base/themes/forbion/images/mail/nav-chevron-down-gray.svg",
onTrailingClick = vm::toggleCcBcc,
)
if (state.showCcBcc) {
MailRecipientComposeField(
label = "Копия:",
value = state.cc,
onValueChange = vm::setCc,
suggestions = if (state.activeRecipientField == MailRecipientField.CC) {
state.recipientSuggestions
} else {
emptyList()
},
showSuggestions = state.activeRecipientField == MailRecipientField.CC,
onSuggestionClick = { vm.selectSuggestion(MailRecipientField.CC, it) },
onDismissSuggestions = vm::dismissSuggestions,
)
MailRecipientComposeField(
label = "Скрытая копия:",
value = state.bcc,
onValueChange = vm::setBcc,
suggestions = if (state.activeRecipientField == MailRecipientField.BCC) {
state.recipientSuggestions
} else {
emptyList()
},
showSuggestions = state.activeRecipientField == MailRecipientField.BCC,
onSuggestionClick = { vm.selectSuggestion(MailRecipientField.BCC, it) },
onDismissSuggestions = vm::dismissSuggestions,
)
}
MailComposeField(
label = "Тема сообщения",
value = state.subject,
onValueChange = vm::setSubject,
)
if (state.attachments.isNotEmpty()) {
MailComposeAttachmentsRow(
serverUrl = session.serverUrl,
attachments = state.attachments,
onRemove = vm::removeAttachment,
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White),
) {
MailRichTextEditor(
modifier = Modifier.fillMaxSize(),
controller = editor,
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(10.dp),
) {
if (state.attachmentMenuOpen) {
MailComposeAttachmentMenu(
serverUrl = session.serverUrl,
onUploadFromDevice = {
vm.setAttachmentMenuOpen(false)
pickAttachmentsLauncher.launch(arrayOf("*/*"))
},
onPickFromFiles = {
vm.prepareFilesPick(MailFilesPickMode.ATTACHMENT)
vm.filesPickIntent(context, session)?.let(filesPickerLauncher::launch)
},
onAddShareLink = {
vm.prepareFilesPick(MailFilesPickMode.SHARE_LINK)
vm.filesPickIntent(context, session)?.let(filesPickerLauncher::launch)
},
onDismiss = { vm.setAttachmentMenuOpen(false) },
)
}
Box(
modifier = Modifier
.size(32.dp)
.clickable(
enabled = !state.uploadingAttachments,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { vm.setAttachmentMenuOpen(!state.attachmentMenuOpen) },
),
contentAlignment = Alignment.Center,
) {
if (state.uploadingAttachments) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
} else {
AsyncImage(
model = "$base/themes/forbion/images/mail/file-up-gray.svg",
contentDescription = "Вложение",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
}
}
}
Spacer(Modifier.height(8.dp))
if (state.toolbarVisible) {
MailComposeFormattingToolbar(
serverUrl = session.serverUrl,
onCommand = { command, value -> editor.exec(command, value) },
)
}
MailComposeBottomBar(
serverUrl = session.serverUrl,
sending = state.sending,
moreMenuOpen = state.moreMenuOpen,
sendLaterMenuOpen = state.sendLaterMenuOpen,
requestMdn = state.requestMdn,
sendLaterOptions = vm.sendLaterOptions,
selectedSendPreset = state.selectedSendPreset,
customSendAt = state.customSendAt,
onToggleToolbar = vm::toggleToolbar,
onMoreMenuOpenChange = vm::setMoreMenuOpen,
onSendLaterMenuOpenChange = vm::setSendLaterMenuOpen,
onToggleRequestMdn = vm::toggleRequestMdn,
onSelectSendPreset = vm::selectSendPreset,
onCustomSendAtChange = vm::setCustomSendAt,
onSend = { vm.send(session, editor, onUnauthorized) },
)
}
}
@Composable
private fun MailComposeHeader(
senderLabel: String,
title: String,
serverUrl: String,
onClose: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
Column(
modifier = Modifier
.fillMaxWidth()
.background(F7Colors.Surface)
.padding(bottom = 8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/arrow-back-gray.svg",
contentDescription = "Назад",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
senderLabel,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/close-icon-gray.svg",
contentDescription = "Закрыть",
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(F7Colors.Border.copy(alpha = 0.5f)),
)
}
}
@Composable
private fun MailComposeAttachmentsRow(
serverUrl: String,
attachments: List<MailComposeAttachment>,
onRemove: (Int) -> Unit,
) {
val base = serverUrl.trimEnd('/')
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
"Вложения: ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
attachments.forEach { attachment ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.PrimaryLight)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AsyncImage(
model = when (attachment.type) {
MailComposeAttachmentType.CLOUD ->
"$base/themes/forbion/images/mail/folder-add-black.svg"
MailComposeAttachmentType.LOCAL ->
"$base/themes/forbion/images/mail/file-up-gray.svg"
},
contentDescription = null,
modifier = Modifier.size(14.dp),
contentScale = ContentScale.Fit,
)
Text(
attachment.fileName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 13.sp,
modifier = Modifier.widthIn(max = 180.dp),
)
AsyncImage(
model = "$base/themes/forbion/images/mail/close-icon-gray.svg",
contentDescription = "Удалить",
modifier = Modifier
.size(14.dp)
.clickable { onRemove(attachment.id) },
contentScale = ContentScale.Fit,
)
}
}
}
}
}
@Composable
private fun MailComposeField(
label: String,
value: String,
onValueChange: (String) -> Unit,
trailingIconUrl: String? = null,
onTrailingClick: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
}
inner()
},
)
if (trailingIconUrl != null && onTrailingClick != null) {
AsyncImage(
model = trailingIconUrl,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.clickable(onClick = onTrailingClick),
contentScale = ContentScale.Fit,
)
}
}
}
@Composable
private fun MailComposeFormattingToolbar(
serverUrl: String,
onCommand: (String, String?) -> Unit,
) {
val base = serverUrl.trimEnd('/')
val scroll = rememberScrollState()
val row1 = listOf(
ToolbarAction("bold", "$base/themes/forbion/images/mail/text-bold-black.svg"),
ToolbarAction("italic", "$base/themes/forbion/images/mail/text-italic-black.svg"),
ToolbarAction("underline", "$base/themes/forbion/images/mail/text-color-black.svg"),
ToolbarAction("strikeThrough", "$base/themes/forbion/images/mail/text-cross-out-black.svg"),
ToolbarAction("insertUnorderedList", "$base/themes/forbion/images/mail/text-ul-black.svg"),
ToolbarAction("insertOrderedList", "$base/themes/forbion/images/mail/text-ol-black.svg"),
ToolbarAction("justifyLeft", "$base/themes/forbion/images/mail/text-left-black.svg"),
)
val row2 = listOf(
ToolbarAction("formatBlock", "$base/themes/forbion/images/mail/text-editor-font-black.svg", "p"),
ToolbarAction("indent", "$base/themes/forbion/images/mail/text-kov-black.svg"),
ToolbarAction("outdent", "$base/themes/forbion/images/mail/text-upper-black.svg"),
ToolbarAction("removeFormat", "$base/themes/forbion/images/mail/clear-icon-black.svg"),
ToolbarAction("undo", "$base/themes/forbion/images/mail/arrow-back-gray.svg"),
ToolbarAction("redo", "$base/themes/forbion/images/mail/reply-left-gray.svg"),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(10.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(10.dp))
.background(Color.White)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(scroll),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row1.forEach { action ->
ToolbarIcon(action, onCommand)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row2.forEach { action ->
ToolbarIcon(action, onCommand)
}
}
}
}
private data class ToolbarAction(
val command: String,
val iconUrl: String,
val value: String? = null,
)
@Composable
private fun ToolbarIcon(
action: ToolbarAction,
onCommand: (String, String?) -> Unit,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { onCommand(action.command, action.value) },
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = action.iconUrl,
contentDescription = null,
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
}
}
@Composable
private fun MailComposeBottomBar(
serverUrl: String,
sending: Boolean,
moreMenuOpen: Boolean,
sendLaterMenuOpen: Boolean,
requestMdn: Boolean,
sendLaterOptions: List<MailSendLaterOption>,
selectedSendPreset: MailSendLaterPreset,
customSendAt: LocalDateTime,
onToggleToolbar: () -> Unit,
onMoreMenuOpenChange: (Boolean) -> Unit,
onSendLaterMenuOpenChange: (Boolean) -> Unit,
onToggleRequestMdn: () -> Unit,
onSelectSendPreset: (MailSendLaterPreset) -> Unit,
onCustomSendAtChange: (LocalDateTime) -> Unit,
onSend: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
val context = LocalContext.current
Box {
if (moreMenuOpen) {
MailComposeMoreMenu(
serverUrl = serverUrl,
requestMdn = requestMdn,
onToggleRequestMdn = onToggleRequestMdn,
onSendLaterClick = {
onMoreMenuOpenChange(false)
onSendLaterMenuOpenChange(true)
},
onDismiss = { onMoreMenuOpenChange(false) },
)
}
if (sendLaterMenuOpen) {
MailComposeSendLaterMenu(
options = sendLaterOptions,
selectedPreset = selectedSendPreset,
customSendAt = customSendAt,
onSelectPreset = onSelectSendPreset,
onCustomSendAtChange = onCustomSendAtChange,
onDismiss = { onSendLaterMenuOpenChange(false) },
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(F7Colors.Surface)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(100.dp))
.clickable(onClick = onToggleToolbar),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/format-size-icon.svg",
contentDescription = "Форматирование",
modifier = Modifier.size(22.dp),
contentScale = ContentScale.Fit,
)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(100.dp))
.clickable { onMoreMenuOpenChange(!moreMenuOpen) },
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/dots-icon-black.svg",
contentDescription = "Ещё",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
Box(
modifier = Modifier
.weight(1f)
.height(44.dp)
.shadow(4.dp, RoundedCornerShape(100.dp), spotColor = F7Colors.Primary.copy(alpha = 0.2f))
.clip(RoundedCornerShape(100.dp))
.background(
Brush.linearGradient(
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
),
)
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), RoundedCornerShape(100.dp))
.clickable(enabled = !sending, onClick = onSend),
contentAlignment = Alignment.Center,
) {
if (sending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/send-message-white.svg",
contentDescription = null,
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
Text(
"Отправить",
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
)
}
}
}
}
}
}
@Composable
private fun MailComposeAttachmentMenu(
serverUrl: String,
onUploadFromDevice: () -> Unit,
onPickFromFiles: () -> Unit,
onAddShareLink: () -> Unit,
onDismiss: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
Popup(
alignment = Alignment.BottomEnd,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
Column(
modifier = Modifier
.padding(end = 4.dp, bottom = 40.dp)
.width(320.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(vertical = 6.dp),
) {
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/mail/file-up-gray.svg",
title = "Загрузить файл с телефона",
onClick = onUploadFromDevice,
)
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/mail/folder-add-black.svg",
title = "Из приложения «Файлы»",
onClick = onPickFromFiles,
)
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/mail/sharing-icon-black.svg",
title = "Добавить ссылку для общего доступа из Файлов",
onClick = onAddShareLink,
)
}
}
}
@Composable
private fun MailComposeMoreMenu(
serverUrl: String,
requestMdn: Boolean,
onToggleRequestMdn: () -> Unit,
onSendLaterClick: () -> Unit,
onDismiss: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
Popup(
alignment = Alignment.BottomStart,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
Column(
modifier = Modifier
.padding(start = 12.dp, bottom = 72.dp)
.width(300.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(vertical = 6.dp),
) {
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/send-clock-icon.svg",
title = "Отправить позже",
onClick = onSendLaterClick,
)
MailComposeMenuRow(
iconUrl = if (requestMdn) {
"$base/themes/forbion/images/mail/checkbox-checked-green.svg"
} else {
"$base/themes/forbion/images/mail/checkbox-outline-gray.svg"
},
title = "Запросить подтверждение прочтения",
onClick = onToggleRequestMdn,
)
}
}
}
@Composable
private fun MailComposeSendLaterMenu(
options: List<MailSendLaterOption>,
selectedPreset: MailSendLaterPreset,
customSendAt: LocalDateTime,
onSelectPreset: (MailSendLaterPreset) -> Unit,
onCustomSendAtChange: (LocalDateTime) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
Popup(
alignment = Alignment.BottomStart,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
Column(
modifier = Modifier
.padding(start = 12.dp, bottom = 72.dp)
.width(320.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(12.dp)
.verticalScroll(rememberScrollState()),
) {
Text("Отправить позже", fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
options.forEach { option ->
val selected = option.preset == selectedPreset
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable { onSelectPreset(option.preset) }
.padding(horizontal = 8.dp, vertical = 8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(18.dp)
.clip(RoundedCornerShape(100.dp))
.border(
2.dp,
if (selected) F7Colors.Primary else F7Colors.Border,
RoundedCornerShape(100.dp),
)
.background(if (selected) F7Colors.Primary.copy(alpha = 0.15f) else Color.Transparent),
)
Spacer(Modifier.width(10.dp))
Text(option.title, fontSize = 14.sp)
}
if (option.preset == MailSendLaterPreset.CUSTOM && selected) {
Spacer(Modifier.height(8.dp))
val formatted = MailComposeSchedule.formatCustom(customSendAt)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.clickable {
val date = customSendAt.toLocalDate()
DatePickerDialog(
context,
{ _, year, month, day ->
val updatedDate = java.time.LocalDate.of(year, month + 1, day)
TimePickerDialog(
context,
{ _, hour, minute ->
onCustomSendAtChange(
LocalDateTime.of(updatedDate, java.time.LocalTime.of(hour, minute)),
)
},
customSendAt.hour,
customSendAt.minute,
true,
).show()
},
date.year,
date.monthValue - 1,
date.dayOfMonth,
).show()
}
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(formatted, fontSize = 14.sp)
}
}
}
}
}
}
}
@Composable
private fun MailComposeMenuRow(
iconUrl: String,
title: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = iconUrl,
contentDescription = null,
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
Text(title, fontSize = 14.sp, lineHeight = 18.sp)
}
}
@@ -0,0 +1,396 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.feature.contacts.ContactItem
import ru.forbion.f7cloud.feature.contacts.ContactRecipientHelper
import ru.forbion.f7cloud.feature.contacts.ContactsRepository
import ru.forbion.f7cloud.feature.files.FilesApiRepository
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
enum class MailRecipientField {
TO,
CC,
BCC,
}
data class MailComposeUiState(
val to: String = "",
val cc: String = "",
val bcc: String = "",
val subject: String = "",
val showCcBcc: Boolean = false,
val requestMdn: Boolean = false,
val toolbarVisible: Boolean = false,
val moreMenuOpen: Boolean = false,
val sendLaterMenuOpen: Boolean = false,
val selectedSendPreset: MailSendLaterPreset = MailSendLaterPreset.NOW,
val customSendAt: LocalDateTime = LocalDateTime.now().withSecond(0).withNano(0),
val attachments: List<MailComposeAttachment> = emptyList(),
val uploadingAttachments: Boolean = false,
val attachmentMenuOpen: Boolean = false,
val sending: Boolean = false,
val error: String? = null,
val finished: Boolean = false,
val activeRecipientField: MailRecipientField? = null,
val recipientSuggestions: List<ContactItem> = emptyList(),
)
class MailComposeViewModel(
private val launch: MailComposeLaunch,
private val repository: MailRepository = MailRepository(),
private val filesApiRepository: FilesApiRepository = FilesApiRepository(),
contactsRepository: ContactsRepository? = null,
) : ViewModel() {
private val contactsRepository = contactsRepository
private val _state = MutableStateFlow(MailComposeUiState())
val state: StateFlow<MailComposeUiState> = _state.asStateFlow()
private var cachedContacts: List<ContactItem> = emptyList()
private var nextCloudAttachmentId = -1
private var pendingFilesPickMode: MailFilesPickMode? = null
init {
val customSendAt = launch.initialCustomSendAtEpochSeconds?.let { epoch ->
LocalDateTime.ofInstant(Instant.ofEpochSecond(epoch), ZoneId.systemDefault())
} ?: LocalDateTime.now().withSecond(0).withNano(0)
_state.value = MailComposeUiState(
to = launch.initialTo,
cc = launch.initialCc,
subject = launch.initialSubject,
showCcBcc = launch.showCcBcc || launch.initialCc.isNotBlank(),
selectedSendPreset = launch.initialSendPreset,
customSendAt = customSendAt,
)
}
val sendLaterOptions: List<MailSendLaterOption> = MailComposeSchedule.options()
val senderLabel: String
get() {
val email = launch.accountEmail
val name = launch.accountName?.takeIf { it.isNotBlank() }
return if (name != null) "$name <$email>" else email
}
fun initContacts(session: AuthSession) {
val repo = contactsRepository ?: return
viewModelScope.launch {
launch(Dispatchers.IO) {
runCatching { repo.syncContacts(session) }
}
repo.observeContacts(session).collect { contacts ->
cachedContacts = contacts
val active = _state.value.activeRecipientField
if (active != null) {
val current = currentValue(active)
updateSuggestions(active, current)
}
}
}
}
fun setTo(value: String) = updateRecipient(MailRecipientField.TO, value)
fun setCc(value: String) = updateRecipient(MailRecipientField.CC, value)
fun setBcc(value: String) = updateRecipient(MailRecipientField.BCC, value)
fun selectSuggestion(field: MailRecipientField, contact: ContactItem) {
val current = currentValue(field)
val formatted = ContactRecipientHelper.formatRecipient(contact.displayName, contact.email)
val updated = ContactRecipientHelper.replaceCurrentToken(current, formatted) + ", "
_state.update {
when (field) {
MailRecipientField.TO -> it.copy(
to = updated,
activeRecipientField = field,
recipientSuggestions = emptyList(),
)
MailRecipientField.CC -> it.copy(
cc = updated,
activeRecipientField = field,
recipientSuggestions = emptyList(),
)
MailRecipientField.BCC -> it.copy(
bcc = updated,
activeRecipientField = field,
recipientSuggestions = emptyList(),
)
}
}
}
fun dismissSuggestions() {
_state.update { it.copy(activeRecipientField = null, recipientSuggestions = emptyList()) }
}
fun setSubject(value: String) = _state.update { it.copy(subject = value) }
fun toggleCcBcc() = _state.update { it.copy(showCcBcc = !it.showCcBcc) }
fun toggleToolbar() = _state.update {
it.copy(
toolbarVisible = !it.toolbarVisible,
moreMenuOpen = false,
sendLaterMenuOpen = false,
attachmentMenuOpen = false,
)
}
fun setMoreMenuOpen(open: Boolean) = _state.update {
it.copy(moreMenuOpen = open, sendLaterMenuOpen = false, attachmentMenuOpen = false)
}
fun setSendLaterMenuOpen(open: Boolean) = _state.update { it.copy(sendLaterMenuOpen = open, moreMenuOpen = false) }
fun toggleRequestMdn() = _state.update { it.copy(requestMdn = !it.requestMdn) }
fun selectSendPreset(preset: MailSendLaterPreset) {
_state.update {
it.copy(
selectedSendPreset = preset,
sendLaterMenuOpen = preset != MailSendLaterPreset.CUSTOM,
)
}
}
fun setCustomSendAt(value: LocalDateTime) {
_state.update {
it.copy(
customSendAt = value,
selectedSendPreset = MailSendLaterPreset.CUSTOM,
)
}
}
fun setAttachmentMenuOpen(open: Boolean) = _state.update {
it.copy(attachmentMenuOpen = open, moreMenuOpen = false, sendLaterMenuOpen = false)
}
fun prepareFilesPick(mode: MailFilesPickMode) {
pendingFilesPickMode = mode
setAttachmentMenuOpen(false)
}
fun filesPickIntent(context: Context, session: AuthSession): Intent? {
val mode = pendingFilesPickMode ?: return null
val pickMode = when (mode) {
MailFilesPickMode.ATTACHMENT -> MailFilesPickerActivity.MODE_ATTACHMENT
MailFilesPickMode.SHARE_LINK -> MailFilesPickerActivity.MODE_SHARE_LINK
}
return MailFilesPickerActivity.intent(context, session, pickMode)
}
fun cancelFilesPick() {
pendingFilesPickMode = null
}
fun handleFilesPickResult(
data: Intent,
session: AuthSession,
editor: MailRichTextEditorController,
onUnauthorized: () -> Unit,
) {
val mode = pendingFilesPickMode ?: return
pendingFilesPickMode = null
val path = data.getStringExtra(MailFilesPickerActivity.RESULT_PATH).orEmpty()
if (path.isBlank()) return
when (mode) {
MailFilesPickMode.ATTACHMENT -> addCloudAttachment(
path = path,
fileName = data.getStringExtra(MailFilesPickerActivity.RESULT_NAME).orEmpty().ifBlank {
path.substringAfterLast('/')
},
mimeType = data.getStringExtra(MailFilesPickerActivity.RESULT_MIME).orEmpty()
.ifBlank { "application/octet-stream" },
size = data.getLongExtra(MailFilesPickerActivity.RESULT_SIZE, 0L).takeIf { it > 0L },
)
MailFilesPickMode.SHARE_LINK -> insertShareLinkFromFile(
session = session,
path = path,
editor = editor,
onUnauthorized = onUnauthorized,
)
}
}
fun addAttachmentsFromUris(
context: Context,
session: AuthSession,
uris: List<android.net.Uri>,
onUnauthorized: () -> Unit,
) {
if (uris.isEmpty() || _state.value.uploadingAttachments) return
viewModelScope.launch {
_state.update { it.copy(uploadingAttachments = true, error = null) }
try {
val uploaded = kotlinx.coroutines.withContext(Dispatchers.IO) {
buildList {
uris.forEach { uri ->
val (name, bytes, mime) = MailAttachmentIO.readUri(context, uri)
add(repository.uploadLocalAttachment(session, name, bytes, mime))
}
}
}
_state.update {
it.copy(
attachments = it.attachments + uploaded,
uploadingAttachments = false,
)
}
} catch (_: UnauthorizedException) {
_state.update { it.copy(uploadingAttachments = false) }
onUnauthorized()
} catch (e: Exception) {
_state.update {
it.copy(
uploadingAttachments = false,
error = e.message ?: "Не удалось загрузить вложение",
)
}
}
}
}
private fun addCloudAttachment(
path: String,
fileName: String,
mimeType: String,
size: Long?,
) {
val cloudPath = if (path.startsWith("/")) path else "/$path"
val attachment = MailComposeAttachment(
id = nextCloudAttachmentId--,
fileName = fileName,
mimeType = mimeType,
type = MailComposeAttachmentType.CLOUD,
cloudPath = cloudPath,
size = size,
)
_state.update { it.copy(attachments = it.attachments + attachment) }
}
fun insertShareLinkFromFile(
session: AuthSession,
path: String,
editor: MailRichTextEditorController,
onUnauthorized: () -> Unit,
) {
if (_state.value.uploadingAttachments) return
viewModelScope.launch {
_state.update { it.copy(uploadingAttachments = true, error = null) }
try {
val shareUrl = kotlinx.coroutines.withContext(Dispatchers.IO) {
filesApiRepository.createPublicShareLink(session, path)
}
val html = """<a href="$shareUrl">$shareUrl</a>"""
editor.insertHtml(html)
_state.update { it.copy(uploadingAttachments = false) }
} catch (_: UnauthorizedException) {
_state.update { it.copy(uploadingAttachments = false) }
onUnauthorized()
} catch (e: Exception) {
_state.update {
it.copy(
uploadingAttachments = false,
error = e.message ?: "Не удалось создать ссылку",
)
}
}
}
}
fun removeAttachment(id: Int) {
_state.update { it.copy(attachments = it.attachments.filterNot { att -> att.id == id }) }
}
fun send(
session: AuthSession,
editor: MailRichTextEditorController,
onUnauthorized: () -> Unit,
) {
val current = _state.value
if (current.sending) return
val recipients = repository.parseRecipients(current.to)
if (recipients.isEmpty()) {
_state.update { it.copy(error = "Укажите получателя") }
return
}
_state.update { it.copy(sending = true, error = null, recipientSuggestions = emptyList()) }
viewModelScope.launch {
try {
val bodyHtml = editor.html()
val bodyPlain = editor.plainText()
val sendAt = when (current.selectedSendPreset) {
MailSendLaterPreset.NOW -> null
MailSendLaterPreset.TOMORROW_MORNING ->
sendLaterOptions.first { it.preset == MailSendLaterPreset.TOMORROW_MORNING }.sendAtEpochSeconds?.toInt()
MailSendLaterPreset.TOMORROW_AFTERNOON ->
sendLaterOptions.first { it.preset == MailSendLaterPreset.TOMORROW_AFTERNOON }.sendAtEpochSeconds?.toInt()
MailSendLaterPreset.MONDAY_MORNING ->
sendLaterOptions.first { it.preset == MailSendLaterPreset.MONDAY_MORNING }.sendAtEpochSeconds?.toInt()
MailSendLaterPreset.CUSTOM ->
current.customSendAt.atZone(java.time.ZoneId.systemDefault()).toEpochSecond().toInt()
}
repository.createAndSendMessage(
session = session,
accountId = launch.accountId,
to = recipients,
cc = repository.parseRecipients(current.cc),
bcc = repository.parseRecipients(current.bcc),
subject = current.subject,
bodyHtml = bodyHtml,
bodyPlain = bodyPlain,
editorBody = bodyHtml,
requestMdn = current.requestMdn,
sendAt = sendAt,
attachments = current.attachments,
)
_state.update { it.copy(sending = false, finished = true) }
} catch (_: UnauthorizedException) {
_state.update { it.copy(sending = false) }
onUnauthorized()
} catch (e: Exception) {
_state.update { it.copy(sending = false, error = e.message ?: "Ошибка отправки") }
}
}
}
private fun updateRecipient(field: MailRecipientField, value: String) {
_state.update {
when (field) {
MailRecipientField.TO -> it.copy(to = value, activeRecipientField = field)
MailRecipientField.CC -> it.copy(cc = value, activeRecipientField = field)
MailRecipientField.BCC -> it.copy(bcc = value, activeRecipientField = field)
}
}
updateSuggestions(field, value)
}
private fun updateSuggestions(field: MailRecipientField, value: String) {
val repo = contactsRepository
val token = ContactRecipientHelper.currentToken(value)
val suggestions = if (repo != null && token.isNotBlank()) {
repo.filterSuggestions(cachedContacts, token)
} else {
emptyList()
}
_state.update {
it.copy(
activeRecipientField = field,
recipientSuggestions = suggestions,
)
}
}
private fun currentValue(field: MailRecipientField): String = when (field) {
MailRecipientField.TO -> _state.value.to
MailRecipientField.CC -> _state.value.cc
MailRecipientField.BCC -> _state.value.bcc
}
}
@@ -0,0 +1,262 @@
package ru.forbion.f7cloud.feature.mail
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.feature.files.FileItem
import ru.forbion.f7cloud.feature.files.FilesRepository
class MailFilesPickerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val session = readSession() ?: run {
finish()
return
}
val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_ATTACHMENT
val title = if (mode == MODE_SHARE_LINK) {
"Ссылка из «Файлов»"
} else {
"Выбор файла"
}
setContent {
F7Theme {
MailFilesPickerScreen(
session = session,
title = title,
onCancel = { finish() },
onUnauthorized = { finish() },
onFileSelected = { file ->
setResult(
Activity.RESULT_OK,
Intent().apply {
putExtra(RESULT_PATH, "/${file.relativePath.trim('/')}")
putExtra(RESULT_NAME, file.name)
putExtra(RESULT_SIZE, file.size ?: 0L)
putExtra(RESULT_MIME, file.mimeType.orEmpty())
},
)
finish()
},
)
}
}
}
private fun readSession(): AuthSession? {
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty()
val password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty()
if (username.isBlank() || serverUrl.isBlank() || password.isBlank()) return null
return AuthSession(
serverUrl = serverUrl,
username = username,
appPassword = password,
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
)
}
companion object {
const val EXTRA_USERNAME = "username"
const val EXTRA_PASSWORD = "password"
const val EXTRA_SERVER_URL = "server_url"
const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
const val EXTRA_MODE = "mode"
const val MODE_ATTACHMENT = "attachment"
const val MODE_SHARE_LINK = "share_link"
const val RESULT_PATH = "result_path"
const val RESULT_NAME = "result_name"
const val RESULT_SIZE = "result_size"
const val RESULT_MIME = "result_mime"
fun intent(context: Context, session: AuthSession, mode: String): Intent =
Intent(context, MailFilesPickerActivity::class.java).apply {
putExtra(EXTRA_USERNAME, session.username)
putExtra(EXTRA_PASSWORD, session.appPassword)
putExtra(EXTRA_SERVER_URL, session.serverUrl)
putExtra(EXTRA_TRUST_ALL_CERTS, session.trustAllCerts)
putExtra(EXTRA_MODE, mode)
}
}
}
@Composable
private fun MailFilesPickerScreen(
session: AuthSession,
title: String,
onCancel: () -> Unit,
onUnauthorized: () -> Unit,
onFileSelected: (FileItem) -> Unit,
) {
val context = LocalContext.current
val repository = remember { FilesRepository(context.applicationContext) }
var currentPath by remember { mutableStateOf("") }
var items by remember { mutableStateOf<List<FileItem>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val base = session.serverUrl.trimEnd('/')
LaunchedEffect(session.serverUrl, session.username, currentPath) {
loading = true
error = null
try {
items = withContext(Dispatchers.IO) {
repository.listFolder(session, currentPath)
.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
}
} catch (_: UnauthorizedException) {
onUnauthorized()
} catch (e: Exception) {
error = e.message ?: "Не удалось загрузить файлы"
items = emptyList()
} finally {
loading = false
}
}
BackHandler(enabled = currentPath.isNotBlank()) {
currentPath = parentPath(currentPath)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(F7Colors.Surface),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = {
if (currentPath.isBlank()) onCancel() else currentPath = parentPath(currentPath)
}),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/arrow-back-gray.svg",
contentDescription = "Назад",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(title, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(
if (currentPath.isBlank()) "Файлы" else currentPath,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
when {
loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
error != null -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(error.orEmpty(), color = F7Colors.TextSecondary)
}
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { it.relativePath }) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (item.isDirectory) {
currentPath = item.relativePath
} else {
onFileSelected(item)
}
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = if (item.isDirectory) {
"$base/themes/forbion/images/mail/folder-add-black.svg"
} else {
"$base/themes/forbion/images/mail/file-up-gray.svg"
},
contentDescription = null,
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Text(
item.name,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
private fun parentPath(path: String): String {
val trimmed = path.trim('/')
if (trimmed.isBlank()) return ""
val index = trimmed.lastIndexOf('/')
return if (index < 0) "" else trimmed.substring(0, index)
}
@@ -0,0 +1,153 @@
package ru.forbion.f7cloud.feature.mail
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import ru.forbion.f7cloud.core.auth.AuthSession
import java.util.concurrent.TimeUnit
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun MailMessageBodyView(
session: AuthSession,
messageId: Int,
html: String,
attachments: List<MailAttachment>,
client: OkHttpClient,
modifier: Modifier = Modifier,
) {
val baseUrl = "${session.serverUrl.trimEnd('/')}/"
val apiBase = "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/api"
val serverPrefix = remember(session.serverUrl) { session.serverUrl.trimEnd('/') }
val externalClient = remember {
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
val bodyHtml = remember(html, messageId, apiBase, attachments) {
MailBodyHtml.rewriteImageSources(
html = MailBodyHtml.normalizeForDisplay(html),
messageId = messageId,
apiBase = apiBase,
attachments = attachments,
)
}
val wrapped = remember(bodyHtml, baseUrl) {
"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${MailBodyHtml.READER_CSS}</style>
</head>
<body>$bodyHtml</body>
</html>
""".trimIndent()
}
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = false
settings.domStorageEnabled = false
settings.loadsImagesAutomatically = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
isVerticalScrollBarEnabled = true
isNestedScrollingEnabled = true
setBackgroundColor(0x00000000)
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest,
): WebResourceResponse? {
val url = request.url?.toString() ?: return null
if (url.contains("/apps/mail/proxy", ignoreCase = true)) {
val directUrl = MailBodyHtml.unwrapProxyUrl(url)
if (directUrl != url) {
return fetchExternalImage(externalClient, directUrl)
}
}
if (!url.startsWith(serverPrefix, ignoreCase = true)) {
return null
}
if (!isServerMailResource(url)) {
return null
}
return fetchAuthedResource(client, url)
}
}
}
},
update = { webView ->
webView.onResume()
webView.resumeTimers()
val loadedId = webView.tag as? Int
if (loadedId != messageId) {
webView.tag = messageId
webView.loadDataWithBaseURL(baseUrl, wrapped, "text/html", "UTF-8", null)
}
},
onRelease = { webView ->
runCatching {
webView.stopLoading()
webView.onPause()
webView.pauseTimers()
webView.loadUrl("about:blank")
webView.webViewClient = WebViewClient()
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
},
)
}
private fun isServerMailResource(url: String): Boolean =
url.contains("/apps/mail/", ignoreCase = true)
private fun fetchAuthedResource(client: OkHttpClient, url: String): WebResourceResponse? =
runCatching {
val httpRequest = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.build()
client.newCall(httpRequest).execute().use(::toWebResourceResponse)
}.getOrNull()
private fun fetchExternalImage(client: OkHttpClient, url: String): WebResourceResponse? =
runCatching {
val httpRequest = Request.Builder().url(url).build()
client.newCall(httpRequest).execute().use(::toWebResourceResponse)
}.getOrNull()
private fun toWebResourceResponse(response: Response): WebResourceResponse? {
if (!response.isSuccessful || response.body == null) return null
val body = response.body!!
val contentType = body.contentType()
val mimeType = contentType?.let { "${it.type}/${it.subtype}" } ?: "application/octet-stream"
val encoding = when {
mimeType.startsWith("image/") -> null
mimeType.startsWith("video/") -> null
mimeType.startsWith("audio/") -> null
mimeType == "application/octet-stream" -> null
else -> contentType?.charset()?.name() ?: "utf-8"
}
return WebResourceResponse(mimeType, encoding, body.byteStream())
}
@@ -0,0 +1,332 @@
package ru.forbion.f7cloud.feature.mail
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
enum class MailListFilter {
ALL,
UNREAD,
STARRED,
}
data class MailSearchParams(
val subject: String = "",
val body: String = "",
val dateStart: String = "",
val dateEnd: String = "",
val from: String = "",
val to: String = "",
val cc: String = "",
val bcc: String = "",
val tags: String = "",
val important: Boolean = false,
val starred: Boolean = false,
val unread: Boolean = false,
val hasAttachments: Boolean = false,
val mentionsMe: Boolean = false,
) {
fun isActive(): Boolean = subject.isNotBlank() ||
body.isNotBlank() ||
dateStart.isNotBlank() ||
dateEnd.isNotBlank() ||
from.isNotBlank() ||
to.isNotBlank() ||
cc.isNotBlank() ||
bcc.isNotBlank() ||
tags.isNotBlank() ||
important ||
starred ||
unread ||
hasAttachments ||
mentionsMe
fun toFilterString(): String {
val parts = mutableListOf<String>()
fun addToken(prefix: String, value: String) {
val trimmed = value.trim()
if (trimmed.isNotBlank()) parts += "$prefix$trimmed"
}
addToken("subject:", subject)
addToken("body:", body)
addToken("start:", dateStart)
addToken("end:", dateEnd)
addToken("from:", from)
addToken("to:", to)
addToken("cc:", cc)
addToken("bcc:", bcc)
addToken("tags:", tags)
if (important) parts += "is:important"
if (starred) parts += "is:starred"
if (unread) parts += "is:unread"
if (hasAttachments) parts += "flags:attachments"
if (mentionsMe) parts += "mentions:true"
return parts.joinToString(" ")
}
}
enum class MailQuickFilter(val label: String) {
MENTIONS_ME("Мои"),
HAS_ATTACHMENTS("Имеет вложения"),
LAST_7_DAYS("Последние 7 дней"),
}
fun mailLast7DaysStartDate(): String {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -7)
return SimpleDateFormat("yyyy-MM-dd", Locale.US).format(calendar.time)
}
fun MailSearchParams.isLast7DaysActive(): Boolean =
dateStart == mailLast7DaysStartDate() && dateEnd.isBlank()
fun MailSearchParams.isQuickFilterActive(filter: MailQuickFilter): Boolean = when (filter) {
MailQuickFilter.MENTIONS_ME -> mentionsMe
MailQuickFilter.HAS_ATTACHMENTS -> hasAttachments
MailQuickFilter.LAST_7_DAYS -> isLast7DaysActive()
}
fun MailSearchParams.toggleQuickFilter(filter: MailQuickFilter): MailSearchParams = when (filter) {
MailQuickFilter.MENTIONS_ME -> copy(mentionsMe = !mentionsMe)
MailQuickFilter.HAS_ATTACHMENTS -> copy(hasAttachments = !hasAttachments)
MailQuickFilter.LAST_7_DAYS -> if (isLast7DaysActive()) {
copy(dateStart = "", dateEnd = "")
} else {
copy(dateStart = mailLast7DaysStartDate(), dateEnd = "")
}
}
sealed interface MailInboxListItem {
data class YearHeader(val year: Int) : MailInboxListItem
data class MessageItem(val message: MailMessage) : MailInboxListItem
}
fun messageYear(dateInt: Long): Int? {
if (dateInt <= 0) return null
val calendar = Calendar.getInstance()
calendar.timeInMillis = dateInt * 1000L
return calendar.get(Calendar.YEAR)
}
fun buildMailInboxListItems(messages: List<MailMessage>): List<MailInboxListItem> {
if (messages.isEmpty()) return emptyList()
val out = mutableListOf<MailInboxListItem>()
var lastYear: Int? = null
for (message in messages) {
val year = messageYear(message.dateInt)
if (year != null && year != lastYear) {
out += MailInboxListItem.YearHeader(year)
lastYear = year
}
out += MailInboxListItem.MessageItem(message)
}
return out
}
fun mailFolderListTitle(
folder: MailFolderEntry?,
searchQuery: String,
searchParams: MailSearchParams,
): String? {
if (folder?.specialRole == "outbox") return null
if (searchQuery.isNotBlank() || searchParams.isActive()) return "Результаты поиска"
if (folder?.specialRole == "inbox" && folder.filter == MailListFilter.ALL) return null
return folder?.title
}
data class MailAccount(
val id: Int,
val email: String,
val name: String,
val draftsMailboxId: Int?,
val sentMailboxId: Int?,
val trashMailboxId: Int?,
val archiveMailboxId: Int?,
val junkMailboxId: Int?,
val snoozeMailboxId: Int? = null,
)
data class MailMailbox(
val id: Int,
val accountId: Int,
val displayName: String,
val name: String,
val specialRole: String?,
val unread: Int,
val isInbox: Boolean,
)
data class MailFolderEntry(
val mailboxId: Int,
val accountId: Int,
val title: String,
val specialRole: String?,
val filter: MailListFilter,
val unread: Int = 0,
) {
fun cacheKey(): String = "${mailboxId}_${filter.name}"
}
data class MailMessageFlags(
val seen: Boolean,
val flagged: Boolean,
val hasAttachments: Boolean,
val answered: Boolean,
val important: Boolean = false,
)
data class MailTag(
val id: Long = 0,
val displayName: String,
val colorHex: String = "",
val imapLabel: String = "",
)
data class MailMessage(
val id: Int,
val subject: String,
val from: String,
val fromEmail: String,
val preview: String,
val dateInt: Long,
val flags: MailMessageFlags,
val tags: List<MailTag> = emptyList(),
)
data class MailAttachment(
val id: String,
val fileName: String,
val mime: String,
val size: Long,
val cid: String? = null,
val downloadUrl: String? = null,
)
data class MailMessageDetail(
val id: Int,
val subject: String,
val from: String,
val fromEmail: String,
val to: String,
val cc: String,
val dateInt: Long,
val bodyHtml: String,
val bodyPlain: String,
val hasHtmlBody: Boolean,
val flags: MailMessageFlags,
val attachments: List<MailAttachment>,
val tags: List<MailTag> = emptyList(),
)
fun MailMessage.toDetailStub(): MailMessageDetail =
MailMessageDetail(
id = id,
subject = subject,
from = from,
fromEmail = fromEmail,
to = "",
cc = "",
dateInt = dateInt,
bodyHtml = "",
bodyPlain = "",
hasHtmlBody = false,
flags = flags,
attachments = emptyList(),
tags = tags,
)
fun MailMessageDetail.hasBodyContent(): Boolean =
bodyHtml.isNotBlank() || bodyPlain.isNotBlank()
private val HTML_BODY_HINT = Regex("""^\s*<(?:!DOCTYPE|html|head|body|table|div|p|span|br|meta)\b""", RegexOption.IGNORE_CASE)
fun MailMessageDetail.looksLikeHtml(): Boolean =
hasHtmlBody ||
HTML_BODY_HINT.containsMatchIn(bodyHtml) ||
HTML_BODY_HINT.containsMatchIn(bodyPlain)
fun MailMessageDetail.htmlBodyForDisplay(): String = when {
bodyHtml.isNotBlank() -> bodyHtml
bodyPlain.isNotBlank() && HTML_BODY_HINT.containsMatchIn(bodyPlain) -> bodyPlain
else -> ""
}
fun MailMessageDetail.plainBodyForDisplay(): String {
if (bodyPlain.isNotBlank() && !HTML_BODY_HINT.containsMatchIn(bodyPlain)) return bodyPlain
if (bodyHtml.isNotBlank() && !HTML_BODY_HINT.containsMatchIn(bodyHtml)) return bodyHtml
return ""
}
data class MailBootstrap(
val accounts: List<MailAccount>,
val mailboxes: List<MailMailbox>,
val folders: List<MailFolderEntry>,
val selectedFolder: MailFolderEntry?,
)
data class MailMessagesPage(
val messages: List<MailMessage>,
val nextCursor: Int?,
)
data class MailAppSettings(
val showThreaded: Boolean = true,
val highlightExternalAddresses: Boolean = false,
val allowNewAccounts: Boolean = true,
val trustedSenders: List<MailTrustedSender> = emptyList(),
val textBlocks: List<MailTextBlock> = emptyList(),
)
data class MailTrustedSender(
val id: Int,
val email: String,
val type: String,
)
data class MailTextBlock(
val id: Int,
val title: String,
val content: String,
val preview: String = "",
)
data class MailAccountSettings(
val id: Int,
val email: String,
val name: String,
val signature: String,
val draftsMailboxId: Int?,
val sentMailboxId: Int?,
val trashMailboxId: Int?,
val archiveMailboxId: Int?,
val junkMailboxId: Int?,
val searchBody: Boolean,
val classificationEnabled: Boolean,
val signatureAboveQuote: Boolean,
val imipCreate: Boolean,
val quotaPercentage: Int?,
val imapHost: String?,
val smtpHost: String?,
)
data class MailOutboxMessage(
val id: Int,
val accountId: Int,
val subject: String,
val toRecipients: String,
val preview: String,
val updatedAt: Long,
val sendAt: Long?,
val failed: Boolean,
val status: Int,
)
object MailVirtualFolders {
val OUTBOX = MailFolderEntry(
mailboxId = 0,
accountId = 0,
title = "Исходящие",
specialRole = "outbox",
filter = MailListFilter.ALL,
)
}
@@ -0,0 +1,109 @@
package ru.forbion.f7cloud.feature.mail
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.feature.contacts.ContactItem
import ru.forbion.f7cloud.feature.contacts.ContactListRow
@Composable
fun MailRecipientComposeField(
label: String,
value: String,
onValueChange: (String) -> Unit,
suggestions: List<ContactItem>,
showSuggestions: Boolean,
onSuggestionClick: (ContactItem) -> Unit,
onDismissSuggestions: () -> Unit,
trailingIconUrl: String? = null,
onTrailingClick: (() -> Unit)? = null,
) {
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
}
inner()
},
)
if (trailingIconUrl != null && onTrailingClick != null) {
AsyncImage(
model = trailingIconUrl,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.clickable(onClick = onTrailingClick),
contentScale = ContentScale.Fit,
)
}
}
if (showSuggestions && suggestions.isNotEmpty()) {
Popup(
alignment = Alignment.TopStart,
onDismissRequest = onDismissSuggestions,
properties = PopupProperties(focusable = false),
) {
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 2.dp)
.fillMaxWidth()
.heightIn(max = 240.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)),
) {
suggestions.forEachIndexed { index, contact ->
ContactListRow(
contact = contact,
onClick = { onSuggestionClick(contact) },
)
if (index < suggestions.lastIndex) {
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
}
}
}
}
}
}
}
@@ -0,0 +1,121 @@
package ru.forbion.f7cloud.feature.mail
object MailReplyHelper {
fun extractEmail(raw: String): String {
if (raw.isBlank()) return ""
val match = Regex("<([^>]+)>").find(raw)
return match?.groupValues?.get(1)?.trim() ?: raw.trim()
}
fun replySubject(subject: String): String {
val trimmed = subject.trim().ifBlank { "(без темы)" }
return if (trimmed.startsWith("Re:", ignoreCase = true)) trimmed else "Re: $trimmed"
}
fun forwardSubject(subject: String): String {
val trimmed = subject.trim().ifBlank { "(без темы)" }
return when {
trimmed.startsWith("Fwd:", ignoreCase = true) -> trimmed
trimmed.startsWith("Fw:", ignoreCase = true) -> trimmed
else -> "Fwd: $trimmed"
}
}
fun buildComposeLaunch(
base: MailComposeLaunch,
detail: MailMessageDetail,
mode: MailComposeMode,
accountEmail: String,
): MailComposeLaunch {
val senderEmail = extractEmail(detail.from)
val senderLabel = detail.from.ifBlank { senderEmail }.ifBlank { "Неизвестный" }
val quotedBody = buildQuotedBodyHtml(detail, mode)
return when (mode) {
MailComposeMode.REPLY -> base.copy(
mode = mode,
initialTo = formatRecipient(senderLabel, senderEmail),
initialSubject = replySubject(detail.subject),
initialBodyHtml = quotedBody,
showCcBcc = false,
)
MailComposeMode.REPLY_ALL -> {
val cc = buildReplyAllCc(detail, accountEmail, senderEmail)
base.copy(
mode = mode,
initialTo = formatRecipient(senderLabel, senderEmail),
initialCc = cc,
initialSubject = replySubject(detail.subject),
initialBodyHtml = quotedBody,
showCcBcc = cc.isNotBlank(),
)
}
MailComposeMode.FORWARD -> base.copy(
mode = mode,
initialSubject = forwardSubject(detail.subject),
initialBodyHtml = quotedBody,
showCcBcc = false,
)
MailComposeMode.NEW -> base
}
}
private fun formatRecipient(label: String, email: String): String {
if (email.isBlank()) return label
if (label.isBlank() || label.equals(email, ignoreCase = true)) return email
return "$label <$email>"
}
private fun buildReplyAllCc(
detail: MailMessageDetail,
accountEmail: String,
senderEmail: String,
): String {
val own = accountEmail.lowercase()
val sender = senderEmail.lowercase()
return (detail.to + "," + detail.cc)
.split(',', ';')
.map { token -> extractEmail(token.trim()).ifBlank { token.trim() } }
.filter { email ->
email.isNotBlank() &&
!email.equals(own, ignoreCase = true) &&
!email.equals(sender, ignoreCase = true)
}
.distinct()
.joinToString(", ")
}
private fun buildQuotedBodyHtml(detail: MailMessageDetail, mode: MailComposeMode): String {
val body = if (detail.hasHtmlBody && detail.bodyHtml.isNotBlank()) {
MailBodyHtml.normalizeForDisplay(detail.bodyHtml)
} else {
val plain = MailBodyHtml.normalizePlainForDisplay(
detail.bodyPlain.ifBlank { detail.bodyHtml },
)
plain.replace("\n", "<br>")
}
val date = formatMessageDate(detail.dateInt)
val from = detail.from.ifBlank { "Неизвестный" }
val header = if (mode == MailComposeMode.FORWARD) {
"""
<p>-------- Пересылаемое сообщение --------</p>
<p><b>От:</b> ${escapeHtml(from)}</p>
<p><b>Дата:</b> ${escapeHtml(date)}</p>
<p><b>Тема:</b> ${escapeHtml(detail.subject)}</p>
<p><b>Кому:</b> ${escapeHtml(detail.to)}</p>
""".trimIndent()
} else {
"""
<p>${escapeHtml(date)}, ${escapeHtml(from)} писал(а):</p>
""".trimIndent()
}
return "<br><br><blockquote class=\"quote\">$header$body</blockquote>"
}
private fun escapeHtml(text: String): String =
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
@@ -0,0 +1,991 @@
package ru.forbion.f7cloud.feature.mail
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class MailRepository {
suspend fun loadBootstrap(session: AuthSession): MailBootstrap {
val client = authedClient(session)
val base = apiBase(session)
val accountsJson = getJsonArray(client, "$base/accounts")
if (accountsJson.length() == 0) {
return MailBootstrap(emptyList(), emptyList(), emptyList(), null)
}
val accounts = parseAccounts(accountsJson).distinctBy { it.email.lowercase() }
val allMailboxes = mutableListOf<MailMailbox>()
val allFolders = mutableListOf<MailFolderEntry>()
for (account in accounts) {
val mailboxes = parseMailboxes(getJson(client, "$base/mailboxes?accountId=${account.id}"))
allMailboxes += mailboxes
allFolders += buildFolderList(account, mailboxes)
}
val selected = allFolders.firstOrNull { it.specialRole == "inbox" && it.filter == MailListFilter.ALL }
?: allFolders.firstOrNull()
return MailBootstrap(accounts, allMailboxes, allFolders, selected)
}
suspend fun loadMessages(
session: AuthSession,
folder: MailFolderEntry,
searchQuery: String = "",
searchParams: MailSearchParams = MailSearchParams(),
cursor: Int? = null,
limit: Int = PAGE_SIZE,
): MailMessagesPage {
val client = authedClient(session)
val base = apiBase(session)
val filter = buildFilterQuery(folder.filter, searchQuery, searchParams)
val urlBuilder = StringBuilder("$base/messages?mailboxId=${folder.mailboxId}&limit=$limit&view=singleton")
if (filter.isNotBlank()) urlBuilder.append("&filter=").append(java.net.URLEncoder.encode(filter, "UTF-8"))
if (cursor != null) urlBuilder.append("&cursor=$cursor")
val messages = parseMessages(getJson(client, urlBuilder.toString()))
return pageFromMessages(messages, limit)
}
private fun pageFromMessages(messages: List<MailMessage>, limit: Int): MailMessagesPage {
// API cursor is sent_at (dateInt), not database message id.
val nextCursor = if (messages.size >= limit) {
messages.lastOrNull()?.dateInt?.takeIf { it > 0 }?.toInt()
} else {
null
}
return MailMessagesPage(messages, nextCursor)
}
suspend fun loadMessage(session: AuthSession, messageId: Int): MailMessageDetail {
val client = authedClient(session)
val base = apiBase(session)
val json = getJson(client, "$base/messages/$messageId/body") as JSONObject
val body = json.optString("body")
val hasHtml = json.optBoolean("hasHtmlBody", false)
val flagsJson = json.optJSONObject("flags")
return MailMessageDetail(
id = json.optInt("databaseId", messageId),
subject = json.optString("subject").ifBlank { "(без темы)" },
from = parseAddressLabel(json.opt("from")),
fromEmail = parseAddressEmail(json.opt("from")),
to = parseAddressList(json.opt("to")),
cc = parseAddressList(json.opt("cc")),
dateInt = json.optLong("dateInt", 0L),
bodyHtml = if (hasHtml) body else "",
bodyPlain = if (hasHtml) "" else body,
hasHtmlBody = hasHtml,
flags = parseFlags(flagsJson),
attachments = parseAttachments(json.optJSONArray("attachments"), session.serverUrl),
tags = parseTags(json.optJSONObject("tags")),
)
}
suspend fun loadThread(session: AuthSession, messageId: Int): List<MailMessage> {
val client = authedClient(session)
val base = apiBase(session)
return runCatching {
parseMessages(getJson(client, "$base/messages/$messageId/thread"))
}.getOrDefault(emptyList())
}
suspend fun loadAppSettings(session: AuthSession): MailAppSettings {
val client = authedClient(session)
val base = apiBase(session)
val showThreaded = loadPreference(client, base, "layout-message-view", "threaded") == "threaded"
val highlightExternal = loadPreference(client, base, "internal-addresses", "false") == "true"
val allowNewAccounts = loadPreference(client, base, "allow-new-accounts", "true") != "false"
val trustedSenders = loadTrustedSenders(client, base)
val textBlocks = loadTextBlocks(client, base)
return MailAppSettings(
showThreaded = showThreaded,
highlightExternalAddresses = highlightExternal,
allowNewAccounts = allowNewAccounts,
trustedSenders = trustedSenders,
textBlocks = textBlocks,
)
}
suspend fun savePreference(session: AuthSession, key: String, value: String): String {
val client = authedClient(session)
val payload = JSONObject().put("value", value).toString()
val request = Request.Builder()
.url("${apiBase(session)}/preferences/${java.net.URLEncoder.encode(key, "UTF-8")}")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.put(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val json = JSONObject(response.body!!.string())
return json.optString("value", value)
}
}
suspend fun removeTrustedSender(session: AuthSession, email: String, type: String) {
val client = authedClient(session)
val encodedEmail = java.net.URLEncoder.encode(email, "UTF-8")
val encodedType = java.net.URLEncoder.encode(type, "UTF-8")
val request = Request.Builder()
.url("${apiBase(session)}/trustedsenders/$encodedEmail?type=$encodedType")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun createTextBlock(session: AuthSession, title: String, content: String): MailTextBlock {
val client = authedClient(session)
val payload = JSONObject()
.put("title", title)
.put("content", content)
.toString()
val request = Request.Builder()
.url("${apiBase(session)}/textBlocks")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val json = unwrapData(JSONObject(response.body!!.string()))
return parseTextBlock(json as JSONObject)
}
}
suspend fun deleteTextBlock(session: AuthSession, id: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/textBlocks/$id")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun loadAccountSettings(session: AuthSession, accountId: Int): MailAccountSettings {
val client = authedClient(session)
val json = getJson(client, "${apiBase(session)}/accounts/$accountId") as JSONObject
return parseAccountSettings(json)
}
suspend fun patchAccountSettings(
session: AuthSession,
accountId: Int,
draftsMailboxId: Int? = null,
sentMailboxId: Int? = null,
trashMailboxId: Int? = null,
archiveMailboxId: Int? = null,
junkMailboxId: Int? = null,
searchBody: Boolean? = null,
classificationEnabled: Boolean? = null,
signatureAboveQuote: Boolean? = null,
imipCreate: Boolean? = null,
): MailAccountSettings {
val payload = JSONObject()
draftsMailboxId?.let { payload.put("draftsMailboxId", it) }
sentMailboxId?.let { payload.put("sentMailboxId", it) }
trashMailboxId?.let { payload.put("trashMailboxId", it) }
archiveMailboxId?.let { payload.put("archiveMailboxId", it) }
junkMailboxId?.let { payload.put("junkMailboxId", it) }
searchBody?.let { payload.put("searchBody", it) }
classificationEnabled?.let { payload.put("classificationEnabled", it) }
signatureAboveQuote?.let { payload.put("signatureAboveQuote", it) }
imipCreate?.let { payload.put("imipCreate", it) }
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/accounts/$accountId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.patch(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val body = response.body!!.string()
return parseAccountSettings(JSONObject(body))
}
}
suspend fun updateAccountSignature(session: AuthSession, accountId: Int, signature: String) {
val payload = JSONObject().put("signature", signature).toString()
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/accounts/$accountId/signature")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.put(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun loadOutboxMessages(session: AuthSession): List<MailOutboxMessage> {
val client = authedClient(session)
val json = getJson(client, "${apiBase(session)}/outbox")
return parseOutboxMessages(json)
}
suspend fun sendOutboxMessage(session: AuthSession, messageId: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/outbox/$messageId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось отправить (HTTP ${response.code})")
}
}
suspend fun deleteOutboxMessage(session: AuthSession, messageId: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/outbox/$messageId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось удалить (HTTP ${response.code})")
}
}
suspend fun setMessageFlags(
session: AuthSession,
messageId: Int,
seen: Boolean? = null,
flagged: Boolean? = null,
important: Boolean? = null,
) {
val flags = JSONObject()
seen?.let { flags.put("seen", it) }
flagged?.let { flags.put("flagged", it) }
important?.let { flags.put("important", it) }
if (flags.length() == 0) return
val body = JSONObject().put("flags", flags).toString()
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId/flags")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.put(body.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun moveMessage(session: AuthSession, messageId: Int, destMailboxId: Int) {
val client = authedClient(session)
val url = "${apiBase(session)}/messages/$messageId/move?destFolderId=$destMailboxId"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось переместить (HTTP ${response.code})")
}
}
suspend fun snoozeMessage(
session: AuthSession,
messageId: Int,
unixTimestamp: Long,
destMailboxId: Int,
) {
val client = authedClient(session)
val url = "${apiBase(session)}/messages/$messageId/snooze" +
"?unixTimestamp=$unixTimestamp&destMailboxId=$destMailboxId"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось отложить (HTTP ${response.code})")
}
}
suspend fun setMessageTag(session: AuthSession, messageId: Int, imapLabel: String, add: Boolean) {
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(imapLabel, Charsets.UTF_8.name())
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId/tags/$encoded")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.let { builder ->
if (add) {
builder.put("".toRequestBody("application/json".toMediaType()))
} else {
builder.delete()
}
}
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось изменить метку (HTTP ${response.code})")
}
}
suspend fun deleteMessage(session: AuthSession, messageId: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId")
.header("OCS-APIRequest", "true")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun downloadAttachment(
session: AuthSession,
messageId: Int,
attachmentId: String,
): ByteArray {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId/attachment/$attachmentId")
.header("OCS-APIRequest", "true")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Вложение HTTP ${response.code}")
}
return response.body!!.bytes()
}
}
fun composeUrl(session: AuthSession): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/compose"
}
suspend fun uploadLocalAttachment(
session: AuthSession,
fileName: String,
bytes: ByteArray,
mimeType: String,
): MailComposeAttachment {
val client = authedClient(session)
val mediaType = mimeType.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType()
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"attachment",
fileName,
bytes.toRequestBody(mediaType),
)
.build()
val request = Request.Builder()
.url("${apiBase(session)}/attachments")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить вложение (HTTP ${response.code})")
}
val json = JSONObject(response.body!!.string())
val data = json.optJSONObject("data") ?: json
val id = data.optInt("id", 0)
if (id <= 0) error("Не удалось загрузить вложение")
return MailComposeAttachment(
id = id,
fileName = data.optString("fileName", fileName),
mimeType = data.optString("mimeType", mimeType),
)
}
}
suspend fun createAndSendMessage(
session: AuthSession,
accountId: Int,
to: List<MailRecipient>,
cc: List<MailRecipient>,
bcc: List<MailRecipient>,
subject: String,
bodyHtml: String,
bodyPlain: String,
editorBody: String,
requestMdn: Boolean,
sendAt: Int?,
attachments: List<MailComposeAttachment> = emptyList(),
) {
val client = authedClient(session)
val payload = JSONObject().apply {
put("accountId", accountId)
put("subject", subject)
put("bodyPlain", bodyPlain)
put("bodyHtml", bodyHtml)
put("editorBody", editorBody)
put("isHtml", true)
put("smimeSign", false)
put("smimeEncrypt", false)
put("requestMdn", requestMdn)
put("isPgpMime", false)
put("to", recipientsJson(to))
put("cc", recipientsJson(cc))
put("bcc", recipientsJson(bcc))
put("attachments", attachmentsJson(attachments))
if (sendAt != null) put("sendAt", sendAt)
}
val createRequest = Request.Builder()
.url("${apiBase(session)}/outbox")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
val messageId = client.newCall(createRequest).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось создать письмо (HTTP ${response.code})")
}
val body = response.body!!.string()
val json = JSONObject(body)
val data = json.optJSONObject("data") ?: json
data.optInt("id", data.optInt("databaseId", 0))
}
if (messageId <= 0) error("Не удалось создать письмо")
val sendRequest = Request.Builder()
.url("${apiBase(session)}/outbox/$messageId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(sendRequest).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Не удалось отправить письмо (HTTP ${response.code})")
}
}
}
private fun attachmentsJson(attachments: List<MailComposeAttachment>): JSONArray {
val array = JSONArray()
attachments.forEach { attachment ->
array.put(
when (attachment.type) {
MailComposeAttachmentType.LOCAL -> JSONObject().apply {
put("type", "local")
put("id", attachment.id)
}
MailComposeAttachmentType.CLOUD -> JSONObject().apply {
put("type", "cloud")
put(
"fileName",
attachment.cloudPath ?: "/${attachment.fileName.trim('/')}",
)
attachment.size?.let { put("size", it) }
}
},
)
}
return array
}
private fun recipientsJson(recipients: List<MailRecipient>): JSONArray {
val array = JSONArray()
recipients.forEach { recipient ->
array.put(
JSONObject().apply {
put("email", recipient.email)
put("label", recipient.label.ifBlank { recipient.email })
},
)
}
return array
}
fun parseRecipients(raw: String): List<MailRecipient> {
if (raw.isBlank()) return emptyList()
return raw.split(',', ';')
.map { it.trim() }
.filter { it.isNotBlank() }
.map { token ->
val match = Regex("<([^>]+)>").find(token)
if (match != null) {
val email = match.groupValues[1].trim()
val label = token.replace(match.value, "").trim().trim('"')
MailRecipient(email = email, label = label.ifBlank { email })
} else {
MailRecipient(email = token, label = token)
}
}
}
fun replyUrl(session: AuthSession, mailboxId: Int, messageId: Int): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/box/$mailboxId/thread/$messageId"
}
private fun loadPreference(client: OkHttpClient, base: String, key: String, default: String): String {
val json = getJson(client, "$base/preferences/${java.net.URLEncoder.encode(key, "UTF-8")}") as JSONObject
return json.optString("value", default)
}
private fun loadTrustedSenders(client: OkHttpClient, base: String): List<MailTrustedSender> {
return runCatching {
val data = unwrapData(getJson(client, "$base/trustedsenders"))
parseTrustedSenders(data)
}.getOrDefault(emptyList())
}
private fun loadTextBlocks(client: OkHttpClient, base: String): List<MailTextBlock> {
return runCatching {
val data = unwrapData(getJson(client, "$base/textBlocks"))
parseTextBlocks(data)
}.getOrDefault(emptyList())
}
private fun unwrapData(json: Any): Any {
if (json is JSONObject && json.optString("status") == "success") {
return json.opt("data") ?: json
}
return json
}
private fun parseTrustedSenders(data: Any): List<MailTrustedSender> {
val array = when (data) {
is JSONArray -> data
is JSONObject -> data.optJSONArray("data") ?: JSONArray()
else -> JSONArray()
}
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val email = obj.optString("email")
if (email.isBlank()) continue
add(
MailTrustedSender(
id = obj.optInt("id"),
email = email,
type = obj.optString("type", "individual"),
),
)
}
}.sortedBy { it.email.lowercase() }
}
private fun parseTextBlocks(data: Any): List<MailTextBlock> {
val array = when (data) {
is JSONArray -> data
is JSONObject -> data.optJSONArray("data") ?: JSONArray()
else -> JSONArray()
}
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optInt("id")
if (id <= 0) continue
add(parseTextBlock(obj))
}
}.sortedBy { it.title.lowercase() }
}
private fun parseTextBlock(obj: JSONObject): MailTextBlock {
return MailTextBlock(
id = obj.optInt("id"),
title = obj.optString("title"),
content = obj.optString("content"),
preview = obj.optString("preview"),
)
}
private fun authedClient(session: AuthSession): OkHttpClient {
return NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
private fun apiBase(session: AuthSession): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/api"
}
private fun buildFilterQuery(
filter: MailListFilter,
search: String,
searchParams: MailSearchParams = MailSearchParams(),
): String {
val parts = mutableListOf<String>()
when (filter) {
MailListFilter.UNREAD -> parts += "is:unread"
MailListFilter.STARRED -> parts += "is:starred"
MailListFilter.ALL -> Unit
}
val advanced = searchParams.toFilterString()
if (advanced.isNotBlank()) parts += advanced
if (search.isNotBlank()) parts += search.trim()
return parts.joinToString(" ")
}
private fun buildFolderList(account: MailAccount, mailboxes: List<MailMailbox>): List<MailFolderEntry> {
val byId = mailboxes.associateBy { it.id }
val result = mutableListOf<MailFolderEntry>()
fun add(mailbox: MailMailbox?, title: String, role: String?, filter: MailListFilter = MailListFilter.ALL) {
if (mailbox == null) return
result += MailFolderEntry(
mailboxId = mailbox.id,
accountId = account.id,
title = title,
specialRole = role,
filter = filter,
unread = mailbox.unread,
)
}
fun mailboxForRole(role: String, configuredId: Int?): MailMailbox? {
mailboxes.firstOrNull { it.specialRole == role }?.let { return it }
val configured = configuredId?.let { byId[it] }
if (configured != null && (role != "sent" || configured.specialRole != "inbox" && !configured.isInbox)) {
return configured
}
return null
}
val inbox = mailboxes.firstOrNull { it.isInbox || it.specialRole == "inbox" }
add(inbox, "Входящие", "inbox")
if (inbox != null) {
add(inbox, "Непрочитанные", "inbox", MailListFilter.UNREAD)
add(inbox, "Избранное", "inbox", MailListFilter.STARRED)
}
add(mailboxForRole("sent", account.sentMailboxId), "Отправленные", "sent")
add(mailboxForRole("drafts", account.draftsMailboxId), "Черновики", "drafts")
add(mailboxForRole("archive", account.archiveMailboxId), "Архив", "archive")
add(mailboxForRole("trash", account.trashMailboxId), "Корзина", "trash")
add(mailboxForRole("junk", account.junkMailboxId), "Спам", "junk")
val usedIds = result.map { it.mailboxId }.toSet()
mailboxes.filter { it.id !in usedIds && !it.isInbox && it.specialRole.isNullOrBlank() }
.sortedBy { it.displayName.lowercase() }
.forEach { add(it, it.displayName, null) }
return result
}
private fun parseAccounts(array: JSONArray): List<MailAccount> {
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optInt("accountId", obj.optInt("id", 0))
if (id <= 0) continue
add(
MailAccount(
id = id,
email = obj.optString("emailAddress").ifBlank { obj.optString("email") },
name = obj.optString("name"),
draftsMailboxId = obj.optInt("draftsMailboxId").takeIf { it > 0 },
sentMailboxId = obj.optInt("sentMailboxId").takeIf { it > 0 },
trashMailboxId = obj.optInt("trashMailboxId").takeIf { it > 0 },
archiveMailboxId = obj.optInt("archiveMailboxId").takeIf { it > 0 },
junkMailboxId = obj.optInt("junkMailboxId").takeIf { it > 0 },
snoozeMailboxId = obj.optInt("snoozeMailboxId").takeIf { it > 0 },
),
)
}
}
}
private fun getJsonArray(client: OkHttpClient, url: String): JSONArray {
return when (val json = getJson(client, url)) {
is JSONArray -> json
else -> JSONArray()
}
}
private fun getJson(client: OkHttpClient, url: String): Any {
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code == 412) error("Mail API: CSRF — обновите приложение")
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val body = response.body!!.string().trim()
if (body.startsWith("<?xml", ignoreCase = true)) {
error("Mail API вернул XML — проверьте, что «Почта» включена на сервере")
}
if (body.startsWith("[")) return JSONArray(body)
if (body.startsWith("{")) return JSONObject(body)
error("Mail API: неожиданный ответ")
}
}
private fun parseMailboxes(data: Any): List<MailMailbox> {
val out = mutableListOf<MailMailbox>()
fun addFromObject(obj: JSONObject) {
val id = obj.optInt("databaseId", obj.optInt("id", 0))
if (id <= 0) return
val name = obj.optString("name")
val displayName = obj.optString("displayName").ifBlank { name }
val special = obj.optJSONArray("specialUse")
var specialRole = obj.optString("specialRole").ifBlank { null }
if (specialRole == null && special != null && special.length() > 0) {
specialRole = special.optString(0).removePrefix("\\").lowercase()
}
var isInbox = specialRole == "inbox"
if (!isInbox && special != null) {
for (i in 0 until special.length()) {
if (special.optString(i).contains("inbox", ignoreCase = true)) {
isInbox = true
specialRole = "inbox"
break
}
}
}
if (!isInbox && name.equals("INBOX", ignoreCase = true)) {
isInbox = true
specialRole = "inbox"
}
out += MailMailbox(
id = id,
accountId = obj.optInt("accountId"),
displayName = displayName.ifBlank { "Mailbox" },
name = name,
specialRole = specialRole,
unread = obj.optInt("unread", 0),
isInbox = isInbox,
)
obj.optJSONArray("mailboxes")?.let { nested ->
for (i in 0 until nested.length()) {
nested.optJSONObject(i)?.let { addFromObject(it) }
}
}
}
when (data) {
is JSONArray -> for (i in 0 until data.length()) {
data.optJSONObject(i)?.let { addFromObject(it) }
}
is JSONObject -> {
val mailboxes = data.optJSONArray("mailboxes")
if (mailboxes != null) {
for (i in 0 until mailboxes.length()) {
mailboxes.optJSONObject(i)?.let { addFromObject(it) }
}
} else if (data.has("databaseId")) {
addFromObject(data)
}
}
}
return out
}
private fun parseMessages(data: Any): List<MailMessage> {
val out = mutableListOf<MailMessage>()
fun addFromObject(obj: JSONObject) {
val id = obj.optInt("databaseId", obj.optInt("uid", 0))
if (id <= 0) return
val fromLabel = parseAddressLabel(obj.opt("from"))
val fromEmail = parseAddressEmail(obj.opt("from"))
out += MailMessage(
id = id,
subject = obj.optString("subject").ifBlank { "(без темы)" },
from = fromLabel.ifBlank { "Unknown" },
fromEmail = fromEmail,
preview = obj.optString("previewText").ifBlank { obj.optString("summary") },
dateInt = obj.optLong("dateInt", 0L),
flags = parseFlags(obj.optJSONObject("flags")),
tags = parseTags(obj.optJSONObject("tags")),
)
}
when (data) {
is JSONArray -> for (i in 0 until data.length()) {
data.optJSONObject(i)?.let { addFromObject(it) }
}
is JSONObject -> {
val keys = data.keys()
while (keys.hasNext()) {
data.optJSONObject(keys.next())?.let { addFromObject(it) }
}
}
}
return out.sortedByDescending { it.dateInt }
}
private fun parseTags(tags: JSONObject?): List<MailTag> {
if (tags == null) return emptyList()
return buildList {
val keys = tags.keys()
while (keys.hasNext()) {
val imapLabel = keys.next()
val tagObj = tags.optJSONObject(imapLabel) ?: continue
val name = tagObj.optString("displayName").trim()
if (name.isEmpty()) continue
add(
MailTag(
id = tagObj.optLong("id"),
displayName = name,
colorHex = tagObj.optString("color"),
imapLabel = imapLabel,
),
)
}
}
}
private fun parseFlags(flags: JSONObject?): MailMessageFlags {
return MailMessageFlags(
seen = flags?.optBoolean("seen", true) ?: true,
flagged = flags?.optBoolean("flagged", false) ?: false,
hasAttachments = flags?.optBoolean("hasAttachments", false) ?: false,
answered = flags?.optBoolean("answered", false) ?: false,
important = flags?.optBoolean("important", false) ?: false,
)
}
private fun parseAttachments(array: JSONArray?, serverUrl: String): List<MailAttachment> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optString("id").ifBlank { obj.optInt("id", 0).toString() }
if (id == "0") continue
add(
MailAttachment(
id = id,
fileName = obj.optString("fileName").ifBlank { "attachment" },
mime = obj.optString("mime").ifBlank { "application/octet-stream" },
size = obj.optLong("size", 0L),
cid = obj.optString("cid").ifBlank { null },
downloadUrl = obj.optString("downloadUrl").ifBlank { null },
),
)
}
}
}
private fun parseAddressEmail(value: Any?): String {
when (value) {
is JSONObject -> return value.optString("email")
is JSONArray -> return value.optJSONObject(0)?.optString("email").orEmpty()
}
return ""
}
private fun parseAddressLabel(value: Any?): String {
when (value) {
is JSONObject -> return value.optString("label").ifBlank { value.optString("email") }
is JSONArray -> {
if (value.length() == 0) return ""
val first = value.optJSONObject(0) ?: return ""
return first.optString("label").ifBlank { first.optString("email") }
}
}
return ""
}
private fun parseAddressList(value: Any?): String {
if (value !is JSONArray) return ""
return buildList {
for (i in 0 until value.length()) {
val label = parseAddressLabel(value.opt(i))
if (label.isNotBlank()) add(label)
}
}.joinToString(", ")
}
private fun parseAccountSettings(json: JSONObject): MailAccountSettings {
val data = json.optJSONObject("data") ?: json
return MailAccountSettings(
id = data.optInt("accountId", data.optInt("id")),
email = data.optString("emailAddress").ifBlank { data.optString("email") },
name = data.optString("name"),
signature = data.optString("signature"),
draftsMailboxId = data.optInt("draftsMailboxId").takeIf { it > 0 },
sentMailboxId = data.optInt("sentMailboxId").takeIf { it > 0 },
trashMailboxId = data.optInt("trashMailboxId").takeIf { it > 0 },
archiveMailboxId = data.optInt("archiveMailboxId").takeIf { it > 0 },
junkMailboxId = data.optInt("junkMailboxId").takeIf { it > 0 },
searchBody = data.optBoolean("searchBody", false),
classificationEnabled = data.optBoolean("classificationEnabled", false),
signatureAboveQuote = data.optBoolean("signatureAboveQuote", false),
imipCreate = data.optBoolean("imipCreate", false),
quotaPercentage = data.optInt("quotaPercentage").takeIf { it > 0 },
imapHost = data.optString("imapHost").ifBlank { null },
smtpHost = data.optString("smtpHost").ifBlank { null },
)
}
private fun parseOutboxMessages(data: Any): List<MailOutboxMessage> {
val array = when (data) {
is JSONObject -> {
val root = data.optJSONObject("data") ?: data
root.optJSONArray("messages") ?: JSONArray()
}
is JSONArray -> data
else -> JSONArray()
}
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optInt("id")
if (id <= 0) continue
if (obj.optInt("status") == 12) continue
val preview = obj.optString("bodyPlain")
.ifBlank { obj.optString("editorBody") }
.ifBlank { obj.optString("bodyHtml") }
.replace(Regex("<[^>]+>"), " ")
.trim()
add(
MailOutboxMessage(
id = id,
accountId = obj.optInt("accountId"),
subject = obj.optString("subject").ifBlank { "(без темы)" },
toRecipients = parseAddressList(obj.opt("to")),
preview = preview,
updatedAt = obj.optLong("updatedAt"),
sendAt = obj.optLong("sendAt").takeIf { it > 0 },
failed = obj.optBoolean("failed"),
status = obj.optInt("status"),
),
)
}
}.sortedByDescending { it.updatedAt }
}
companion object {
const val PAGE_SIZE = 20
}
}
@@ -0,0 +1,159 @@
package ru.forbion.f7cloud.feature.mail
import android.annotation.SuppressLint
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class MailRichTextEditorController {
internal var webView: WebView? = null
private var ready = false
private val pending = mutableListOf<() -> Unit>()
fun exec(command: String, value: String? = null) {
val script = if (value == null) {
"document.execCommand('$command', false, null);"
} else {
"document.execCommand('$command', false, ${jsString(value)});"
}
runWhenReady { webView?.evaluateJavascript(script, null) }
}
fun focus() {
runWhenReady {
webView?.evaluateJavascript("document.getElementById('editor').focus();", null)
}
}
fun setHtml(html: String) {
if (html.isBlank()) return
val encoded = java.net.URLEncoder.encode(html, Charsets.UTF_8.name())
.replace("'", "\\'")
runWhenReady {
webView?.evaluateJavascript(
"document.getElementById('editor').innerHTML = decodeURIComponent('$encoded');",
null,
)
}
}
fun insertHtml(html: String) {
if (html.isBlank()) return
val encoded = java.net.URLEncoder.encode(html, Charsets.UTF_8.name())
.replace("'", "\\'")
runWhenReady {
webView?.evaluateJavascript(
"""
(function() {
var html = decodeURIComponent('$encoded');
var editor = document.getElementById('editor');
var sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
editor.innerHTML += html;
return;
}
var range = sel.getRangeAt(0);
range.deleteContents();
var template = document.createElement('template');
template.innerHTML = html;
range.insertNode(template.content);
})();
""".trimIndent(),
null,
)
}
}
suspend fun html(): String = query("document.getElementById('editor').innerHTML")
suspend fun plainText(): String = query("document.getElementById('editor').innerText")
internal fun markReady() {
ready = true
val actions = pending.toList()
pending.clear()
actions.forEach { it() }
}
private fun runWhenReady(action: () -> Unit) {
if (ready) action() else pending += action
}
private suspend fun query(script: String): String = suspendCancellableCoroutine { cont ->
runWhenReady {
webView?.evaluateJavascript(script) { value ->
cont.resume(value?.trim('"')?.replace("\\n", "\n")?.replace("\\\"", "\"") ?: "")
}
}
}
private fun jsString(value: String): String =
"'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun MailRichTextEditor(
modifier: Modifier = Modifier,
controller: MailRichTextEditorController = remember { MailRichTextEditorController() },
) {
val html = remember {
"""
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #fff; }
#editor {
min-height: 100%;
box-sizing: border-box;
padding: 12px;
outline: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 16px;
line-height: 1.45;
color: #151515;
word-break: break-word;
}
</style>
</head>
<body><div id="editor" contenteditable="true"></div></body>
</html>
""".trimIndent()
}
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
setBackgroundColor(0xFFFFFFFF.toInt())
addJavascriptInterface(
object {
@JavascriptInterface
fun onReady() {
post { controller.markReady() }
}
},
"AndroidEditor",
)
webViewClient = object : android.webkit.WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
evaluateJavascript("AndroidEditor.onReady();", null)
}
}
controller.webView = this
loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
},
onRelease = {
controller.webView = null
},
)
}
@@ -0,0 +1,943 @@
package ru.forbion.f7cloud.feature.mail
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import androidx.activity.compose.BackHandler
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.feature.files.LocalFileOpener
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File
@Composable
fun MailScreen(
session: AuthSession,
modifier: Modifier = Modifier,
openMessageId: Int? = null,
openMailboxId: Int? = null,
pushRefreshRequest: Int = 0,
sidebarOpen: Boolean = false,
onSidebarOpenChange: (Boolean) -> Unit = {},
settingsOpen: Boolean = false,
onSettingsOpenChange: (Boolean) -> Unit = {},
onOpenMessageConsumed: () -> Unit = {},
onMessageOpenStateChange: (Boolean) -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val cacheRepository = remember { MailCacheRepository(context) }
val vm: MailViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
MailViewModel(cacheRepository = cacheRepository) as T
},
)
val state by vm.state.collectAsState()
val httpClient = remember(session.username, session.appPassword, session.trustAllCerts) {
NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(pushRefreshRequest) {
if (pushRefreshRequest > 0) {
vm.load(session, forceRefresh = true)
}
}
LaunchedEffect(openMessageId) {
val messageId = openMessageId ?: return@LaunchedEffect
vm.openDeepLink(session, messageId, openMailboxId)
onOpenMessageConsumed()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.selectedMessageId) {
onMessageOpenStateChange(state.selectedMessageId != null)
}
val composeLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
vm.load(session, forceRefresh = true)
}
val openCompose: () -> Unit = {
val account = state.selectedFolder?.let { folder ->
state.accounts.find { it.id == folder.accountId }
} ?: state.accounts.firstOrNull()
if (account == null || account.id <= 0 || account.email.isBlank()) {
Toast.makeText(context, "Учётная запись почты не найдена", Toast.LENGTH_SHORT).show()
} else {
composeLauncher.launch(
MailComposeActivity.intent(
context,
MailComposeLaunch(
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
accountId = account.id,
accountEmail = account.email,
accountName = account.name,
mailboxId = state.selectedFolder?.mailboxId,
),
),
)
}
}
val openComposeForMessageWithSchedule: (
MailComposeMode,
MailMessageDetail,
MailSendLaterPreset,
Long?,
) -> Unit = { mode, detail, sendPreset, customSendAtEpochSeconds ->
val account = state.selectedFolder?.let { folder ->
state.accounts.find { it.id == folder.accountId }
} ?: state.accounts.firstOrNull()
if (account == null || account.id <= 0 || account.email.isBlank()) {
Toast.makeText(context, "Учётная запись почты не найдена", Toast.LENGTH_SHORT).show()
} else {
val base = MailComposeLaunch(
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
accountId = account.id,
accountEmail = account.email,
accountName = account.name,
mailboxId = state.selectedFolder?.mailboxId,
initialSendPreset = sendPreset,
initialCustomSendAtEpochSeconds = customSendAtEpochSeconds,
)
composeLauncher.launch(
MailComposeActivity.intent(
context,
MailReplyHelper.buildComposeLaunch(base, detail, mode, account.email),
),
)
}
}
val openComposeForMessage: (MailComposeMode, MailMessageDetail) -> Unit = { mode, detail ->
openComposeForMessageWithSchedule(mode, detail, MailSendLaterPreset.NOW, null)
}
val openScheduledReplyForMessage: (MailMessageDetail, MailSendLaterOption) -> Unit = { detail, option ->
if (option.preset != MailSendLaterPreset.NOW) {
openComposeForMessageWithSchedule(
MailComposeMode.REPLY,
detail,
option.preset,
option.sendAtEpochSeconds,
)
}
}
var searchFilterOpen by remember { mutableStateOf(false) }
var moveSheetOpen by remember { mutableStateOf(false) }
var tagsSheetOpen by remember { mutableStateOf(false) }
var snoozeSheetOpen by remember { mutableStateOf(false) }
val mailBackState = MailBackStackState(
searchFilterOpen = searchFilterOpen,
snoozeSheetOpen = snoozeSheetOpen,
tagsSheetOpen = tagsSheetOpen,
moveSheetOpen = moveSheetOpen,
settingsOpen = settingsOpen,
editingAccountId = state.editingAccountId,
sidebarOpen = sidebarOpen,
messageOpen = state.selectedMessageId != null,
)
val mailCanGoBack = mailBackState.canGoBack()
val mailNavigateBack: () -> Boolean = {
navigateMailBack(
state = mailBackState,
closeSearchFilter = { searchFilterOpen = false },
closeSnoozeSheet = { snoozeSheetOpen = false },
closeTagsSheet = { tagsSheetOpen = false },
closeMoveSheet = { moveSheetOpen = false },
closeAccountSettings = { vm.closeAccountSettings() },
closeSettings = {
vm.closeAccountSettings()
onSettingsOpenChange(false)
},
closeSidebar = { onSidebarOpenChange(false) },
closeMessage = { vm.closeMessage() },
)
}
MailSettingsSheet(
visible = settingsOpen,
serverUrl = session.serverUrl,
accounts = state.accounts,
mailboxes = state.mailboxes,
appSettings = state.appSettings,
appSettingsLoading = state.appSettingsLoading,
appSettingsSaving = state.appSettingsSaving,
editingAccountId = state.editingAccountId,
accountSettings = state.accountSettings,
accountSettingsLoading = state.accountSettingsLoading,
accountSettingsSaving = state.accountSettingsSaving,
onLoadAppSettings = { vm.loadAppSettings(session) },
onAccountClick = { vm.openAccountSettings(session, it) },
onAccountSettingsBack = { vm.closeAccountSettings() },
onSaveAccountSettings = { vm.saveAccountSettings(session, it) },
onShowThreadedChange = { vm.setShowThreaded(session, it) },
onHighlightExternalChange = { vm.setHighlightExternalAddresses(session, it) },
onRemoveTrustedSender = { vm.removeTrustedSender(session, it) },
onCreateTextBlock = { title, content -> vm.createTextBlock(session, title, content) },
onDeleteTextBlock = { vm.deleteTextBlock(session, it) },
onDismiss = {
vm.closeAccountSettings()
onSettingsOpenChange(false)
},
onSwipeBack = { mailNavigateBack() },
)
BackHandler(enabled = mailCanGoBack) {
mailNavigateBack()
}
F7OverlayDismissHandler(
enabled = mailCanGoBack,
onDismiss = { mailNavigateBack() },
)
MailSearchParamsSheet(
visible = searchFilterOpen,
params = state.searchParams,
onDismiss = { searchFilterOpen = false },
onSearch = { vm.applySearchParams(session, it) },
onClear = { vm.clearSearchParams(session) },
)
Box(
modifier = modifier
.fillMaxSize()
.f7SwipeFromRightToDismiss(
enabled = mailCanGoBack,
onDismiss = { mailNavigateBack() },
),
) {
if (state.selectedMessageId != null) {
val messageAccountId = state.selectedFolder?.accountId
?: state.accounts.firstOrNull()?.id
val moveMailboxes = remember(state.mailboxes, messageAccountId) {
state.mailboxes.filter { mailbox ->
messageAccountId == null || mailbox.accountId == messageAccountId
}
}
val availableTags = remember(state.messages) {
state.messages
.flatMap { it.tags }
.filter { it.imapLabel.isNotBlank() }
.distinctBy { it.imapLabel }
}
val snoozeOptions = remember { MailComposeSchedule.options() }
Box(
modifier = Modifier
.fillMaxSize()
.f7SwipeFromRightToDismiss(
enabled = mailCanGoBack,
onDismiss = { mailNavigateBack() },
),
) {
val detail = state.messageDetail
val htmlBodyReady = detail != null && detail.looksLikeHtml() && detail.htmlBodyForDisplay().isNotBlank()
Column(modifier = Modifier.fillMaxSize()) {
if (!state.error.isNullOrBlank()) {
Text(
text = state.error.orEmpty(),
color = F7Colors.Error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
if (htmlBodyReady) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
) {
MailMessageDetailHeader(
detail = detail,
threadMessages = state.threadMessages,
threadExpanded = state.threadExpanded,
session = session,
messageAccountId = messageAccountId,
vm = vm,
openComposeForMessage = openComposeForMessage,
onTagsSheetOpen = { tagsSheetOpen = true },
onMoveSheetOpen = { moveSheetOpen = true },
onSnoozeSheetOpen = { snoozeSheetOpen = true },
closeMessage = { mailNavigateBack() },
)
MailMessageDetailView(
session = session,
detail = detail,
loading = state.messageLoading,
httpClient = httpClient,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
showHeader = false,
htmlBodyExternal = true,
onAttachmentClick = { attachment ->
val messageId = detail.id
vm.downloadAttachment(session, messageId, attachment) { result ->
result.onSuccess { bytes ->
val safeName = attachment.fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val file = File(context.cacheDir, "mail_$safeName")
file.writeBytes(bytes)
LocalFileOpener.openExternal(context, file, attachment.mime)
}.onFailure {
Toast.makeText(context, it.message ?: "Ошибка загрузки", Toast.LENGTH_SHORT).show()
}
}
},
)
}
key(detail.id) {
MailMessageBodyView(
session = session,
messageId = detail.id,
html = detail.htmlBodyForDisplay(),
attachments = detail.attachments,
client = httpClient,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
} else {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
MailMessageDetailHeader(
detail = detail,
threadMessages = state.threadMessages,
threadExpanded = state.threadExpanded,
session = session,
messageAccountId = messageAccountId,
vm = vm,
openComposeForMessage = openComposeForMessage,
onTagsSheetOpen = { tagsSheetOpen = true },
onMoveSheetOpen = { moveSheetOpen = true },
onSnoozeSheetOpen = { snoozeSheetOpen = true },
closeMessage = { mailNavigateBack() },
)
MailMessageDetailView(
session = session,
detail = detail,
loading = state.messageLoading,
httpClient = httpClient,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
showHeader = false,
htmlBodyExternal = false,
onAttachmentClick = { attachment ->
val messageId = detail?.id ?: return@MailMessageDetailView
vm.downloadAttachment(session, messageId, attachment) { result ->
result.onSuccess { bytes ->
val safeName = attachment.fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val file = File(context.cacheDir, "mail_$safeName")
file.writeBytes(bytes)
LocalFileOpener.openExternal(context, file, attachment.mime)
}.onFailure {
Toast.makeText(context, it.message ?: "Ошибка загрузки", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(120.dp))
}
}
}
// Зона свайпа справа — WebView не отдаёт жесты Compose, поэтому ловим на краю поверх.
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(24.dp)
.f7SwipeFromRightToDismiss(
enabled = mailCanGoBack,
edgeFraction = 1f,
onDismiss = { mailNavigateBack() },
),
)
state.messageDetail?.let { detail ->
MailReplyFab(
serverUrl = session.serverUrl,
onClick = { openComposeForMessage(MailComposeMode.REPLY, detail) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 88.dp),
)
}
MailMoveMailboxSheet(
visible = moveSheetOpen,
mailboxes = moveMailboxes,
onDismiss = { moveSheetOpen = false },
onSelect = { mailbox ->
state.messageDetail?.let { detail ->
vm.moveMessage(session, detail.id, mailbox.id)
}
},
)
MailMessageTagsSheet(
visible = tagsSheetOpen,
availableTags = availableTags,
selectedTags = state.messageDetail?.tags.orEmpty(),
onDismiss = { tagsSheetOpen = false },
onToggle = { tag ->
val detail = state.messageDetail ?: return@MailMessageTagsSheet
val hasTag = detail.tags.any { it.imapLabel == tag.imapLabel }
vm.toggleMessageTag(session, detail.id, tag, add = !hasTag)
},
)
MailSnoozeSheet(
visible = snoozeSheetOpen,
options = snoozeOptions,
onDismiss = { snoozeSheetOpen = false },
onSelect = { option ->
val detail = state.messageDetail ?: return@MailSnoozeSheet
openScheduledReplyForMessage(detail, option)
},
)
}
} else {
MailInboxScreen(
session = session,
modifier = Modifier.fillMaxSize(),
selectedFolder = state.selectedFolder,
loading = state.loading && state.messages.isEmpty() && state.outboxMessages.isEmpty(),
loadingMore = state.loadingMore,
error = state.error,
searchQuery = state.searchQuery,
searchParams = state.searchParams,
isOutbox = state.selectedFolder?.specialRole == "outbox",
messages = state.messages,
outboxMessages = state.outboxMessages,
nextCursor = state.nextCursor,
onSearchChange = { vm.setSearchQuery(session, it) },
onQuickFilterToggle = { vm.toggleQuickFilter(session, it) },
onComposeClick = openCompose,
onFilterClick = { searchFilterOpen = true },
onMessageClick = { vm.openMessage(session, it) },
onOutboxRetry = { vm.retryOutboxMessage(session, it) },
onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
onLoadMore = { vm.loadMore(session) },
)
}
MailNavigationSidebar(
serverUrl = session.serverUrl,
visible = sidebarOpen,
accounts = state.accounts,
folders = state.folders,
selected = state.selectedFolder,
collapsedAccountIds = state.collapsedAccountIds,
onDismiss = { onSidebarOpenChange(false) },
onComposeClick = {
onSidebarOpenChange(false)
openCompose()
},
onRefreshClick = { vm.load(session, forceRefresh = true) },
onFolderClick = {
vm.selectFolder(session, it)
onSidebarOpenChange(false)
},
onToggleAccountCollapsed = { vm.toggleAccountCollapsed(it) },
onPriorityInboxClick = {
state.folders.firstOrNull { it.specialRole == "inbox" && it.filter == MailListFilter.ALL }
?.let {
vm.selectFolder(session, it)
onSidebarOpenChange(false)
}
},
onOutboxClick = {
vm.selectOutbox(session)
onSidebarOpenChange(false)
},
)
}
}
@Composable
private fun MailInboxScreen(
session: AuthSession,
modifier: Modifier = Modifier,
selectedFolder: MailFolderEntry?,
loading: Boolean,
loadingMore: Boolean,
error: String?,
searchQuery: String,
searchParams: MailSearchParams,
isOutbox: Boolean,
messages: List<MailMessage>,
outboxMessages: List<MailOutboxMessage>,
nextCursor: Int?,
onSearchChange: (String) -> Unit,
onQuickFilterToggle: (MailQuickFilter) -> Unit,
onComposeClick: () -> Unit,
onFilterClick: () -> Unit,
onMessageClick: (Int) -> Unit,
onOutboxRetry: (Int) -> Unit,
onOutboxDelete: (Int) -> Unit,
onLoadMore: () -> Unit,
) {
val listItems = remember(messages) { buildMailInboxListItems(messages) }
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (!isOutbox) {
MailInboxToolbar(
serverUrl = session.serverUrl,
query = searchQuery,
onQueryChange = onSearchChange,
onComposeClick = onComposeClick,
onFilterClick = onFilterClick,
)
MailQuickFilterChips(
searchParams = searchParams,
onFilterToggle = onQuickFilterToggle,
)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Исходящие",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
if (outboxMessages.isNotEmpty()) {
Text(
"${outboxMessages.size}",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
}
}
if (!folderTitle.isNullOrBlank()) {
Text(
folderTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
}
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return@Box
}
if (isOutbox) {
if (outboxMessages.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text("Нет неотправленных писем", color = F7Colors.TextSecondary)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(outboxMessages, key = { it.id }) { message ->
MailOutboxRow(
message = message,
onRetry = { onOutboxRetry(message.id) },
onDelete = { onOutboxDelete(message.id) },
)
}
}
}
return@Box
}
if (messages.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
if (searchQuery.isNotBlank() || searchParams.isActive()) {
"Ничего не найдено"
} else {
"Нет писем"
},
color = F7Colors.TextSecondary,
)
}
} else {
val listState = rememberLazyListState()
LaunchedEffect(listState, messages.size, nextCursor, loadingMore) {
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
val total = info.totalItemsCount
total > 0 && lastVisible >= total - 3
}
.distinctUntilChanged()
.collect { nearEnd ->
if (nearEnd && nextCursor != null && !loadingMore) {
onLoadMore()
}
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
listItems,
key = { item ->
when (item) {
is MailInboxListItem.YearHeader -> "year-${item.year}"
is MailInboxListItem.MessageItem -> item.message.id
}
},
) { item ->
when (item) {
is MailInboxListItem.YearHeader -> MailYearHeader(item.year)
is MailInboxListItem.MessageItem -> MailEnvelopeRow(
message = item.message,
serverUrl = session.serverUrl,
onClick = { onMessageClick(item.message.id) },
)
}
}
if (loadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.height(24.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
}
}
}
}
}
}
}
}
@Composable
private fun MailMessageDetailHeader(
detail: MailMessageDetail?,
threadMessages: List<MailMessage>,
threadExpanded: Boolean,
session: AuthSession,
messageAccountId: Int?,
vm: MailViewModel,
openComposeForMessage: (MailComposeMode, MailMessageDetail) -> Unit,
onTagsSheetOpen: () -> Unit,
onMoveSheetOpen: () -> Unit,
onSnoozeSheetOpen: () -> Unit,
closeMessage: () -> Unit,
) {
val message = detail ?: return
MailThreadSection(
currentMessageId = message.id,
threadMessages = threadMessages,
expanded = threadExpanded,
onToggleExpanded = { vm.toggleThreadExpanded() },
onMessageClick = { vm.openMessage(session, it) },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
MailMessageSenderCard(
fromName = message.from,
fromEmail = message.fromEmail,
subject = message.subject,
to = message.to,
cc = message.cc,
dateInt = message.dateInt,
flagged = message.flags.flagged,
seen = message.flags.seen,
important = message.flags.important,
serverUrl = session.serverUrl,
onReply = { openComposeForMessage(MailComposeMode.REPLY, message) },
onForward = { openComposeForMessage(MailComposeMode.FORWARD, message) },
onToggleStar = { vm.toggleStar(session, message.id) },
onMarkUnread = {
if (message.flags.seen) vm.toggleRead(session, message.id)
},
onDelete = {
vm.deleteMessage(session, message.id)
closeMessage()
},
onToggleImportant = { vm.toggleImportant(session, message.id) },
onMarkSpam = {
messageAccountId?.let { vm.markAsSpam(session, message.id, it) }
},
onEditTags = onTagsSheetOpen,
onMove = onMoveSheetOpen,
onSnooze = onSnoozeSheetOpen,
)
}
@Composable
private fun MailMessageDetailView(
session: AuthSession,
detail: MailMessageDetail?,
loading: Boolean,
httpClient: okhttp3.OkHttpClient,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
htmlBodyExternal: Boolean = false,
onAttachmentClick: (MailAttachment) -> Unit,
) {
if (detail == null) {
if (!showHeader) return
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return
}
val bodyLoading = loading && !detail.hasBodyContent()
val embedded = !showHeader
Column(
modifier = modifier.then(
if (showHeader) Modifier.fillMaxSize() else Modifier.fillMaxWidth(),
),
) {
if (showHeader) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(F7Colors.Background)
.padding(bottom = 8.dp),
) {
Text(
detail.subject.ifBlank { "(без темы)" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
MailAvatar(name = detail.from, email = detail.fromEmail, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"От:",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.width(6.dp))
Text(
detail.from.ifBlank { "Неизвестный" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
if (detail.to.isNotBlank()) {
Text(
"Кому: ${detail.to}",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
if (detail.cc.isNotBlank()) {
Text(
"Копия: ${detail.cc}",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
if (detail.dateInt > 0) {
Text(
formatMessageDate(detail.dateInt),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
if (detail.attachments.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
detail.attachments.forEach { attachment ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(F7Colors.SurfaceMuted)
.clickable { onAttachmentClick(attachment) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("📎", modifier = Modifier.padding(end = 8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
attachment.fileName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val sizeLabel = formatAttachmentSize(attachment.size)
if (sizeLabel.isNotBlank()) {
Text(sizeLabel, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)
}
}
}
Spacer(Modifier.height(4.dp))
}
}
if (bodyLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.height(28.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
}
} else if (!htmlBodyExternal) {
if (detail.looksLikeHtml() && detail.htmlBodyForDisplay().isNotBlank()) {
key(detail.id) {
MailMessageBodyView(
session = session,
messageId = detail.id,
html = detail.htmlBodyForDisplay(),
attachments = detail.attachments,
client = httpClient,
modifier = Modifier
.fillMaxWidth()
.then(if (showHeader) Modifier.weight(1f) else Modifier),
)
}
} else {
val plainBody = MailBodyHtml.normalizePlainForDisplay(
detail.plainBodyForDisplay().ifBlank { "(пустое письмо)" },
)
if (embedded) {
Text(
plainBody,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(top = 8.dp),
) {
Text(plainBody, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}
@@ -0,0 +1,774 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
private val MailSettingsCard = Color(0xFFFDFDFD)
private val MailSettingsAccountCard = Color(0xFFE8F5E0)
@Composable
fun MailSettingsSheet(
visible: Boolean,
serverUrl: String,
accounts: List<MailAccount>,
mailboxes: List<MailMailbox>,
appSettings: MailAppSettings?,
appSettingsLoading: Boolean,
appSettingsSaving: Boolean,
editingAccountId: Int?,
accountSettings: MailAccountSettings?,
accountSettingsLoading: Boolean,
accountSettingsSaving: Boolean,
onLoadAppSettings: () -> Unit,
onAccountClick: (Int) -> Unit,
onAccountSettingsBack: () -> Unit,
onSaveAccountSettings: (MailAccountSettings) -> Unit,
onShowThreadedChange: (Boolean) -> Unit,
onHighlightExternalChange: (Boolean) -> Unit,
onRemoveTrustedSender: (MailTrustedSender) -> Unit,
onCreateTextBlock: (String, String) -> Unit,
onDeleteTextBlock: (Int) -> Unit,
onDismiss: () -> Unit,
onSwipeBack: () -> Unit = onDismiss,
) {
if (!visible) return
LaunchedEffect(visible, editingAccountId) {
if (visible && editingAccountId == null) {
onLoadAppSettings()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxSize()
.f7SwipeFromRightToDismiss(onDismiss = onSwipeBack),
color = F7Colors.Surface,
) {
if (editingAccountId != null) {
MailAccountSettingsScreen(
settings = accountSettings,
loading = accountSettingsLoading,
saving = accountSettingsSaving,
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
onBack = onAccountSettingsBack,
onSave = onSaveAccountSettings,
)
} else {
MailAppSettingsScreen(
serverUrl = serverUrl,
accounts = accounts,
appSettings = appSettings,
loading = appSettingsLoading,
saving = appSettingsSaving,
onAccountClick = onAccountClick,
onShowThreadedChange = onShowThreadedChange,
onHighlightExternalChange = onHighlightExternalChange,
onRemoveTrustedSender = onRemoveTrustedSender,
onCreateTextBlock = onCreateTextBlock,
onDeleteTextBlock = onDeleteTextBlock,
onDismiss = onDismiss,
)
}
}
}
}
@Composable
private fun MailAppSettingsScreen(
serverUrl: String,
accounts: List<MailAccount>,
appSettings: MailAppSettings?,
loading: Boolean,
saving: Boolean,
onAccountClick: (Int) -> Unit,
onShowThreadedChange: (Boolean) -> Unit,
onHighlightExternalChange: (Boolean) -> Unit,
onRemoveTrustedSender: (MailTrustedSender) -> Unit,
onCreateTextBlock: (String, String) -> Unit,
onDeleteTextBlock: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val baseUrl = serverUrl.trimEnd('/')
var textBlockDialogOpen by remember { mutableStateOf(false) }
var textBlockTitle by remember { mutableStateOf("") }
var textBlockContent by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Параметры эл. почты",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
IconButton(onClick = onDismiss) {
Text("", style = MaterialTheme.typography.titleMedium)
}
}
Spacer(Modifier.height(16.dp))
if (loading && appSettings == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
MailSettingsSectionTitle("Основные")
F7SecondaryButton(
text = "Установить как почтовое приложение по умолчанию",
onClick = {
runCatching {
context.startActivity(Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS))
}.onFailure {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:"))
context.startActivity(Intent.createChooser(intent, "Почтовое приложение"))
}
},
modifier = Modifier.fillMaxWidth(),
)
MailSettingsFormGroup(
label = "Параметры учётной записи",
) {
if (accounts.isEmpty()) {
Text(
"Учётные записи не найдены",
color = F7Colors.TextSecondary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
accounts.forEach { account ->
MailSettingsAccountRow(
email = account.email.ifBlank { account.name },
subtitle = account.name.takeIf { it.isNotBlank() && it != account.email },
onClick = { onAccountClick(account.id) },
)
}
}
if (appSettings?.allowNewAccounts != false) {
F7SecondaryButton(
text = "Добавить учётную запись",
onClick = {
val url = "$baseUrl/index.php/apps/mail/setup"
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
modifier = Modifier.fillMaxWidth(),
)
}
}
MailSettingsSectionTitle("Внешний вид")
MailSettingsSwitchCard(
label = "Показать все сообщения в ветке",
description = "Если выключено, будет показано только выбранное сообщение",
checked = appSettings?.showThreaded == true,
enabled = !saving,
onCheckedChange = onShowThreadedChange,
)
MailSettingsSectionTitle("Текстовые шаблоны")
Text(
"Повторно используемые фрагменты текста, которые можно вставлять в письма",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
appSettings?.textBlocks.orEmpty().forEach { block ->
MailSettingsListRow(
title = block.title,
subtitle = block.preview.ifBlank { block.content.replace(Regex("<[^>]+>"), " ").trim() },
onDelete = { onDeleteTextBlock(block.id) },
)
}
F7SecondaryButton(
text = "Новый текстовый блок",
onClick = { textBlockDialogOpen = true },
modifier = Modifier.fillMaxWidth(),
)
MailSettingsSectionTitle("Конфиденциальность")
MailSettingsFormGroup(label = "Всегда показывать изображения из") {
val senders = appSettings?.trustedSenders.orEmpty()
if (senders.isEmpty()) {
Text(
"Сейчас нет доверенных отправителей.",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
senders.forEach { sender ->
MailSettingsListRow(
title = sender.email,
subtitle = if (sender.type == "domain") "домен" else "адрес",
onDelete = { onRemoveTrustedSender(sender) },
)
}
}
}
MailSettingsSectionTitle("Безопасность")
MailSettingsSwitchCard(
label = "Выделять внешние адреса",
description = "Управляйте внутренними адресами и доменами, чтобы контакты оставались без пометки",
checked = appSettings?.highlightExternalAddresses == true,
enabled = !saving,
onCheckedChange = onHighlightExternalChange,
)
MailSettingsFormGroup(label = "S/MIME") {
F7SecondaryButton(
text = "Управление сертификатами",
onClick = {
val url = "$baseUrl/index.php/apps/mail"
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
modifier = Modifier.fillMaxWidth(),
)
}
}
if (saving) {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
}
}
}
if (textBlockDialogOpen) {
Dialog(onDismissRequest = { textBlockDialogOpen = false }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = F7Colors.Surface,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
"Новый текстовый блок",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(12.dp))
MailSettingsTextArea(
label = "Название",
value = textBlockTitle,
onValueChange = { textBlockTitle = it },
minHeight = 48.dp,
)
Spacer(Modifier.height(8.dp))
MailSettingsTextArea(
label = "Содержимое",
value = textBlockContent,
onValueChange = { textBlockContent = it },
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
F7SecondaryButton(
text = "Отмена",
onClick = {
textBlockDialogOpen = false
textBlockTitle = ""
textBlockContent = ""
},
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(
enabled = textBlockTitle.isNotBlank() && textBlockContent.isNotBlank(),
) {
onCreateTextBlock(textBlockTitle.trim(), textBlockContent.trim())
textBlockDialogOpen = false
textBlockTitle = ""
textBlockContent = ""
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
"OK",
color = F7Colors.TextOnPrimary,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
}
}
@Composable
private fun MailSettingsSectionTitle(title: String) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
}
@Composable
private fun MailSettingsFormGroup(
label: String,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
content()
}
}
}
@Composable
private fun MailSettingsAccountRow(
email: String,
subtitle: String?,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsAccountCard)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
}
Text("", color = F7Colors.TextSecondary)
}
}
@Composable
private fun MailSettingsSwitchCard(
label: String,
description: String? = null,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
if (!description.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(description, style = MaterialTheme.typography.bodySmall, color = F7Colors.TextSecondary)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = F7Colors.Primary,
),
)
}
}
@Composable
private fun MailSettingsListRow(
title: String,
subtitle: String? = null,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(start = 14.dp, end = 4.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onDelete) {
Text("", color = F7Colors.TextSecondary)
}
}
}
@Composable
private fun MailAccountSettingsScreen(
settings: MailAccountSettings?,
loading: Boolean,
saving: Boolean,
mailboxes: List<MailMailbox>,
onBack: () -> Unit,
onSave: (MailAccountSettings) -> Unit,
) {
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
LaunchedEffect(settings) {
if (settings != null) draft = settings
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Text("", style = MaterialTheme.typography.headlineSmall)
}
Text(
"Параметры учётной записи",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
modifier = Modifier.weight(1f),
)
}
if (loading || draft == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
MailSettingsInfoRow("Email", draft!!.email)
if (draft!!.name.isNotBlank()) {
MailSettingsInfoRow("Имя", draft!!.name)
}
if (!draft!!.imapHost.isNullOrBlank()) {
MailSettingsInfoRow("IMAP", draft!!.imapHost.orEmpty())
}
if (!draft!!.smtpHost.isNullOrBlank()) {
MailSettingsInfoRow("SMTP", draft!!.smtpHost.orEmpty())
}
draft!!.quotaPercentage?.let { quota ->
MailSettingsInfoRow("Квота", "$quota%")
}
MailSettingsTextArea(
label = "Подпись",
value = draft!!.signature,
onValueChange = { draft = draft!!.copy(signature = it) },
)
MailSettingsMailboxPicker(
label = "Черновики",
mailboxes = mailboxes,
selectedId = draft!!.draftsMailboxId,
onSelected = { draft = draft!!.copy(draftsMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Отправленные",
mailboxes = mailboxes,
selectedId = draft!!.sentMailboxId,
onSelected = { draft = draft!!.copy(sentMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Корзина",
mailboxes = mailboxes,
selectedId = draft!!.trashMailboxId,
onSelected = { draft = draft!!.copy(trashMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Архив",
mailboxes = mailboxes,
selectedId = draft!!.archiveMailboxId,
onSelected = { draft = draft!!.copy(archiveMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Спам",
mailboxes = mailboxes,
selectedId = draft!!.junkMailboxId,
onSelected = { draft = draft!!.copy(junkMailboxId = it) },
)
MailSettingsToggleRow(
label = "Искать в теле письма",
checked = draft!!.searchBody,
onCheckedChange = { draft = draft!!.copy(searchBody = it) },
)
MailSettingsToggleRow(
label = "Приоритетные входящие",
checked = draft!!.classificationEnabled,
onCheckedChange = { draft = draft!!.copy(classificationEnabled = it) },
)
MailSettingsToggleRow(
label = "Подпись над цитатой",
checked = draft!!.signatureAboveQuote,
onCheckedChange = { draft = draft!!.copy(signatureAboveQuote = it) },
)
MailSettingsToggleRow(
label = "Создавать события из приглашений",
checked = draft!!.imipCreate,
onCheckedChange = { draft = draft!!.copy(imipCreate = it) },
)
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(enabled = !saving) { onSave(draft!!) }
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
) {
if (saving) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
color = F7Colors.TextOnPrimary,
strokeWidth = 2.dp,
)
} else {
Text(
"Сохранить",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextOnPrimary,
)
}
}
}
}
@Composable
private fun MailSettingsInfoRow(label: String, value: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
Spacer(Modifier.height(4.dp))
Text(value, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
@Composable
private fun MailSettingsTextArea(
label: String,
value: String,
onValueChange: (String) -> Unit,
minHeight: androidx.compose.ui.unit.Dp = 80.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
Spacer(Modifier.height(6.dp))
androidx.compose.foundation.text.BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight),
textStyle = MaterialTheme.typography.bodyMedium.copy(color = F7Colors.TextPrimary),
)
}
}
@Composable
private fun MailSettingsToggleRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.clickable { onCheckedChange(!checked) }
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun MailSettingsMailboxPicker(
label: String,
mailboxes: List<MailMailbox>,
selectedId: Int?,
onSelected: (Int?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedName = mailboxes.firstOrNull { it.id == selectedId }?.displayName ?: "Не выбрано"
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.clickable { expanded = true }
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
Spacer(Modifier.height(4.dp))
Text(
selectedName,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Не выбрано") },
onClick = {
onSelected(null)
expanded = false
},
)
mailboxes.forEach { mailbox ->
DropdownMenuItem(
text = { Text(mailbox.displayName) },
onClick = {
onSelected(mailbox.id)
expanded = false
},
)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.feature.mail
/** Подмена брендинга в WebView почты (как f7cloud_branding.js на сервере). */
object MailWebBranding {
fun brandingScriptUrl(serverUrl: String): String {
return "${serverUrl.trimEnd('/')}/themes/forbion/js/f7cloud_branding.js"
}
fun replaceInText(text: String): String {
return text
.replace(Regex("Nextcloud", RegexOption.IGNORE_CASE), "F7cloud")
.replace(Regex("NEXTCLOUD"), "F7CLOUD")
.replace(Regex("nextcloud\\.com", RegexOption.IGNORE_CASE), "f7cloud.ru")
}
/** Загружает f7cloud_branding.js с сервера, затем выполняет [body]. */
fun jsAfterBranding(serverUrl: String, body: String): String {
val url = brandingScriptUrl(serverUrl).replace("\\", "\\\\").replace("'", "\\'")
return """
(function() {
var run = function() { $body };
if (window.__f7cloudBranding) { run(); return; }
var s = document.createElement('script');
s.src = '$url';
s.onload = run;
s.onerror = run;
(document.head || document.documentElement).appendChild(s);
})();
""".trimIndent()
}
}
+29
View File
@@ -0,0 +1,29 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'ru.forbion.f7cloud.feature.talknative'
compileSdk 36
defaultConfig {
minSdk 26
missingDimensionStrategy 'default', 'f7'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
api project(':vendor:talk-app')
implementation project(':core:auth')
implementation project(':core:push')
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.talknative
import ru.forbion.f7cloud.core.auth.AuthSession
/** Maps F7cloud [AuthSession] to talk-android account credentials. */
data class TalkAccount(
val serverUrl: String,
val userId: String,
val displayName: String,
val password: String,
val trustAllCerts: Boolean,
)
object TalkAuthBridge {
fun fromSession(session: AuthSession): TalkAccount = TalkAccount(
serverUrl = session.serverUrl.trimEnd('/'),
userId = session.davUserId ?: session.username,
displayName = session.username,
password = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
/** Basic-auth header value for talk-android OkHttp interceptors. */
fun basicAuthHeader(session: AuthSession): String {
val creds = "${session.username}:${session.appPassword}"
return "Basic ${android.util.Base64.encodeToString(creds.toByteArray(), android.util.Base64.NO_WRAP)}"
}
}
@@ -0,0 +1,12 @@
package ru.forbion.f7cloud.feature.talknative
/**
* Room metadata passed into talk-android [ru.f7cloud.talk.activities.CallActivity].
*/
data class TalkNativeCallContext(
val roomToken: String,
val displayName: String = "",
val isOneToOne: Boolean = false,
val joinExistingCall: Boolean = false,
val isVoiceOnly: Boolean = false,
)
@@ -0,0 +1,190 @@
package ru.forbion.f7cloud.feature.talknative
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.Toast
import ru.f7cloud.talk.activities.CallActivity
import ru.f7cloud.talk.services.CallForegroundService
import ru.f7cloud.talk.utils.bundle.BundleKeys
import ru.f7cloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_DISPLAY_NAME
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR
import ru.f7cloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Launches Talk calls via native WebRTC ([CallActivity]), same as F7cloud Talk Android.
*/
object TalkNativeCallLauncher {
private const val TAG = "TalkNativeCallLauncher"
private const val WEBVIEW_CALL_ACTIVITY = "ru.forbion.f7cloud.feature.talk.TalkCallActivity"
fun launchIncomingCall(
context: Context,
session: AuthSession,
acceptUrl: String,
roomDisplayName: String? = null,
) {
val roomToken = extractRoomToken(stripDirectCallHash(acceptUrl)) ?: run {
showError(context, "Некорректная ссылка звонка")
return
}
launchRoomCall(
context = context,
session = session,
callContext = TalkNativeCallContext(
roomToken = roomToken,
displayName = roomDisplayName.orEmpty(),
joinExistingCall = true,
),
incomingFromNotification = true,
suppressIncomingRingtone = true,
)
}
fun launchRoomCall(context: Context, session: AuthSession, roomToken: String) {
launchRoomCall(
context = context,
session = session,
callContext = TalkNativeCallContext(roomToken = roomToken.trim()),
incomingFromNotification = false,
)
}
fun launchRoomCall(context: Context, session: AuthSession, callContext: TalkNativeCallContext) {
launchRoomCall(
context = context,
session = session,
callContext = callContext,
incomingFromNotification = callContext.joinExistingCall,
)
}
private fun launchRoomCall(
context: Context,
session: AuthSession,
callContext: TalkNativeCallContext,
incomingFromNotification: Boolean,
suppressIncomingRingtone: Boolean = false,
) {
if (!TalkNativeConfig.useNativeWebRtc) {
if (TalkNativeConfig.allowWebViewCallFallback) {
launchWebViewFallback(
context,
session,
callContext.roomToken.trim(),
incomingFromNotification,
)
} else {
showError(context, "Нативные звонки отключены")
}
return
}
if (!TalkVendorBootstrap.ensureSessionReady(context, session)) {
showError(context, "Не удалось подготовить Talk для звонка")
return
}
val token = callContext.roomToken.trim()
if (token.isBlank()) {
showError(context, "Некорректная комната звонка")
return
}
runCatching {
startCallActivity(
context,
buildCallIntent(context, session, callContext, incomingFromNotification, suppressIncomingRingtone),
)
}.onFailure { error ->
Log.e(TAG, "Native CallActivity launch failed", error)
if (TalkNativeConfig.allowWebViewCallFallback) {
launchWebViewFallback(context, session, token, incomingFromNotification)
} else {
showError(context, "Не удалось начать звонок")
}
}
}
private fun buildCallIntent(
context: Context,
session: AuthSession,
callContext: TalkNativeCallContext,
incomingFromNotification: Boolean,
suppressIncomingRingtone: Boolean = false,
): Intent =
Intent(context, CallActivity::class.java).apply {
putExtra(BundleKeys.KEY_ROOM_TOKEN, callContext.roomToken.trim())
putExtra(BundleKeys.KEY_MODIFIED_BASE_URL, session.serverUrl.trimEnd('/'))
val displayName = callContext.displayName.trim()
putExtra(KEY_CONVERSATION_NAME, displayName)
putExtra(KEY_CONVERSATION_DISPLAY_NAME, displayName)
putExtra(BundleKeys.KEY_ROOM_ONE_TO_ONE, callContext.isOneToOne)
putExtra(BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO, true)
putExtra(BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO, true)
putExtra(KEY_IS_MODERATOR, true)
putExtra(KEY_RECORDING_STATE, 0)
putExtra(BundleKeys.KEY_CALL_VOICE_ONLY, callContext.isVoiceOnly)
if (incomingFromNotification) {
putExtra(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, true)
}
if (suppressIncomingRingtone) {
putExtra(BundleKeys.KEY_SUPPRESS_INCOMING_RINGTONE, true)
}
}
private fun startCallActivity(context: Context, intent: Intent) {
val appContext = context.applicationContext
ApplicationWideCurrentRoomHolder.getInstance().clear()
CallForegroundService.stop(appContext)
if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// CallActivity is singleTask; onNewIntent restarts when a previous call screen is still alive.
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)
}
private fun launchWebViewFallback(
context: Context,
session: AuthSession,
roomToken: String,
autoJoin: Boolean,
) {
val callUrl = "${session.serverUrl.trimEnd('/')}/call/$roomToken"
val intent = Intent().apply {
setClassName(context, WEBVIEW_CALL_ACTIVITY)
if (context !is Activity) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
putExtra("url", callUrl)
putExtra("title", if (autoJoin) "Входящий звонок" else "Звонок")
putExtra("username", session.username)
putExtra("password", session.appPassword)
putExtra("server_url", session.serverUrl)
putExtra("trust_all_certs", session.trustAllCerts)
putExtra("auto_join", autoJoin)
}
context.startActivity(intent)
}
private fun showError(context: Context, message: String) {
Toast.makeText(context.applicationContext, message, Toast.LENGTH_LONG).show()
}
private fun stripDirectCallHash(url: String): String {
val hash = url.indexOf('#')
return if (hash >= 0) url.substring(0, hash) else url
}
private fun extractRoomToken(url: String): String? {
val path = runCatching { android.net.Uri.parse(url).path }.getOrNull() ?: url
val marker = "/call/"
val idx = path.indexOf(marker)
if (idx < 0) return null
val rest = path.substring(idx + marker.length)
val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it }
return rest.substring(0, end).ifBlank { null }
}
}
@@ -0,0 +1,21 @@
package ru.forbion.f7cloud.feature.talknative
/**
* Feature flags for gradual talk-android integration.
* Vendor source: vendor/talk-android/ (v23.0.0, GPL-3.0-or-later).
*/
object TalkNativeConfig {
/** When true, AppScaffold routes Talk tab through [TalkNativeFacade]. */
var useNativeTalkShell: Boolean = false
/** Sync F7cloud session into talk-android User DB on app start. */
var bootstrapVendorRuntime: Boolean = true
/**
* When true, calls use ru.f7cloud.talk.activities.CallActivity (native WebRTC).
*/
var useNativeWebRtc: Boolean = true
/** Debug-only escape hatch; production uses native CallActivity only. */
var allowWebViewCallFallback: Boolean = false
}
@@ -0,0 +1,16 @@
package ru.forbion.f7cloud.feature.talknative
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Entry point for talk-android shell integration.
* When [TalkNativeConfig.useNativeTalkShell] is false, the app uses legacy [TalkScreen].
*/
object TalkNativeFacade {
val isNativeShellEnabled: Boolean get() = TalkNativeConfig.useNativeTalkShell
fun accountFor(session: AuthSession): TalkAccount = TalkAuthBridge.fromSession(session)
/** Placeholder for future native Talk list/chat Activities from vendor/talk-android. */
fun nativeShellReady(): Boolean = false
}
@@ -0,0 +1,34 @@
package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.content.Intent
import android.util.Log
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.push.PushIntents
/**
* Routes f7push call Accept to native WebRTC (when enabled) or WebView fallback.
*/
object TalkPushBridge {
private const val TAG = "TalkPushBridge"
fun launchAcceptedCall(context: Context, acceptUrl: String) {
val session = AuthStore(context).load()
if (session == null) {
Log.w(TAG, "No session for accepted call")
return
}
TalkNativeCallLauncher.launchIncomingCall(context, session, acceptUrl)
}
fun intentForAcceptedCall(context: Context, acceptUrl: String): Intent? {
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null
return launch.apply {
action = PushIntents.ACTION_OPEN_CALL
putExtra(PushIntents.EXTRA_ACCEPT_URL, acceptUrl)
putExtra(PushIntents.EXTRA_AUTO_ACCEPT, true)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}
}
@@ -0,0 +1,27 @@
package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.util.Log
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
/**
* Hooks F7cloud auth into talk-android runtime (User DB, capabilities) for native WebRTC.
*/
object TalkVendorBootstrap {
private const val TAG = "TalkVendorBootstrap"
fun onApplicationCreate(context: Context) {
if (!TalkNativeConfig.bootstrapVendorRuntime) return
val session = AuthStore(context).load() ?: return
runCatching { TalkVendorUserSync.ensureUser(context, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
}
fun ensureSessionReady(context: Context, session: AuthSession): Boolean {
if (!TalkNativeConfig.bootstrapVendorRuntime) return true
return runCatching { TalkVendorUserSync.ensureUser(context, session) }
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}") }
.getOrDefault(false)
}
}
@@ -0,0 +1,36 @@
package ru.forbion.f7cloud.feature.talknative
import android.content.Context
import android.util.Log
import ru.f7cloud.talk.application.F7cloudTalkApplication
import ru.f7cloud.talk.f7cloud.F7TalkUserSync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import ru.forbion.f7cloud.core.auth.AuthSession
/**
* Ensures talk-android [ru.f7cloud.talk.data.user.model.User] exists for F7cloud sessions.
* Required before native [ru.f7cloud.talk.activities.CallActivity] can run.
*/
object TalkVendorUserSync {
private const val TAG = "TalkVendorUserSync"
fun ensureUser(context: Context, session: AuthSession): Boolean {
if (context.applicationContext !is F7cloudTalkApplication) {
Log.w(TAG, "Application is not F7cloudTalkApplication, skip sync")
return false
}
return runBlocking(Dispatchers.IO) {
runCatching {
F7TalkUserSync.get().ensureUser(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
davUserId = session.davUserId,
)
}
.onFailure { Log.w(TAG, "Talk user sync failed: ${it.message}", it) }
.getOrNull() != null
}
}
}
+47
View File
@@ -0,0 +1,47 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
id 'com.google.devtools.ksp'
}
android {
namespace 'ru.forbion.f7cloud.feature.talk'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:push')
implementation project(':feature:talk-native')
implementation project(':core:designsystem')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
implementation 'org.json:json:20240303'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.foundation:foundation-layout'
implementation 'androidx.activity:activity-compose:1.10.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
implementation 'io.coil-kt:coil-compose:2.7.0'
implementation 'androidx.room:room-runtime:2.7.2'
implementation 'androidx.room:room-ktx:2.7.2'
ksp 'androidx.room:room-compiler:2.7.2'
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<activity
android:name=".TalkCallActivity"
android:exported="false"
android:hardwareAccelerated="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".TalkShareActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,320 @@
/**
* F7cloud APK: skip "Check devices" and join incoming calls with camera/mic off.
* Requires sessionStorage f7_apk_direct_join=1 (set from Android on push accept).
*/
(function () {
'use strict';
var LOG = 'F7TalkJoin';
var LEFT_PREFIX = 'f7_apk_left_';
var STYLE_ID = 'f7-talk-hide-media-style';
function log(msg) {
try { console.debug('[' + LOG + '] ' + msg); } catch (e) { /* ignore */ }
}
function roomToken() {
var m = window.location.pathname.match(/\/call\/([A-Za-z0-9]+)/);
return m ? m[1] : null;
}
function leftKey() {
var t = roomToken();
return t ? (LEFT_PREFIX + t) : null;
}
function markLeft() {
try {
var k = leftKey();
if (k) {
sessionStorage.setItem(k, String(Date.now()));
}
sessionStorage.removeItem('f7_apk_direct_join');
document.documentElement.removeAttribute('data-f7-direct-join');
log('marked left');
} catch (e) { /* ignore */ }
}
function leftRecently() {
try {
var k = leftKey();
if (!k) {
return false;
}
var ts = parseInt(sessionStorage.getItem(k) || '0', 10);
return ts > 0 && (Date.now() - ts) < (10 * 60 * 1000);
} catch (e) {
return false;
}
}
function isDirectJoin() {
if (leftRecently()) {
return false;
}
try {
return sessionStorage.getItem('f7_apk_direct_join') === '1';
} catch (e) {
return false;
}
}
function stripDirectCallHash() {
try {
if (window.location.hash === '#direct-call') {
history.replaceState(history.state, '', window.location.pathname + window.location.search);
log('stripped #direct-call hash');
}
} catch (e) { /* ignore */ }
}
function prepareStorage() {
stripDirectCallHash();
try {
localStorage.setItem('showMediaSettings', 'false');
document.documentElement.setAttribute('data-f7-direct-join', '1');
} catch (e) { /* ignore */ }
var token = roomToken();
if (token) {
try {
localStorage.setItem('videoDisabled_' + token, 'true');
localStorage.removeItem('audioDisabled_' + token);
} catch (e2) { /* ignore */ }
}
}
function hideMediaDialogs() {
var roots = document.querySelectorAll('.media-settings');
for (var i = 0; i < roots.length; i++) {
var el = roots[i];
var modal = el.closest('.modal-wrapper')
|| el.closest('.modal-container')
|| el.closest('[role="dialog"]')
|| el;
modal.style.setProperty('display', 'none', 'important');
modal.style.setProperty('visibility', 'hidden', 'important');
modal.style.setProperty('opacity', '0', 'important');
modal.style.setProperty('pointer-events', 'none', 'important');
}
}
function injectHideStyle() {
if (document.getElementById(STYLE_ID)) {
return;
}
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = ''
+ 'html[data-f7-direct-join="1"] .modal-wrapper,'
+ 'html[data-f7-direct-join="1"] .modal-container,'
+ 'html[data-f7-direct-join="1"] .media-settings,'
+ 'html[data-f7-direct-join="1"] .modal-wrapper .media-settings'
+ '{display:none!important;visibility:hidden!important;opacity:0!important;pointer-events:none!important;}';
(document.head || document.documentElement).appendChild(style);
hideMediaDialogs();
}
function eventBus() {
if (window._nc_event_bus) {
return window._nc_event_bus;
}
if (window.OC && window.OC._eventBus) {
return window.OC._eventBus;
}
return null;
}
function emitEvent(name, arg) {
var bus = eventBus();
if (!bus || typeof bus.emit !== 'function') {
return false;
}
try {
bus.emit(name, arg === undefined ? '' : arg);
return true;
} catch (e) {
return false;
}
}
function labelMatchesMediaToggle(label) {
return label.indexOf('camera') !== -1 || label.indexOf('video') !== -1
|| label.indexOf('камер') !== -1 || label.indexOf('видео') !== -1
|| label.indexOf('mute video') !== -1 || label.indexOf('turn off') !== -1;
}
function disableCameraInMediaSettings() {
var root = document.querySelector('.media-settings');
if (!root) {
return;
}
var toggles = root.querySelector('.media-settings__toggles');
var scope = toggles || root;
var buttons = scope.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var b = buttons[i];
var label = ((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || '')).toLowerCase();
if (!labelMatchesMediaToggle(label)) {
continue;
}
var pressed = b.getAttribute('aria-pressed');
if (pressed === 'true' || pressed === null) {
b.click();
log('toggled camera off: ' + label);
}
}
}
function clickJoinButtons() {
var root = document.querySelector('.media-settings');
if (root) {
var inDialog = root.querySelectorAll('button.action-button, button.primary, button[class*="join"]');
for (var d = 0; d < inDialog.length; d++) {
var db = inDialog[d];
if (db.disabled || db.offsetParent === null) {
continue;
}
var dt = ((db.getAttribute('aria-label') || '') + ' ' + (db.textContent || '')).toLowerCase();
if (dt.indexOf('join') !== -1 || dt.indexOf('присоедин') !== -1
|| dt.indexOf('answer') !== -1 || dt.indexOf('начать') !== -1
|| dt.indexOf('apply') !== -1 || dt.indexOf('примен') !== -1
|| dt.indexOf('save') !== -1 || dt.indexOf('сохран') !== -1
|| dt.indexOf('готово') !== -1) {
db.click();
log('clicked in-dialog: ' + dt);
return true;
}
}
}
var btn = document.querySelector('button.join-call');
if (btn && !btn.disabled && btn.offsetParent !== null) {
btn.click();
log('clicked button.join-call');
return true;
}
var buttons = document.querySelectorAll('button.action-button, button.primary');
for (var i = 0; i < buttons.length; i++) {
var b = buttons[i];
if (b.disabled || b.offsetParent === null) {
continue;
}
var t = ((b.getAttribute('aria-label') || '') + ' ' + (b.textContent || '')).toLowerCase();
if (t.indexOf('join') !== -1 || t.indexOf('присоедин') !== -1
|| t.indexOf('answer') !== -1 || t.indexOf('ответ') !== -1
|| t.indexOf('принять') !== -1 || t.indexOf('начать') !== -1) {
b.click();
log('clicked: ' + t);
return true;
}
}
return false;
}
function forceJoin() {
if (!isDirectJoin()) {
return false;
}
prepareStorage();
injectHideStyle();
disableCameraInMediaSettings();
emitEvent('talk:media-settings:hide');
if (clickJoinButtons()) {
try { sessionStorage.removeItem('f7_apk_direct_join'); } catch (e) { /* ignore */ }
setTimeout(function () {
document.documentElement.removeAttribute('data-f7-direct-join');
}, 5000);
return true;
}
return false;
}
function installLeaveHook() {
if (window.__f7TalkLeaveHook) {
return;
}
window.__f7TalkLeaveHook = true;
document.addEventListener('click', function (ev) {
var el = ev.target;
if (!el) {
return;
}
for (var i = 0; i < 5 && el; i++) {
if (el.tagName && el.tagName.toLowerCase() === 'button') {
break;
}
el = el.parentElement;
}
if (!el || !el.tagName || el.tagName.toLowerCase() !== 'button') {
return;
}
var txt = ((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '')).toLowerCase();
if (txt.indexOf('leave') !== -1 || txt.indexOf('hang up') !== -1 || txt.indexOf('end') !== -1
|| txt.indexOf('выйти') !== -1 || txt.indexOf('покин') !== -1 || txt.indexOf('заверш') !== -1) {
markLeft();
}
}, true);
}
function installBusHook() {
if (window.__f7TalkBusHook) {
return;
}
var bus = eventBus();
if (!bus || typeof bus.on !== 'function') {
return;
}
window.__f7TalkBusHook = true;
bus.on('talk:media-settings:show', function () {
if (!isDirectJoin()) {
return;
}
log('block media-settings');
prepareStorage();
injectHideStyle();
setTimeout(function () {
disableCameraInMediaSettings();
emitEvent('talk:media-settings:hide');
forceJoin();
}, 50);
});
}
var tries = 0;
function tick() {
if (!isDirectJoin()) {
return;
}
installLeaveHook();
installBusHook();
injectHideStyle();
tries++;
if (forceJoin() || tries >= 100) {
return;
}
setTimeout(tick, 200);
}
function start() {
prepareStorage();
if (!isDirectJoin()) {
return;
}
if (!roomToken()) {
return;
}
tries = 0;
installLeaveHook();
installBusHook();
tick();
}
window.addEventListener('hashchange', start);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
start();
} else {
document.addEventListener('DOMContentLoaded', start);
window.addEventListener('load', start);
}
})();
@@ -0,0 +1,51 @@
package ru.forbion.f7cloud.feature.talk
import android.content.Context
import android.os.PowerManager
import android.util.Log
/** Keeps the device awake while a Talk call page is open (MIUI / Doze mitigation). */
class F7CallWakeLock(context: Context) {
private val wakeLock: PowerManager.WakeLock =
context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "f7cloud:talk_call")
.apply { setReferenceCounted(false) }
private var held = false
fun updateForUrl(url: String?) {
if (TalkHelper.isActiveCallUrl(url)) {
acquire()
} else {
release()
}
}
fun releaseAll() {
release()
}
private fun acquire() {
if (held) return
runCatching {
wakeLock.acquire(4 * 60 * 60 * 1000L)
held = true
Log.d(TAG, "Wake lock acquired for Talk call")
}.onFailure {
Log.w(TAG, "Wake lock acquire failed", it)
}
}
private fun release() {
if (!held) return
runCatching {
if (wakeLock.isHeld) wakeLock.release()
}.onFailure {
Log.w(TAG, "Wake lock release failed", it)
}
held = false
}
companion object {
private const val TAG = "F7CallWakeLock"
}
}
@@ -0,0 +1,17 @@
package ru.forbion.f7cloud.feature.talk
import ru.forbion.f7cloud.core.auth.AuthSession
object TalkAssets {
fun spreed(session: AuthSession, fileName: String): String {
return "${session.serverUrl.trimEnd('/')}/themes/forbion/images/spreed/$fileName"
}
fun header(session: AuthSession, fileName: String): String {
return "${session.serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
}
fun theme(session: AuthSession, fileName: String): String {
return "${session.serverUrl.trimEnd('/')}/themes/forbion/images/$fileName"
}
}
@@ -0,0 +1,203 @@
package ru.forbion.f7cloud.feature.talk
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.davFileUrl
import ru.forbion.f7cloud.core.network.davFolderUrl
import java.util.concurrent.TimeUnit
object TalkAttachmentUploader {
private const val MAX_UPLOAD_BYTES = 25L * 1024 * 1024
private const val SHARE_TYPE_ROOM = "10"
private const val DEFAULT_ATTACHMENT_FOLDER = "/Talk"
fun uploadAndShare(
session: AuthSession,
roomToken: String,
fileName: String,
mimeType: String,
bytes: ByteArray,
caption: String = "",
replyTo: Long? = null,
) {
require(fileName.isNotBlank()) { "Имя файла пустое" }
if (bytes.isEmpty()) error("Файл пуст")
if (bytes.size > MAX_UPLOAD_BYTES) {
error("Файл слишком большой (макс. 25 МБ)")
}
val client = uploadClient(session)
val davUserId = OcsUserResolver.resolveDavUserId(session)
val attachmentFolder = fetchAttachmentFolder(client, session)
val remotePath = uniqueRemotePath(client, session, davUserId, attachmentFolder, fileName)
ensureFolderExists(client, session, davUserId, attachmentFolder)
putFile(client, session, davUserId, remotePath, mimeType, bytes)
shareToRoom(client, session, roomToken, remotePath, caption, replyTo)
}
private fun uploadClient(session: AuthSession): OkHttpClient =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
.newBuilder()
.readTimeout(5, TimeUnit.MINUTES)
.writeTimeout(5, TimeUnit.MINUTES)
.callTimeout(6, TimeUnit.MINUTES)
.build()
private fun fetchAttachmentFolder(client: OkHttpClient, session: AuthSession): String {
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/capabilities?format=json"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) return DEFAULT_ATTACHMENT_FOLDER
val data = JSONObject(response.body!!.string())
.optJSONObject("ocs")
?.optJSONObject("data")
?.optJSONObject("capabilities")
?.optJSONObject("spreed")
?.optJSONObject("config")
?.optJSONObject("attachments")
val folder = data?.optString("folder").orEmpty().trim()
return folder.ifBlank { DEFAULT_ATTACHMENT_FOLDER }
}
}
private fun uniqueRemotePath(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
attachmentFolder: String,
fileName: String,
): String {
val safeName = fileName.substringAfterLast('/').trim().ifBlank { "file" }
var candidate = joinRemotePath(attachmentFolder, safeName)
var counter = 2
while (fileExists(client, session, davUserId, candidate)) {
val dot = safeName.lastIndexOf('.')
val renamed = if (dot > 0) {
"${safeName.substring(0, dot)} ($counter)${safeName.substring(dot)}"
} else {
"$safeName ($counter)"
}
candidate = joinRemotePath(attachmentFolder, renamed)
counter++
}
return candidate
}
private fun joinRemotePath(folder: String, fileName: String): String {
val base = folder.trim('/').ifBlank { "Talk" }
return "/$base/$fileName"
}
private fun fileExists(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
remotePath: String,
): Boolean {
val url = davFileUrl(session.serverUrl, davUserId, remotePath.trim('/'))
val request = Request.Builder().url(url).head().build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
return response.isSuccessful
}
}
private fun ensureFolderExists(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
attachmentFolder: String,
) {
val segments = attachmentFolder.trim('/').split('/').filter { it.isNotBlank() }
var built = ""
for (segment in segments) {
built = if (built.isEmpty()) segment else "$built/$segment"
mkcol(client, session, davUserId, built)
}
}
private fun mkcol(client: OkHttpClient, session: AuthSession, davUserId: String, relativePath: String) {
val url = davFolderUrl(session.serverUrl, davUserId, relativePath).trimEnd('/')
val request = Request.Builder()
.url(url)
.method("MKCOL", ByteArray(0).toRequestBody(null))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
// 405 = already exists
}
}
private fun putFile(
client: OkHttpClient,
session: AuthSession,
davUserId: String,
remotePath: String,
mimeType: String,
bytes: ByteArray,
) {
val mediaType = mimeType.ifBlank { "application/octet-stream" }.toMediaType()
val url = davFileUrl(session.serverUrl, davUserId, remotePath.trim('/'))
val request = Request.Builder()
.url(url)
.put(bytes.toRequestBody(mediaType))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Не удалось загрузить файл: HTTP ${response.code}")
}
}
}
private fun shareToRoom(
client: OkHttpClient,
session: AuthSession,
roomToken: String,
remotePath: String,
caption: String,
replyTo: Long?,
) {
val meta = JSONObject()
if (caption.isNotBlank()) meta.put("caption", caption)
if (replyTo != null && replyTo > 0L) meta.put("replyTo", replyTo.toString())
val body = FormBody.Builder()
.add("path", remotePath)
.add("shareWith", roomToken)
.add("shareType", SHARE_TYPE_ROOM)
.add("talkMetaData", meta.toString())
.build()
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/files_sharing/api/v1/shares"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Не удалось отправить вложение в чат: HTTP ${response.code}")
}
val ocs = JSONObject(response.body?.string().orEmpty()).optJSONObject("ocs")
if (ocs?.optJSONObject("meta")?.optString("status") == "failure") {
error(ocs.optJSONObject("meta")?.optString("message") ?: "Share failed")
}
}
}
}
@@ -0,0 +1,453 @@
package ru.forbion.f7cloud.feature.talk
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.HttpAuthHandler
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import okhttp3.OkHttpClient
import okhttp3.Request
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.AuthStore
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.push.F7IncomingCallQueue
import java.io.FilterInputStream
import java.net.URI
class TalkCallActivity : ComponentActivity() {
private var webViewRef: WebView? = null
private var pageReady by mutableStateOf(false)
private var callWakeLock: F7CallWakeLock? = null
private var pendingAutoJoinUrl: String? = null
private var pendingPermissionRequest: PermissionRequest? = null
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
val request = pendingPermissionRequest ?: return@registerForActivityResult
pendingPermissionRequest = null
if (grants.values.all { it }) {
request.grant(request.resources)
} else {
request.deny()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val launch = readLaunch() ?: run {
finish()
return
}
val callUrl = TalkHelper.getCallUrlForWebViewLoad(launch.url)
val roomToken = TalkHelper.extractRoomToken(callUrl)
if (launch.autoJoin) {
pendingAutoJoinUrl = callUrl
F7IncomingCallQueue.dismissAndShowNext(
this,
roomToken ?: TalkHelper.extractRoomToken(launch.url),
)
}
callWakeLock = F7CallWakeLock(this).also { it.updateForUrl(callUrl) }
ensureMediaPermissions()
val httpClient = NetworkFactory.newAuthedClient(
launch.username,
launch.password,
launch.trustAllCerts,
)
val authHosts = buildAuthHosts(launch, callUrl)
setContent {
F7Theme {
Scaffold(
topBar = {
TopAppBar(
title = { Text(launch.title) },
navigationIcon = {
IconButton(onClick = { finish() }) {
Text("", style = MaterialTheme.typography.titleLarge)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = F7Colors.Surface,
titleContentColor = F7Colors.TextPrimary,
),
)
},
containerColor = F7Colors.Background,
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(F7Colors.Surface),
) {
TalkCallWebView(
launch = launch,
callUrl = callUrl,
roomToken = roomToken,
autoJoin = launch.autoJoin,
httpClient = httpClient,
authHosts = authHosts,
onPageReady = { pageReady = true },
onUrlChanged = { url ->
callWakeLock?.updateForUrl(url)
},
)
if (!pageReady) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
}
}
}
}
}
}
private fun readLaunch(): TalkCallLaunch? {
val url = intent.getStringExtra(EXTRA_URL) ?: return null
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
if (url.isBlank() || username.isBlank()) return null
return TalkCallLaunch(
url = url,
title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "Звонок" },
username = username,
password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty(),
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
autoJoin = intent.getBooleanExtra(EXTRA_AUTO_JOIN, false),
)
}
private fun ensureMediaPermissions() {
val needed = mutableListOf<String>()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
needed += Manifest.permission.CAMERA
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
needed += Manifest.permission.RECORD_AUDIO
}
if (needed.isNotEmpty()) {
permissionLauncher.launch(needed.toTypedArray())
}
}
private fun handleWebViewPermissionRequest(request: PermissionRequest) {
val androidPerms = linkedSetOf<String>()
for (resource in request.resources) {
when (resource) {
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> androidPerms += Manifest.permission.CAMERA
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> androidPerms += Manifest.permission.RECORD_AUDIO
}
}
if (androidPerms.isEmpty()) {
request.grant(request.resources)
return
}
val needAsk = androidPerms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (needAsk.isEmpty()) {
request.grant(request.resources)
return
}
pendingPermissionRequest = request
permissionLauncher.launch(needAsk.toTypedArray())
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun TalkCallWebView(
launch: TalkCallLaunch,
callUrl: String,
roomToken: String?,
autoJoin: Boolean,
httpClient: OkHttpClient,
authHosts: Set<String>,
onPageReady: () -> Unit,
onUrlChanged: (String?) -> Unit,
) {
val serverPrefix = launch.serverUrl.trimEnd('/')
val origin = runCatching { URI(launch.serverUrl).scheme + "://" + URI(launch.serverUrl).host }.getOrNull()
?: serverPrefix
val directJoinJs = if (autoJoin) rememberAssetJs("f7_talk_direct_join.js") else null
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
webViewRef = this
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
CookieManager.getInstance().setAcceptCookie(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
@Suppress("DEPRECATION")
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
loadWithOverviewMode = true
useWideViewPort = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
userAgentString = TALK_COMPAT_USER_AGENT
}
if (autoJoin) {
evaluateJavascript(TalkHelper.buildDirectJoinDocumentStartScript(), null)
}
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
runOnUiThread { handleWebViewPermissionRequest(request) }
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
pageReady = false
onUrlChanged(url)
if (redirectPendingCallIfNeeded(view, url)) return
}
override fun onPageFinished(view: WebView?, url: String?) {
onUrlChanged(url)
val body = buildString {
append(TalkHelper.buildPreconnectScript(origin))
append('\n')
if (autoJoin || pendingAutoJoinUrl != null) {
append(TalkHelper.buildDirectJoinBootstrapScript(roomToken))
} else {
append(TalkHelper.buildNormalCallBootstrapScript(roomToken))
}
}
view?.evaluateJavascript(
TalkWebBranding.jsAfterBranding(launch.serverUrl, body),
null,
)
if ((autoJoin || pendingAutoJoinUrl != null) && !directJoinJs.isNullOrBlank()) {
view?.evaluateJavascript(directJoinJs, null)
}
onPageReady()
}
override fun onReceivedHttpAuthRequest(
view: WebView?,
handler: HttpAuthHandler?,
host: String?,
realm: String?,
) {
if (host != null && authHosts.any { host.equals(it, ignoreCase = true) }) {
handler?.proceed(launch.username, launch.password)
} else {
super.onReceivedHttpAuthRequest(view, handler, host, realm)
}
}
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest,
): WebResourceResponse? {
val url = request.url?.toString() ?: return null
if (!url.startsWith(serverPrefix, ignoreCase = true)) return null
return runCatching {
val builder = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
request.requestHeaders.forEach { (k, v) ->
if (!k.equals("Authorization", ignoreCase = true)) {
builder.header(k, v)
}
}
val response = httpClient.newCall(builder.build()).execute()
if (!response.isSuccessful || response.body == null) {
response.close()
return null
}
val body = response.body!!
val stream = object : FilterInputStream(body.byteStream()) {
override fun close() {
super.close()
response.close()
}
}
WebResourceResponse(
body.contentType()?.let { "${it.type}/${it.subtype}" },
body.contentType()?.charset()?.name() ?: "utf-8",
stream,
)
}.getOrNull()
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
if (launch.trustAllCerts) {
handler?.proceed()
} else {
super.onReceivedSslError(view, handler, error)
}
}
}
loadUrl(callUrl)
}
},
onRelease = { webViewRef = null },
)
}
private fun redirectPendingCallIfNeeded(view: WebView?, loadingUrl: String?): Boolean {
val pending = pendingAutoJoinUrl ?: return false
if (loadingUrl.isNullOrBlank()) return false
if (TalkHelper.isCallRoomUrl(loadingUrl)) return false
if (!TalkHelper.isPortalHomeUrl(loadingUrl)) return false
view?.stopLoading()
view?.loadUrl(pending)
return true
}
private fun rememberAssetJs(name: String): String? {
return runCatching {
assets.open(name).bufferedReader().use { it.readText() }
}.getOrNull()
}
override fun onDestroy() {
callWakeLock?.releaseAll()
callWakeLock = null
webViewRef?.destroy()
webViewRef = null
super.onDestroy()
}
companion object {
private const val EXTRA_URL = "url"
private const val EXTRA_TITLE = "title"
private const val EXTRA_USERNAME = "username"
private const val EXTRA_PASSWORD = "password"
private const val EXTRA_SERVER_URL = "server_url"
private const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
private const val EXTRA_AUTO_JOIN = "auto_join"
private const val TALK_COMPAT_USER_AGENT =
"Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/131.0.0.0 Mobile Safari/537.36"
fun intent(context: Context, launch: TalkCallLaunch): Intent =
Intent(context, TalkCallActivity::class.java).apply {
putExtra(EXTRA_URL, launch.url)
putExtra(EXTRA_TITLE, launch.title)
putExtra(EXTRA_USERNAME, launch.username)
putExtra(EXTRA_PASSWORD, launch.password)
putExtra(EXTRA_SERVER_URL, launch.serverUrl)
putExtra(EXTRA_TRUST_ALL_CERTS, launch.trustAllCerts)
putExtra(EXTRA_AUTO_JOIN, launch.autoJoin)
}
fun launch(context: Context, session: AuthSession, roomToken: String) {
val url = TalkHelper.buildCallUrl(session.serverUrl, roomToken)
context.startActivity(
intent(
context,
TalkCallLaunch(
url = url,
title = "Звонок",
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
autoJoin = false,
),
),
)
}
fun launchFromAcceptUrl(context: Context, acceptUrl: String) {
val session = AuthStore(context).load() ?: return
val callUrl = TalkHelper.getCallUrlForWebViewLoad(acceptUrl)
context.startActivity(
intent(
context,
TalkCallLaunch(
url = callUrl,
title = "Входящий звонок",
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
autoJoin = true,
),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
private fun buildAuthHosts(launch: TalkCallLaunch, callUrl: String): Set<String> {
val hosts = mutableSetOf<String>()
runCatching { URI(launch.url).host }.getOrNull()?.let { hosts += it }
runCatching { URI(callUrl).host }.getOrNull()?.let { hosts += it }
runCatching { URI(launch.serverUrl).host }.getOrNull()?.let { hosts += it }
return hosts
}
}
}
data class TalkCallLaunch(
val url: String,
val title: String,
val username: String,
val password: String,
val serverUrl: String,
val trustAllCerts: Boolean,
val autoJoin: Boolean = false,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
package ru.forbion.f7cloud.feature.talk
/** Parses Talk/spreed deep links from push and web URLs. */
object TalkDeepLink {
fun extractRoomToken(url: String?): String? {
if (url.isNullOrBlank()) return null
val callMarker = "/call/"
val callIdx = url.indexOf(callMarker)
if (callIdx >= 0) {
val rest = url.substring(callIdx + callMarker.length)
return rest.takeWhile { it.isLetterOrDigit() }.ifBlank { null }
}
val spreedMarker = "/apps/spreed/"
val spreedIdx = url.indexOf(spreedMarker)
if (spreedIdx >= 0) {
val rest = url.substring(spreedIdx + spreedMarker.length)
val token = rest.substringBefore('/').substringBefore('?').substringBefore('#')
if (token.isNotBlank() && token != "api") return token
}
return null
}
fun extractMessageId(url: String?): Long? {
if (url.isNullOrBlank()) return null
val fragMarker = "#message_"
val idx = url.indexOf(fragMarker)
if (idx >= 0) {
return url.substring(idx + fragMarker.length).takeWhile { it.isDigit() }.toLongOrNull()
}
return null
}
}

Some files were not shown because too many files have changed in this diff Show More