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:
@@ -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 />
|
||||
+163
@@ -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,
|
||||
)
|
||||
}
|
||||
+1362
File diff suppressed because it is too large
Load Diff
+290
@@ -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,
|
||||
}
|
||||
+63
@@ -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"
|
||||
}
|
||||
}
|
||||
+446
@@ -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
|
||||
}
|
||||
}
|
||||
+212
@@ -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
|
||||
}
|
||||
}
|
||||
+86
@@ -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"
|
||||
}
|
||||
}
|
||||
+645
@@ -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"))
|
||||
}
|
||||
}
|
||||
+207
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
+290
@@ -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() }
|
||||
}
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
+1016
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+420
@@ -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,
|
||||
)
|
||||
+163
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+384
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
+40
@@ -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 = "Имя папки",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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
|
||||
}
|
||||
}
|
||||
+79
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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| | | |<br\s*/?>)*</p>""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
private val NBSP_RUN = Regex("""(?: | | |\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, " ")
|
||||
// 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("&", "&")
|
||||
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
|
||||
}
|
||||
}
|
||||
+262
@@ -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,
|
||||
)
|
||||
}
|
||||
+109
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
+28
@@ -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)}"
|
||||
}
|
||||
}
|
||||
+12
@@ -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,
|
||||
)
|
||||
+190
@@ -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 }
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user