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,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user