Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,68 @@
package ru.forbion.f7cloud.feature.f7support
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import android.widget.Toast
import androidx.core.content.FileProvider
import java.io.File
import java.util.UUID
data class SupportPendingFile(
val localId: String = UUID.randomUUID().toString(),
val fileName: String,
val bytes: ByteArray,
val mimeType: String,
)
internal object SupportFileIO {
fun readUris(context: Context, uris: List<Uri>): List<SupportPendingFile> {
return uris.mapNotNull { uri ->
runCatching { readUri(context, uri) }.getOrNull()
}
}
fun readUri(
context: Context,
uri: Uri,
fallbackName: String = "file.bin",
): SupportPendingFile {
val resolver = context.contentResolver
var name = fallbackName
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
}
}
}
val mime = resolver.getType(uri) ?: "application/octet-stream"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
return SupportPendingFile(fileName = name, bytes = bytes, mimeType = mime)
}
fun openBytes(context: Context, fileName: String, bytes: ByteArray, mimeType: String) {
val safeName = fileName.replace(Regex("[\\\\/:*?\"<>|]"), "_").ifBlank { "file" }
val cacheDir = File(context.cacheDir, "f7support").apply { mkdirs() }
val file = File(cacheDir, safeName)
file.writeBytes(bytes)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType.ifBlank { "application/octet-stream" })
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
context.startActivity(Intent.createChooser(intent, "Открыть с помощью"))
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, "Нет приложения для открытия этого файла", Toast.LENGTH_LONG).show()
}
}
}
@@ -0,0 +1,420 @@
package ru.forbion.f7cloud.feature.f7support
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.core.network.applyOcsJson
import ru.forbion.f7cloud.core.network.ocsData
import ru.forbion.f7cloud.core.network.ocsMeta
import ru.forbion.f7cloud.core.network.parseJsonArray
import ru.forbion.f7cloud.core.network.parseJsonObject
import java.net.URI
class SupportRepository {
suspend fun loadConfig(session: AuthSession): SupportConfig {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val request = Request.Builder()
.url("${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/f7support/api/v1/mobile-config?format=json")
.applyOcsJson()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Support config HTTP ${response.code}")
}
val data = parseJsonObject(response.body!!.string(), "конфигурация поддержки").ocsData()
val apiBase = data?.optString("supportApiBase").orEmpty().ifBlank {
"https://support.f7cloud.ru"
}
val serverAddress = data?.optString("serverAddress").orEmpty().ifBlank {
hostFromUrl(session.serverUrl)
}
return SupportConfig(
supportApiBase = apiBase.trimEnd('/'),
serverAddress = serverAddress,
clientReadReceipts = data?.optBoolean("clientReadReceipts") == true,
isSupportAdmin = data?.optBoolean("isSupportAdmin") == true,
username = session.username,
serverUrl = session.serverUrl.trimEnd('/'),
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
}
}
suspend fun listTickets(config: SupportConfig): List<SupportTicket> {
val client = supportClient()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets")
.headers(identityHeaders(config))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить обращения (${response.code})")
}
val array = parseJsonArray(response.body!!.string(), "список обращений")
val out = mutableListOf<SupportTicket>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
parseTicket(obj)?.let { out += it }
}
return out
}
}
suspend fun loadMessages(config: SupportConfig, ticketNumber: String): List<SupportMessage> {
val client = supportClient()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/messages")
.headers(identityHeaders(config))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить сообщения (${response.code})")
}
val array = parseJsonArray(response.body!!.string(), "сообщения обращения")
val out = mutableListOf<SupportMessage>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optLong("id", obj.optLong("message_id", 0L))
val role = obj.optString("author_role")
val author = obj.optString("author")
val outgoing = messageIsOutgoing(config, role, author)
out += SupportMessage(
id = id,
text = obj.optString("text"),
createdAt = obj.optString("created_at"),
outgoing = outgoing,
authorLabel = supportSenderLabel(config, role, author, outgoing),
attachments = parseAttachments(obj.optJSONArray("attachments")),
read = obj.optBoolean("read_by_operator", false) ||
obj.optBoolean("is_read", false),
)
}
return out
}
}
suspend fun sendMessage(config: SupportConfig, ticketNumber: String, text: String): Long {
val client = supportClient()
val payload = JSONObject().put("text", text).toString()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/messages")
.headers(identityHeaders(config))
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось отправить сообщение (${response.code})")
}
val json = parseJsonObject(response.body!!.string(), "ответ на сообщение")
return json.optLong("id", json.optLong("message_id", 0L))
}
}
suspend fun uploadAttachment(
config: SupportConfig,
ticketNumber: String,
messageId: Long,
file: SupportPendingFile,
) {
val client = supportClient()
val mediaType = file.mimeType.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType()
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("message_id", messageId.toString())
.addFormDataPart(
"file",
file.fileName,
file.bytes.toRequestBody(mediaType),
)
.build()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/attachments")
.headers(identityHeaders(config))
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Вложение не принято (${response.code})")
}
}
}
suspend fun sendMessageWithAttachments(
config: SupportConfig,
ticketNumber: String,
text: String,
attachments: List<SupportPendingFile>,
) {
val trimmed = text.trim()
val messageText = when {
trimmed.isNotEmpty() -> trimmed
attachments.isNotEmpty() -> MESSAGE_BODY_PLACEHOLDER
else -> error("Введите сообщение или прикрепите файл")
}
val messageId = sendMessage(config, ticketNumber, messageText)
if (attachments.isEmpty()) return
if (messageId <= 0L) {
error("Сообщение создано, но сервер не вернул id — вложения не отправлены")
}
attachments.forEach { file ->
uploadAttachment(config, ticketNumber, messageId, file)
}
}
suspend fun downloadAttachment(
config: SupportConfig,
ticketNumber: String,
attachmentId: Long,
): Pair<ByteArray, String> {
val client = supportClient()
val request = Request.Builder()
.url(
"${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}" +
"/attachments/${encodePath(attachmentId.toString())}",
)
.headers(identityHeaders(config))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось скачать вложение (${response.code})")
}
val mime = response.header("Content-Type")?.substringBefore(';')?.trim()
?: "application/octet-stream"
return response.body!!.bytes() to mime
}
}
suspend fun createTicket(
config: SupportConfig,
subject: String,
body: String,
attachments: List<SupportPendingFile> = emptyList(),
): String {
val client = supportClient()
val payload = JSONObject()
.put("server_address", config.serverAddress)
.put("username", config.username)
.put("subject", subject)
.put("body", TICKET_CREATE_BODY_PLACEHOLDER)
.put("duplicate", 0)
.toString()
val createReq = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets")
.headers(identityHeaders(config))
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
val ticketNumber = client.newCall(createReq).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось создать обращение (${response.code})")
}
parseJsonObject(response.body!!.string(), "создание обращения").optString("ticket_number")
}
if (ticketNumber.isBlank()) {
error("Сервер не вернул номер обращения")
}
val messageId = sendMessage(config, ticketNumber, body.trim())
if (attachments.isNotEmpty()) {
if (messageId <= 0L) {
error("Обращение создано, но вложения не отправлены — откройте чат и прикрепите файлы вручную")
} else {
attachments.forEach { file ->
uploadAttachment(config, ticketNumber, messageId, file)
}
}
}
return ticketNumber
}
suspend fun markRead(config: SupportConfig, ticketNumber: String) {
if (!config.clientReadReceipts) return
val client = supportClient()
val request = Request.Builder()
.url("${config.supportApiBase}/api/client/tickets/${encodePath(ticketNumber)}/read")
.headers(identityHeaders(config))
.post("".toRequestBody(null))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 404) return
}
}
suspend fun submitComplaint(
config: SupportConfig,
ticketNumber: String,
ticketSubject: String,
text: String,
) {
val client = NetworkFactory.newAuthedClient(config.username, config.appPassword, config.trustAllCerts)
val payload = JSONObject()
.put("ticketNumber", ticketNumber)
.put("ticketSubject", ticketSubject)
.put("text", text)
.toString()
val request = Request.Builder()
.url("${config.serverUrl}/ocs/v2.php/apps/f7support/api/v1/complaint?format=json")
.applyOcsJson()
.post(payload.toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
val body = response.body?.string().orEmpty()
val err = runCatching {
parseJsonObject(body, "жалоба").ocsMeta()?.optString("message")
}.getOrNull().orEmpty()
error(err.ifBlank { "Не удалось отправить жалобу (${response.code})" })
}
}
}
private fun parseTicket(obj: JSONObject): SupportTicket? {
val number = obj.optString("ticket_number")
if (number.isBlank()) return null
val bitrixRaw = obj.opt("bitrix_deal_id")
val bitrixDealId = when (bitrixRaw) {
is Number -> bitrixRaw.toLong().takeIf { it > 0 }
else -> bitrixRaw?.toString()?.trim()?.toLongOrNull()?.takeIf { it > 0 }
}
return SupportTicket(
ticketNumber = number,
subject = obj.optString("subject").ifBlank { "" },
status = obj.optString("status"),
preview = obj.optString("preview_text"),
hasUnread = obj.optBoolean("has_unread", false),
activityAt = obj.optString("activity_at").ifBlank { obj.optString("created_at") },
createdAt = obj.optString("created_at"),
bitrixDealId = bitrixDealId,
assignedEmployee = obj.optString("assigned_employee"),
clientUsername = obj.optString("client_username"),
)
}
private fun parseAttachments(array: JSONArray?): List<SupportAttachment> {
if (array == null || array.length() == 0) return emptyList()
val out = mutableListOf<SupportAttachment>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optLong("id", obj.optLong("attachment_id", 0L))
if (id <= 0L) continue
out += SupportAttachment(
id = id,
filename = obj.optString("filename").ifBlank { "file" },
mimeType = obj.optString("mime_type"),
sizeBytes = obj.optLong("size_bytes", -1L).takeIf { it >= 0 },
)
}
return out
}
private fun messageIsOutgoing(config: SupportConfig, role: String, author: String): Boolean {
val r = role.lowercase()
val a = author.lowercase()
val u = config.username.lowercase()
if (config.isSupportAdmin && r == "support" && u.isNotEmpty() && a == u) return true
if (r == "client" || r == "user") return true
return u.isNotEmpty() && a == u
}
private fun supportSenderLabel(
config: SupportConfig,
role: String,
author: String,
outgoing: Boolean,
): String {
if (outgoing) return "Вы"
val name = author.trim()
if (name.isNotEmpty()) return name
return if (role.equals("support", true)) "Поддержка" else "Клиент"
}
private fun supportClient(): OkHttpClient = OkHttpClient.Builder().build()
private fun identityHeaders(config: SupportConfig): okhttp3.Headers {
val builder = okhttp3.Headers.Builder()
.add("X-F7cloud-User", config.username)
.add("X-F7cloud-Server", config.serverAddress)
if (config.isSupportAdmin) {
builder.add("X-F7cloud-Support-Admin", "1")
}
return builder.build()
}
private fun encodePath(segment: String): String {
return java.net.URLEncoder.encode(segment, Charsets.UTF_8.name())
}
private fun hostFromUrl(serverUrl: String): String {
return runCatching { URI(serverUrl.trimEnd('/')).host }.getOrNull().orEmpty()
}
companion object {
private const val MESSAGE_BODY_PLACEHOLDER = "."
private const val TICKET_CREATE_BODY_PLACEHOLDER = "."
}
}
data class SupportConfig(
val supportApiBase: String,
val serverAddress: String,
val clientReadReceipts: Boolean,
val isSupportAdmin: Boolean,
val username: String,
val serverUrl: String,
val appPassword: String,
val trustAllCerts: Boolean,
)
data class SupportTicket(
val ticketNumber: String,
val subject: String,
val status: String,
val preview: String,
val hasUnread: Boolean,
val activityAt: String,
val createdAt: String,
val bitrixDealId: Long? = null,
val assignedEmployee: String = "",
val clientUsername: String = "",
) {
fun displayNumber(): String = bitrixDealId?.toString() ?: ""
fun statusBucket(): SupportStatusBucket = when (status) {
"Закрыт" -> SupportStatusBucket.Closed
"В работе" -> SupportStatusBucket.Progress
"Новый" -> SupportStatusBucket.New
else -> SupportStatusBucket.New
}
}
enum class SupportStatusBucket(val title: String) {
New("Новые"),
Progress("В работе"),
Closed("Закрыт"),
}
data class SupportMessage(
val id: Long,
val text: String,
val createdAt: String,
val outgoing: Boolean,
val authorLabel: String,
val attachments: List<SupportAttachment> = emptyList(),
val read: Boolean = false,
)
data class SupportAttachment(
val id: Long,
val filename: String,
val mimeType: String,
val sizeBytes: Long? = null,
)
@@ -0,0 +1,163 @@
package ru.forbion.f7cloud.feature.f7support
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
@Composable
fun SupportScreen(
session: AuthSession,
modifier: Modifier = Modifier,
createRequest: Int = 0,
openTicketNumber: String? = null,
onOpenTicketConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: SupportViewModel = viewModel()
val state by vm.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val pickCreateFilesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.addCreateFiles(context, uris)
}
}
val pickChatFilesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.addChatFiles(context, uris)
}
}
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(createRequest) {
if (createRequest > 0) {
vm.setShowCreate(true)
}
}
LaunchedEffect(openTicketNumber) {
if (!openTicketNumber.isNullOrBlank()) {
vm.openTicketFromPush(session, openTicketNumber)
onOpenTicketConsumed()
}
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.snackbar) {
val msg = state.snackbar ?: return@LaunchedEffect
snackbarHostState.showSnackbar(msg)
vm.clearSnackbar()
}
LaunchedEffect(state.error) {
val err = state.error ?: return@LaunchedEffect
if (state.selectedTicket != null || state.showCreate || state.showComplaint) {
snackbarHostState.showSnackbar(err)
vm.clearError()
}
}
F7OverlayDismissHandler(
enabled = state.showComplaint,
onDismiss = { vm.setShowComplaint(false) },
)
F7OverlayDismissHandler(
enabled = state.selectedTicket != null,
onDismiss = { vm.closeTicket(session) },
)
F7OverlayDismissHandler(
enabled = state.showCreate,
onDismiss = { vm.setShowCreate(false) },
)
if (state.showCreate) {
SupportCreateDialog(
loading = state.loading,
pendingFiles = state.createPendingFiles,
onDismiss = { vm.setShowCreate(false) },
onPickFiles = { pickCreateFilesLauncher.launch(arrayOf("*/*")) },
onRemoveFile = vm::removeCreateFile,
onSubmit = { subject, body -> vm.createTicket(session, subject, body) },
)
}
state.selectedTicket?.let { ticket ->
SupportChatDialog(
session = session,
ticket = ticket,
messages = state.messages,
loading = state.loading && state.messages.isEmpty(),
sending = state.sending,
pendingFiles = state.chatPendingFiles,
downloadingAttachmentId = state.downloadingAttachmentId,
onDismiss = { vm.closeTicket(session) },
onSend = { text -> vm.sendMessage(session, text) },
onPickFiles = { pickChatFilesLauncher.launch(arrayOf("*/*")) },
onRemoveFile = vm::removeChatFile,
onAttachmentClick = { vm.downloadAttachment(context, it) },
onComplaintClick = { vm.setShowComplaint(true) },
)
}
if (state.showComplaint && state.selectedTicket != null) {
SupportComplaintDialog(
sending = state.complaintSending,
onDismiss = { vm.setShowComplaint(false) },
onSubmit = { vm.submitComplaint(it) },
)
}
Box(modifier = modifier.fillMaxSize()) {
F7ModuleScreen(
modifier = Modifier.fillMaxSize(),
loading = state.loading && state.tickets.isEmpty() && state.selectedTicket == null,
error = if (state.selectedTicket == null) state.error else null,
onRefresh = { vm.load(session) },
) {
if (state.selectedTicket == null) {
SupportHomeContent(
serverUrl = session.serverUrl,
isSupportAdmin = state.config?.isSupportAdmin == true,
ticketsByBucket = state.ticketsByBucket,
hasTickets = state.hasTickets,
onCreateClick = { vm.setShowCreate(true) },
onTicketClick = { vm.openTicket(session, it) },
modifier = Modifier.fillMaxSize(),
)
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
if (state.loading && state.tickets.isNotEmpty() && state.selectedTicket == null && !state.showCreate) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter),
color = F7Colors.Primary,
)
}
}
}
@@ -0,0 +1,384 @@
package ru.forbion.f7cloud.feature.f7support
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.AppForegroundTracker
import ru.forbion.f7cloud.core.network.UnauthorizedException
data class SupportUiState(
val loading: Boolean = false,
val config: SupportConfig? = null,
val tickets: List<SupportTicket> = emptyList(),
val selectedTicket: SupportTicket? = null,
val messages: List<SupportMessage> = emptyList(),
val sending: Boolean = false,
val showCreate: Boolean = false,
val showComplaint: Boolean = false,
val complaintSending: Boolean = false,
val createPendingFiles: List<SupportPendingFile> = emptyList(),
val chatPendingFiles: List<SupportPendingFile> = emptyList(),
val downloadingAttachmentId: Long? = null,
val snackbar: String? = null,
val error: String? = null,
val unauthorized: Boolean = false,
) {
val ticketsByBucket: Map<SupportStatusBucket, List<SupportTicket>>
get() = SupportStatusBucket.entries.associateWith { bucket ->
tickets
.filter { it.statusBucket() == bucket }
.sortedByDescending { it.activityAt }
}
val hasTickets: Boolean get() = tickets.isNotEmpty()
}
class SupportViewModel(
private val repository: SupportRepository = SupportRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(SupportUiState())
val state: StateFlow<SupportUiState> = _state.asStateFlow()
private var cachedConfig: SupportConfig? = null
private var pollJob: Job? = null
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
val config = repository.loadConfig(session)
cachedConfig = config
val tickets = repository.listTickets(config)
config to tickets
}.onSuccess { (config, tickets) ->
_state.value = _state.value.copy(
loading = false,
config = config,
tickets = tickets,
error = null,
)
restartPolling(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun refreshTickets(session: AuthSession) {
val config = cachedConfig ?: return
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.listTickets(config) }
.onSuccess { tickets ->
val selected = _state.value.selectedTicket
val updatedSelected = selected?.let { sel ->
tickets.find { it.ticketNumber == sel.ticketNumber } ?: sel
}
_state.value = _state.value.copy(
tickets = tickets,
selectedTicket = updatedSelected,
)
}
}
}
fun openTicket(session: AuthSession, ticketNumber: String) {
val config = cachedConfig ?: return
val ticket = _state.value.tickets.find { it.ticketNumber == ticketNumber }
?: SupportTicket(
ticketNumber = ticketNumber,
subject = "",
status = "",
preview = "",
hasUnread = false,
activityAt = "",
createdAt = "",
)
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(
loading = true,
selectedTicket = ticket,
messages = emptyList(),
error = null,
)
runCatching {
repository.loadMessages(config, ticketNumber)
}.onSuccess { messages ->
repository.markRead(config, ticketNumber)
_state.value = _state.value.copy(
loading = false,
messages = messages,
tickets = _state.value.tickets.map {
if (it.ticketNumber == ticketNumber) it.copy(hasUnread = false) else it
},
)
restartPolling(session)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun closeTicket(session: AuthSession) {
_state.value = _state.value.copy(
selectedTicket = null,
messages = emptyList(),
showComplaint = false,
chatPendingFiles = emptyList(),
)
refreshTickets(session)
restartPolling(session)
}
fun sendMessage(session: AuthSession, text: String) {
val config = cachedConfig ?: return
val ticket = _state.value.selectedTicket ?: return
val trimmed = text.trim()
val files = _state.value.chatPendingFiles
if (trimmed.isEmpty() && files.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(sending = true, error = null)
runCatching {
repository.sendMessageWithAttachments(config, ticket.ticketNumber, trimmed, files)
}.onSuccess {
val messages = repository.loadMessages(config, ticket.ticketNumber)
_state.value = _state.value.copy(
sending = false,
messages = messages,
chatPendingFiles = emptyList(),
)
refreshTickets(session)
}.onFailure { t ->
_state.value = _state.value.copy(
sending = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun setShowCreate(show: Boolean) {
_state.value = _state.value.copy(
showCreate = show,
error = null,
createPendingFiles = if (show) _state.value.createPendingFiles else emptyList(),
)
}
fun setShowComplaint(show: Boolean) {
_state.value = _state.value.copy(showComplaint = show, error = null)
}
fun createTicket(session: AuthSession, subject: String, body: String) {
val config = cachedConfig ?: return
val files = _state.value.createPendingFiles
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
repository.createTicket(config, subject.trim(), body.trim(), files)
}.onSuccess { ticketNumber ->
val tickets = repository.listTickets(config)
_state.value = _state.value.copy(
loading = false,
showCreate = false,
createPendingFiles = emptyList(),
tickets = tickets,
)
openTicket(session, ticketNumber)
}.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun addCreateFiles(context: Context, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { SupportFileIO.readUris(context, uris) }
.onSuccess { files ->
if (files.isEmpty()) return@onSuccess
_state.value = _state.value.copy(
createPendingFiles = _state.value.createPendingFiles + files,
)
}
.onFailure { t ->
_state.value = _state.value.copy(error = t.message)
}
}
}
fun removeCreateFile(localId: String) {
_state.value = _state.value.copy(
createPendingFiles = _state.value.createPendingFiles.filterNot { it.localId == localId },
)
}
fun addChatFiles(context: Context, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
runCatching { SupportFileIO.readUris(context, uris) }
.onSuccess { files ->
if (files.isEmpty()) return@onSuccess
_state.value = _state.value.copy(
chatPendingFiles = _state.value.chatPendingFiles + files,
)
}
.onFailure { t ->
_state.value = _state.value.copy(error = t.message)
}
}
}
fun removeChatFile(localId: String) {
_state.value = _state.value.copy(
chatPendingFiles = _state.value.chatPendingFiles.filterNot { it.localId == localId },
)
}
fun downloadAttachment(
context: Context,
attachment: SupportAttachment,
) {
val config = cachedConfig ?: return
val ticket = _state.value.selectedTicket ?: return
if (_state.value.downloadingAttachmentId == attachment.id) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(downloadingAttachmentId = attachment.id)
runCatching {
repository.downloadAttachment(config, ticket.ticketNumber, attachment.id)
}.onSuccess { (bytes, mime) ->
SupportFileIO.openBytes(
context = context,
fileName = attachment.filename,
bytes = bytes,
mimeType = mime.ifBlank { attachment.mimeType },
)
_state.value = _state.value.copy(downloadingAttachmentId = null)
}.onFailure { t ->
_state.value = _state.value.copy(
downloadingAttachmentId = null,
error = t.message,
)
}
}
}
fun submitComplaint(text: String) {
val config = cachedConfig ?: return
val ticket = _state.value.selectedTicket ?: return
val trimmed = text.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(complaintSending = true, error = null)
runCatching {
repository.submitComplaint(
config = config,
ticketNumber = ticket.ticketNumber,
ticketSubject = ticket.subject,
text = trimmed,
)
}.onSuccess {
_state.value = _state.value.copy(
complaintSending = false,
showComplaint = false,
snackbar = "Жалоба отправлена",
)
}.onFailure { t ->
_state.value = _state.value.copy(
complaintSending = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun clearSnackbar() {
_state.value = _state.value.copy(snackbar = null)
}
fun clearError() {
_state.value = _state.value.copy(error = null)
}
fun openTicketFromPush(session: AuthSession, ticketNumber: String) {
if (cachedConfig == null) {
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.loadConfig(session) }
.onSuccess { config ->
cachedConfig = config
_state.value = _state.value.copy(config = config)
openTicket(session, ticketNumber)
}
}
} else {
openTicket(session, ticketNumber)
}
}
private fun restartPolling(session: AuthSession) {
pollJob?.cancel()
pollJob = viewModelScope.launch(Dispatchers.IO) {
while (isActive) {
delay(pollIntervalMs())
if (!AppForegroundTracker.isForeground) continue
val config = cachedConfig ?: continue
val selected = _state.value.selectedTicket
runCatching {
if (selected != null) {
val messages = repository.loadMessages(config, selected.ticketNumber)
val tickets = repository.listTickets(config)
Pair(messages, tickets)
} else {
Pair(null, repository.listTickets(config))
}
}.onSuccess { (messages, tickets) ->
if (selected != null && messages != null) {
val updatedSelected = tickets.find { it.ticketNumber == selected.ticketNumber }
_state.value = _state.value.copy(
messages = messages,
tickets = tickets,
selectedTicket = updatedSelected ?: selected,
)
} else {
_state.value = _state.value.copy(tickets = tickets)
}
}
}
}
}
private fun pollIntervalMs(): Long =
if (_state.value.selectedTicket != null) POLL_TICKET_MS else POLL_LIST_MS
override fun onCleared() {
pollJob?.cancel()
super.onCleared()
}
companion object {
private const val POLL_LIST_MS = 15_000L
private const val POLL_TICKET_MS = 8_000L
}
}