Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.feature.mail'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation project(':core:auth')
implementation project(':core:network')
implementation project(':core:designsystem')
implementation project(':feature:files')
implementation project(':feature:contacts')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'org.json:json:20240303'
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.foundation:foundation-layout'
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".MailComposeActivity"
android:exported="false"
android:hardwareAccelerated="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".MailFilesPickerActivity"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,28 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
internal object MailAttachmentIO {
fun readUri(
context: Context,
uri: Uri,
fallbackName: String = "attachment.bin",
): Triple<String, ByteArray, String> {
val resolver = context.contentResolver
var name = fallbackName
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0) {
name = cursor.getString(idx)?.takeIf { it.isNotBlank() } ?: name
}
}
}
val mime = resolver.getType(uri) ?: "application/octet-stream"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Не удалось прочитать файл")
return Triple(name, bytes, mime)
}
}
@@ -0,0 +1,107 @@
package ru.forbion.f7cloud.feature.mail
data class MailBackStackState(
val searchFilterOpen: Boolean,
val snoozeSheetOpen: Boolean,
val tagsSheetOpen: Boolean,
val moveSheetOpen: Boolean,
val settingsOpen: Boolean,
val editingAccountId: Int?,
val sidebarOpen: Boolean,
val messageOpen: Boolean,
)
fun MailBackStackState.canGoBack(): Boolean =
searchFilterOpen ||
snoozeSheetOpen ||
tagsSheetOpen ||
moveSheetOpen ||
settingsOpen ||
sidebarOpen ||
messageOpen
fun navigateMailBack(
state: MailBackStackState,
closeSearchFilter: () -> Unit,
closeSnoozeSheet: () -> Unit,
closeTagsSheet: () -> Unit,
closeMoveSheet: () -> Unit,
closeAccountSettings: () -> Unit,
closeSettings: () -> Unit,
closeSidebar: () -> Unit,
closeMessage: () -> Unit,
): Boolean = when {
state.searchFilterOpen -> {
closeSearchFilter()
true
}
state.snoozeSheetOpen -> {
closeSnoozeSheet()
true
}
state.tagsSheetOpen -> {
closeTagsSheet()
true
}
state.moveSheetOpen -> {
closeMoveSheet()
true
}
state.settingsOpen && state.editingAccountId != null -> {
closeAccountSettings()
true
}
state.settingsOpen -> {
closeSettings()
true
}
state.sidebarOpen -> {
closeSidebar()
true
}
state.messageOpen -> {
closeMessage()
true
}
else -> false
}
data class MailComposeBackStackState(
val attachmentMenuOpen: Boolean,
val sendLaterMenuOpen: Boolean,
val moreMenuOpen: Boolean,
val toolbarVisible: Boolean,
)
fun MailComposeBackStackState.canGoBack(): Boolean =
attachmentMenuOpen || sendLaterMenuOpen || moreMenuOpen || toolbarVisible
fun navigateMailComposeBack(
state: MailComposeBackStackState,
closeAttachmentMenu: () -> Unit,
closeSendLaterMenu: () -> Unit,
closeMoreMenu: () -> Unit,
closeToolbar: () -> Unit,
closeScreen: () -> Unit,
): Boolean = when {
state.attachmentMenuOpen -> {
closeAttachmentMenu()
true
}
state.sendLaterMenuOpen -> {
closeSendLaterMenu()
true
}
state.moreMenuOpen -> {
closeMoreMenu()
true
}
state.toolbarVisible -> {
closeToolbar()
true
}
else -> {
closeScreen()
true
}
}
@@ -0,0 +1,178 @@
package ru.forbion.f7cloud.feature.mail
import android.net.Uri
/** Shared HTML normalization and reader styles for mail message bodies. */
object MailBodyHtml {
val READER_CSS = """
html, body {
margin: 0;
padding: 0;
overflow-x: hidden;
word-wrap: break-word;
overflow-wrap: anywhere;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.45;
color: #1a1a1a;
padding: 8px;
}
body, p, div, span, td, th, li, blockquote {
white-space: normal !important;
}
img, table, video {
max-width: 100% !important;
height: auto !important;
}
a:has(img) {
pointer-events: none !important;
cursor: default !important;
}
table {
table-layout: fixed;
width: 100%;
}
p {
margin: 0 0 0.35em !important;
}
p:last-child {
margin-bottom: 0 !important;
}
p:empty,
p:has(br:only-child) {
margin: 0 !important;
line-height: 0 !important;
font-size: 0 !important;
}
pre, code {
white-space: pre-wrap !important;
word-break: break-word;
}
blockquote,
.quote,
details.quoted-text,
.gmail_quote,
.gmail_extra,
.moz-cite-prefix,
#divRplyFwdMsg {
margin: 2px 0 !important;
margin-left: 0 !important;
padding: 0 0 0 6px !important;
border: none !important;
border-left: 1px solid #70B62B !important;
max-width: none !important;
}
blockquote blockquote,
blockquote .quote,
.quote blockquote,
.quote .quote,
details.quoted-text details.quoted-text {
margin-left: 0 !important;
padding-left: 6px !important;
}
blockquote [style*="margin-left"],
blockquote [style*="padding-left"],
.quote [style*="margin-left"],
.quote [style*="padding-left"],
.gmail_quote [style*="margin-left"],
.gmail_quote [style*="padding-left"] {
margin-left: 0 !important;
padding-left: 0 !important;
}
""".trimIndent()
private val EMPTY_PARAGRAPH = Regex(
"""<p[^>]*>(?:\s|&nbsp;|&#160;|&#xA0;|<br\s*/?>)*</p>""",
RegexOption.IGNORE_CASE,
)
private val NBSP_RUN = Regex("""(?:&nbsp;|&#160;|&#xA0;|\u00A0){2,}""", RegexOption.IGNORE_CASE)
private val SPACE_RUN = Regex(""" {2,}""")
private val MAIL_PROXY_URL = Regex(
"""https?://[^"'\s<>)]+/apps/mail/proxy\?[^"'\s<>)]+""",
RegexOption.IGNORE_CASE,
)
private val CID_REFERENCE = Regex("""cid:([^"'\s>)]+)""", RegexOption.IGNORE_CASE)
fun normalizeForDisplay(html: String): String {
var normalized = MailWebBranding.replaceInText(html)
normalized = normalized.replace(EMPTY_PARAGRAPH, "")
normalized = normalized.replace(NBSP_RUN, "&nbsp;")
// Only collapse spaces between tags to avoid breaking preformatted text nodes.
normalized = normalized.replace(Regex(">(\\s{2,})<")) { match ->
">" + match.groupValues[1].replace(SPACE_RUN, " ") + "<"
}
return normalized
}
/**
* Rewrites image sources for mobile WebView:
* - unwraps Nextcloud mail proxy URLs (proxy requires session cookies, not Basic auth)
* - resolves cid: inline images to authenticated API attachment URLs
*/
fun rewriteImageSources(
html: String,
messageId: Int,
apiBase: String,
attachments: List<MailAttachment>,
): String {
if (html.isBlank()) return html
var result = unwrapMailProxyUrls(html)
if (messageId > 0 && result.contains("cid:", ignoreCase = true)) {
result = rewriteCidReferences(result, messageId, apiBase, attachments)
}
return result
}
fun unwrapMailProxyUrls(html: String): String =
MAIL_PROXY_URL.replace(html) { match -> unwrapProxyUrl(match.value) }
fun unwrapProxyUrl(url: String): String {
val normalized = url.replace("&amp;", "&")
return runCatching {
Uri.parse(normalized).getQueryParameter("src")?.takeIf { it.isNotBlank() }
}.getOrNull() ?: url
}
private fun rewriteCidReferences(
html: String,
messageId: Int,
apiBase: String,
attachments: List<MailAttachment>,
): String {
val byCid = attachments.mapNotNull { attachment ->
attachment.cid
?.trim('<', '>')
?.takeIf { it.isNotBlank() }
?.let { it to attachment }
}.toMap()
val base = apiBase.trimEnd('/')
return CID_REFERENCE.replace(html) { match ->
val cid = match.groupValues[1].trim('<', '>')
val attachment = byCid[cid]
if (attachment != null) {
"$base/messages/$messageId/attachment/${attachment.id}"
} else {
match.value
}
}
}
fun normalizePlainForDisplay(text: String): String =
text.lines()
.joinToString("\n") { line ->
line.replace(SPACE_RUN, " ").trimEnd()
}
.trimEnd()
fun formatPreviewText(raw: String): String {
if (raw.isBlank()) return ""
val withoutTags = raw.replace(Regex("<[^>]+>"), " ")
val decoded = runCatching {
android.text.Html.fromHtml(withoutTags, android.text.Html.FROM_HTML_MODE_LEGACY).toString()
}.getOrDefault(withoutTags)
return normalizePlainForDisplay(decoded).replace('\n', ' ')
}
}
@@ -0,0 +1,381 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import java.io.File
import java.security.MessageDigest
class MailCacheRepository(context: Context) {
private val rootDir = File(context.filesDir, "mail-cache")
suspend fun loadBootstrap(session: AuthSession): MailBootstrap? = withContext(Dispatchers.IO) {
readJson(session, "bootstrap.json")?.let(::parseBootstrap)
}
suspend fun saveBootstrap(session: AuthSession, bootstrap: MailBootstrap) = withContext(Dispatchers.IO) {
writeJson(session, "bootstrap.json", bootstrapToJson(bootstrap))
}
suspend fun loadFolderPage(session: AuthSession, folder: MailFolderEntry): MailMessagesPage? =
withContext(Dispatchers.IO) {
readJson(session, "folders/${folder.cacheKey()}.json")?.let(::parseFolderPage)
}
suspend fun saveFolderPage(session: AuthSession, folder: MailFolderEntry, page: MailMessagesPage) =
withContext(Dispatchers.IO) {
writeJson(session, "folders/${folder.cacheKey()}.json", folderPageToJson(page))
}
suspend fun folderSyncedAt(session: AuthSession, folder: MailFolderEntry): Long? =
withContext(Dispatchers.IO) {
readJson(session, "folders/${folder.cacheKey()}.json")?.optLong("syncedAt")?.takeIf { it > 0L }
}
suspend fun loadMessageDetail(session: AuthSession, messageId: Int): MailMessageDetail? =
withContext(Dispatchers.IO) {
readJson(session, "messages/$messageId.json")?.let(::parseMessageDetail)
}
suspend fun isMessageDetailCached(session: AuthSession, messageId: Int): Boolean =
withContext(Dispatchers.IO) {
val file = File(accountDir(session), "messages/$messageId.json")
if (!file.exists()) return@withContext false
runCatching {
parseMessageDetail(JSONObject(file.readText())).hasBodyContent()
}.getOrDefault(false)
}
suspend fun saveMessageDetail(session: AuthSession, detail: MailMessageDetail) =
withContext(Dispatchers.IO) {
writeJson(session, "messages/${detail.id}.json", messageDetailToJson(detail))
}
suspend fun removeMessage(session: AuthSession, messageId: Int) = withContext(Dispatchers.IO) {
File(accountDir(session), "messages/$messageId.json").delete()
}
suspend fun updateMessageInFolders(session: AuthSession, messageId: Int, updater: (MailMessage) -> MailMessage) =
withContext(Dispatchers.IO) {
val foldersDir = File(accountDir(session), "folders")
if (!foldersDir.exists()) return@withContext
foldersDir.listFiles()?.forEach { file ->
if (!file.name.endsWith(".json")) return@forEach
runCatching {
val json = JSONObject(file.readText())
val messages = json.optJSONArray("messages") ?: return@forEach
var changed = false
for (i in 0 until messages.length()) {
val obj = messages.optJSONObject(i) ?: continue
if (obj.optInt("id") != messageId) continue
val updated = updater(parseMessage(obj))
messages.put(i, messageToJson(updated))
changed = true
}
if (changed) file.writeText(json.toString())
}
}
}
suspend fun removeMessageFromFolders(session: AuthSession, messageId: Int) = withContext(Dispatchers.IO) {
val foldersDir = File(accountDir(session), "folders")
if (!foldersDir.exists()) return@withContext
foldersDir.listFiles()?.forEach { file ->
if (!file.name.endsWith(".json")) return@forEach
runCatching {
val json = JSONObject(file.readText())
val messages = json.optJSONArray("messages") ?: return@forEach
val filtered = JSONArray()
for (i in 0 until messages.length()) {
val obj = messages.optJSONObject(i) ?: continue
if (obj.optInt("id") != messageId) filtered.put(obj)
}
if (filtered.length() != messages.length()) {
json.put("messages", filtered)
file.writeText(json.toString())
}
}
}
}
private fun accountDir(session: AuthSession): File {
val key = accountKey(session)
return File(rootDir, key).also { it.mkdirs() }
}
private fun accountKey(session: AuthSession): String {
val raw = "${session.serverUrl.trimEnd('/')}|${session.username.trim().lowercase()}"
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
private fun readJson(session: AuthSession, relativePath: String): JSONObject? {
val file = File(accountDir(session), relativePath)
if (!file.exists()) return null
return runCatching { JSONObject(file.readText()) }.getOrNull()
}
private fun writeJson(session: AuthSession, relativePath: String, json: JSONObject) {
val file = File(accountDir(session), relativePath)
file.parentFile?.mkdirs()
file.writeText(json.toString())
}
private fun bootstrapToJson(bootstrap: MailBootstrap): JSONObject =
JSONObject()
.put("accounts", accountsToJson(bootstrap.accounts))
.put("folders", foldersToJson(bootstrap.folders))
.put(
"selectedFolder",
bootstrap.selectedFolder?.let { folderToJson(it) },
)
.put("syncedAt", System.currentTimeMillis())
private fun parseBootstrap(json: JSONObject): MailBootstrap {
val accounts = parseAccounts(json.optJSONArray("accounts"))
val folders = parseFolders(json.optJSONArray("folders"))
val selected = json.optJSONObject("selectedFolder")?.let(::parseFolder)
return MailBootstrap(accounts, emptyList(), folders, selected)
}
private fun folderPageToJson(page: MailMessagesPage): JSONObject =
JSONObject()
.put("messages", messagesToJson(page.messages))
.put("nextCursor", page.nextCursor ?: JSONObject.NULL)
.put("syncedAt", System.currentTimeMillis())
private fun parseFolderPage(json: JSONObject): MailMessagesPage =
MailMessagesPage(
messages = parseMessages(json.optJSONArray("messages")),
nextCursor = json.opt("nextCursor").takeUnless { it == JSONObject.NULL } as? Int,
)
private fun messageDetailToJson(detail: MailMessageDetail): JSONObject =
JSONObject()
.put("id", detail.id)
.put("subject", detail.subject)
.put("from", detail.from)
.put("fromEmail", detail.fromEmail)
.put("to", detail.to)
.put("cc", detail.cc)
.put("dateInt", detail.dateInt)
.put("bodyHtml", detail.bodyHtml)
.put("bodyPlain", detail.bodyPlain)
.put("hasHtmlBody", detail.hasHtmlBody)
.put("flags", flagsToJson(detail.flags))
.put("attachments", attachmentsToJson(detail.attachments))
.put("syncedAt", System.currentTimeMillis())
private fun parseMessageDetail(json: JSONObject): MailMessageDetail =
MailMessageDetail(
id = json.optInt("id"),
subject = json.optString("subject"),
from = json.optString("from"),
fromEmail = json.optString("fromEmail"),
to = json.optString("to"),
cc = json.optString("cc"),
dateInt = json.optLong("dateInt"),
bodyHtml = json.optString("bodyHtml"),
bodyPlain = json.optString("bodyPlain"),
hasHtmlBody = json.optBoolean("hasHtmlBody"),
flags = parseFlags(json.optJSONObject("flags")),
attachments = parseAttachments(json.optJSONArray("attachments")),
)
private fun accountsToJson(accounts: List<MailAccount>): JSONArray =
JSONArray().apply {
accounts.forEach { account ->
put(
JSONObject()
.put("id", account.id)
.put("email", account.email)
.put("name", account.name)
.put("draftsMailboxId", account.draftsMailboxId ?: JSONObject.NULL)
.put("sentMailboxId", account.sentMailboxId ?: JSONObject.NULL)
.put("trashMailboxId", account.trashMailboxId ?: JSONObject.NULL)
.put("archiveMailboxId", account.archiveMailboxId ?: JSONObject.NULL)
.put("junkMailboxId", account.junkMailboxId ?: JSONObject.NULL),
)
}
}
private fun parseAccounts(array: JSONArray?): List<MailAccount> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
add(
MailAccount(
id = obj.optInt("id"),
email = obj.optString("email"),
name = obj.optString("name"),
draftsMailboxId = obj.opt("draftsMailboxId").nullInt(),
sentMailboxId = obj.opt("sentMailboxId").nullInt(),
trashMailboxId = obj.opt("trashMailboxId").nullInt(),
archiveMailboxId = obj.opt("archiveMailboxId").nullInt(),
junkMailboxId = obj.opt("junkMailboxId").nullInt(),
),
)
}
}
}
private fun foldersToJson(folders: List<MailFolderEntry>): JSONArray =
JSONArray().apply { folders.forEach { put(folderToJson(it)) } }
private fun folderToJson(folder: MailFolderEntry): JSONObject =
JSONObject()
.put("mailboxId", folder.mailboxId)
.put("accountId", folder.accountId)
.put("title", folder.title)
.put("specialRole", folder.specialRole ?: JSONObject.NULL)
.put("filter", folder.filter.name)
.put("unread", folder.unread)
private fun parseFolders(array: JSONArray?): List<MailFolderEntry> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
array.optJSONObject(i)?.let { add(parseFolder(it)) }
}
}
}
private fun parseFolder(obj: JSONObject): MailFolderEntry =
MailFolderEntry(
mailboxId = obj.optInt("mailboxId"),
accountId = obj.optInt("accountId"),
title = obj.optString("title"),
specialRole = obj.opt("specialRole").nullString(),
filter = runCatching { MailListFilter.valueOf(obj.optString("filter")) }
.getOrDefault(MailListFilter.ALL),
unread = obj.optInt("unread"),
)
private fun messagesToJson(messages: List<MailMessage>): JSONArray =
JSONArray().apply { messages.forEach { put(messageToJson(it)) } }
private fun messageToJson(message: MailMessage): JSONObject =
JSONObject()
.put("id", message.id)
.put("subject", message.subject)
.put("from", message.from)
.put("fromEmail", message.fromEmail)
.put("preview", message.preview)
.put("dateInt", message.dateInt)
.put("flags", flagsToJson(message.flags))
.put("tags", tagsToJson(message.tags))
private fun tagsToJson(tags: List<MailTag>): JSONArray =
JSONArray().apply {
tags.forEach { tag ->
put(
JSONObject()
.put("id", tag.id)
.put("displayName", tag.displayName)
.put("colorHex", tag.colorHex)
.put("imapLabel", tag.imapLabel),
)
}
}
private fun parseTags(array: JSONArray?): List<MailTag> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val name = obj.optString("displayName").trim()
if (name.isEmpty()) continue
add(
MailTag(
id = obj.optLong("id"),
displayName = name,
colorHex = obj.optString("colorHex"),
imapLabel = obj.optString("imapLabel"),
),
)
}
}
}
private fun parseMessages(array: JSONArray?): List<MailMessage> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
array.optJSONObject(i)?.let { add(parseMessage(it)) }
}
}
}
private fun parseMessage(obj: JSONObject): MailMessage =
MailMessage(
id = obj.optInt("id"),
subject = obj.optString("subject"),
from = obj.optString("from"),
fromEmail = obj.optString("fromEmail"),
preview = obj.optString("preview"),
dateInt = obj.optLong("dateInt"),
flags = parseFlags(obj.optJSONObject("flags")),
tags = parseTags(obj.optJSONArray("tags")),
)
private fun flagsToJson(flags: MailMessageFlags): JSONObject =
JSONObject()
.put("seen", flags.seen)
.put("flagged", flags.flagged)
.put("hasAttachments", flags.hasAttachments)
.put("answered", flags.answered)
.put("important", flags.important)
private fun parseFlags(obj: JSONObject?): MailMessageFlags =
MailMessageFlags(
seen = obj?.optBoolean("seen", true) ?: true,
flagged = obj?.optBoolean("flagged", false) ?: false,
hasAttachments = obj?.optBoolean("hasAttachments", false) ?: false,
answered = obj?.optBoolean("answered", false) ?: false,
important = obj?.optBoolean("important", false) ?: false,
)
private fun attachmentsToJson(attachments: List<MailAttachment>): JSONArray =
JSONArray().apply {
attachments.forEach { attachment ->
put(
JSONObject()
.put("id", attachment.id)
.put("fileName", attachment.fileName)
.put("mime", attachment.mime)
.put("size", attachment.size)
.put("cid", attachment.cid)
.put("downloadUrl", attachment.downloadUrl),
)
}
}
private fun parseAttachments(array: JSONArray?): List<MailAttachment> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
add(
MailAttachment(
id = obj.optString("id"),
fileName = obj.optString("fileName"),
mime = obj.optString("mime"),
size = obj.optLong("size"),
cid = obj.optString("cid").ifBlank { null },
downloadUrl = obj.optString("downloadUrl").ifBlank { null },
),
)
}
}
}
private fun Any?.nullInt(): Int? =
if (this == null || this == JSONObject.NULL) null else (this as? Number)?.toInt()
private fun Any?.nullString(): String? =
if (this == null || this == JSONObject.NULL) null else this.toString()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.feature.contacts.ContactsRepository
class MailComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val launch = readLaunch() ?: run {
finish()
return
}
val session = AuthSession(
serverUrl = launch.serverUrl,
username = launch.username,
appPassword = launch.password,
trustAllCerts = launch.trustAllCerts,
)
val vm = MailComposeViewModel(
launch = launch,
contactsRepository = ContactsRepository(applicationContext),
)
setContent {
F7Theme {
MailComposeScreen(
session = session,
launch = launch,
vm = vm,
onClose = { finish() },
onUnauthorized = { finish() },
modifier = Modifier.fillMaxSize(),
)
}
}
}
private fun readLaunch(): MailComposeLaunch? {
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty()
val accountId = intent.getIntExtra(EXTRA_ACCOUNT_ID, 0)
val accountEmail = intent.getStringExtra(EXTRA_ACCOUNT_EMAIL).orEmpty()
if (username.isBlank() || serverUrl.isBlank() || accountId <= 0 || accountEmail.isBlank()) {
return null
}
return MailComposeLaunch(
username = username,
password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
serverUrl = serverUrl,
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
accountId = accountId,
accountEmail = accountEmail,
accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME),
mailboxId = intent.getIntExtra(EXTRA_MAILBOX_ID, 0).takeIf { it > 0 },
mode = runCatching {
MailComposeMode.valueOf(intent.getStringExtra(EXTRA_MODE) ?: MailComposeMode.NEW.name)
}.getOrDefault(MailComposeMode.NEW),
initialTo = intent.getStringExtra(EXTRA_INITIAL_TO).orEmpty(),
initialCc = intent.getStringExtra(EXTRA_INITIAL_CC).orEmpty(),
initialSubject = intent.getStringExtra(EXTRA_INITIAL_SUBJECT).orEmpty(),
initialBodyHtml = intent.getStringExtra(EXTRA_INITIAL_BODY_HTML).orEmpty(),
showCcBcc = intent.getBooleanExtra(EXTRA_SHOW_CC_BCC, false),
initialSendPreset = runCatching {
MailSendLaterPreset.valueOf(
intent.getStringExtra(EXTRA_INITIAL_SEND_PRESET) ?: MailSendLaterPreset.NOW.name,
)
}.getOrDefault(MailSendLaterPreset.NOW),
initialCustomSendAtEpochSeconds = intent.getLongExtra(EXTRA_INITIAL_CUSTOM_SEND_AT, 0L)
.takeIf { it > 0L },
)
}
companion object {
private const val EXTRA_USERNAME = "username"
private const val EXTRA_PASSWORD = "password"
private const val EXTRA_SERVER_URL = "server_url"
private const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
private const val EXTRA_ACCOUNT_ID = "account_id"
private const val EXTRA_ACCOUNT_EMAIL = "account_email"
private const val EXTRA_ACCOUNT_NAME = "account_name"
private const val EXTRA_MAILBOX_ID = "mailbox_id"
private const val EXTRA_MODE = "compose_mode"
private const val EXTRA_INITIAL_TO = "initial_to"
private const val EXTRA_INITIAL_CC = "initial_cc"
private const val EXTRA_INITIAL_SUBJECT = "initial_subject"
private const val EXTRA_INITIAL_BODY_HTML = "initial_body_html"
private const val EXTRA_SHOW_CC_BCC = "show_cc_bcc"
private const val EXTRA_INITIAL_SEND_PRESET = "initial_send_preset"
private const val EXTRA_INITIAL_CUSTOM_SEND_AT = "initial_custom_send_at"
fun intent(context: Context, launch: MailComposeLaunch): Intent =
Intent(context, MailComposeActivity::class.java).apply {
putExtra(EXTRA_USERNAME, launch.username)
putExtra(EXTRA_PASSWORD, launch.password)
putExtra(EXTRA_SERVER_URL, launch.serverUrl)
putExtra(EXTRA_TRUST_ALL_CERTS, launch.trustAllCerts)
putExtra(EXTRA_ACCOUNT_ID, launch.accountId)
putExtra(EXTRA_ACCOUNT_EMAIL, launch.accountEmail)
putExtra(EXTRA_ACCOUNT_NAME, launch.accountName)
launch.mailboxId?.let { putExtra(EXTRA_MAILBOX_ID, it) }
putExtra(EXTRA_MODE, launch.mode.name)
putExtra(EXTRA_INITIAL_TO, launch.initialTo)
putExtra(EXTRA_INITIAL_CC, launch.initialCc)
putExtra(EXTRA_INITIAL_SUBJECT, launch.initialSubject)
putExtra(EXTRA_INITIAL_BODY_HTML, launch.initialBodyHtml)
putExtra(EXTRA_SHOW_CC_BCC, launch.showCcBcc)
putExtra(EXTRA_INITIAL_SEND_PRESET, launch.initialSendPreset.name)
launch.initialCustomSendAtEpochSeconds?.let { putExtra(EXTRA_INITIAL_CUSTOM_SEND_AT, it) }
}
fun launch(context: Context, session: AuthSession, accountId: Int, accountEmail: String, mailboxId: Int? = null) {
context.startActivity(
intent(
context,
MailComposeLaunch(
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
accountId = accountId,
accountEmail = accountEmail,
mailboxId = mailboxId,
),
),
)
}
}
}
@@ -0,0 +1,51 @@
package ru.forbion.f7cloud.feature.mail
enum class MailComposeMode {
NEW,
REPLY,
REPLY_ALL,
FORWARD,
}
data class MailComposeLaunch(
val username: String,
val password: String,
val serverUrl: String,
val trustAllCerts: Boolean,
val accountId: Int,
val accountEmail: String,
val accountName: String? = null,
val mailboxId: Int? = null,
val mode: MailComposeMode = MailComposeMode.NEW,
val initialTo: String = "",
val initialCc: String = "",
val initialSubject: String = "",
val initialBodyHtml: String = "",
val showCcBcc: Boolean = false,
val initialSendPreset: MailSendLaterPreset = MailSendLaterPreset.NOW,
val initialCustomSendAtEpochSeconds: Long? = null,
)
data class MailRecipient(
val email: String,
val label: String = email,
)
enum class MailComposeAttachmentType {
LOCAL,
CLOUD,
}
data class MailComposeAttachment(
val id: Int,
val fileName: String,
val mimeType: String,
val type: MailComposeAttachmentType = MailComposeAttachmentType.LOCAL,
val cloudPath: String? = null,
val size: Long? = null,
)
enum class MailFilesPickMode {
ATTACHMENT,
SHARE_LINK,
}
@@ -0,0 +1,85 @@
package ru.forbion.f7cloud.feature.mail
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
enum class MailSendLaterPreset {
NOW,
TOMORROW_MORNING,
TOMORROW_AFTERNOON,
MONDAY_MORNING,
CUSTOM,
}
data class MailSendLaterOption(
val preset: MailSendLaterPreset,
val title: String,
val sendAtEpochSeconds: Long?,
)
object MailComposeSchedule {
private val dateLabelFormatter = DateTimeFormatter.ofPattern("d MMM", Locale("ru"))
private val customFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale("ru"))
fun options(now: LocalDateTime = LocalDateTime.now()): List<MailSendLaterOption> {
val zone = ZoneId.systemDefault()
val tomorrow = now.toLocalDate().plusDays(1)
val mondayMorning = nextMondayMorning(now.toLocalDate())
return listOf(
MailSendLaterOption(MailSendLaterPreset.NOW, "Отправить сейчас", null),
MailSendLaterOption(
MailSendLaterPreset.TOMORROW_MORNING,
"Завтра утром - ${formatSlot(tomorrow, LocalTime.of(9, 0))}",
epochSeconds(tomorrow, LocalTime.of(9, 0), zone),
),
MailSendLaterOption(
MailSendLaterPreset.TOMORROW_AFTERNOON,
"Завтра днем - ${formatSlot(tomorrow, LocalTime.of(14, 0))}",
epochSeconds(tomorrow, LocalTime.of(14, 0), zone),
),
MailSendLaterOption(
MailSendLaterPreset.MONDAY_MORNING,
"В понедельник утром - ${formatSlot(mondayMorning, LocalTime.of(9, 0))}",
epochSeconds(mondayMorning, LocalTime.of(9, 0), zone),
),
MailSendLaterOption(
MailSendLaterPreset.CUSTOM,
"Настроить дату и время",
epochSeconds(now.toLocalDate(), now.toLocalTime().withSecond(0).withNano(0), zone),
),
)
}
fun formatCustom(dateTime: LocalDateTime): String = customFormatter.format(dateTime)
fun parseCustom(value: String): LocalDateTime? =
runCatching { LocalDateTime.parse(value.trim(), customFormatter) }.getOrNull()
private fun formatSlot(date: LocalDate, time: LocalTime): String {
val datePart = dateLabelFormatter.format(date).replace(".", "")
return "$datePart, ${time.format(DateTimeFormatter.ofPattern("HH:mm"))}"
}
private fun epochSeconds(date: LocalDate, time: LocalTime, zone: ZoneId): Long =
LocalDateTime.of(date, time).atZone(zone).toEpochSecond()
private fun nextMondayMorning(today: LocalDate): LocalDate {
var date = today
if (today.dayOfWeek == DayOfWeek.MONDAY) {
return today
}
while (date.dayOfWeek != DayOfWeek.MONDAY) {
date = date.plusDays(1)
}
return date
}
fun toInstant(epochSeconds: Long?): Instant? =
epochSeconds?.let { Instant.ofEpochSecond(it) }
}
@@ -0,0 +1,889 @@
package ru.forbion.f7cloud.feature.mail
import android.app.Activity
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
import java.time.LocalDateTime
@Composable
fun MailComposeScreen(
session: AuthSession,
launch: MailComposeLaunch,
vm: MailComposeViewModel,
onClose: () -> Unit,
onUnauthorized: () -> Unit,
modifier: Modifier = Modifier,
) {
val state by vm.state.collectAsState()
val context = LocalContext.current
val editor = remember { MailRichTextEditorController() }
val base = session.serverUrl.trimEnd('/')
val pickAttachmentsLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
if (uris.isNotEmpty()) {
vm.addAttachmentsFromUris(context, session, uris, onUnauthorized)
}
}
val filesPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { data ->
vm.handleFilesPickResult(data, session, editor, onUnauthorized)
}
} else {
vm.cancelFilesPick()
}
}
LaunchedEffect(session.serverUrl, session.username) {
vm.initContacts(session)
}
LaunchedEffect(state.finished) {
if (state.finished) {
Toast.makeText(context, "Письмо отправлено", Toast.LENGTH_SHORT).show()
onClose()
}
}
LaunchedEffect(launch.initialBodyHtml) {
if (launch.initialBodyHtml.isNotBlank()) {
editor.setHtml(launch.initialBodyHtml)
}
}
LaunchedEffect(state.error) {
state.error?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
val composeNavigateBack: () -> Boolean = {
navigateMailComposeBack(
state = MailComposeBackStackState(
attachmentMenuOpen = state.attachmentMenuOpen,
sendLaterMenuOpen = state.sendLaterMenuOpen,
moreMenuOpen = state.moreMenuOpen,
toolbarVisible = state.toolbarVisible,
),
closeAttachmentMenu = { vm.setAttachmentMenuOpen(false) },
closeSendLaterMenu = { vm.setSendLaterMenuOpen(false) },
closeMoreMenu = { vm.setMoreMenuOpen(false) },
closeToolbar = { vm.toggleToolbar() },
closeScreen = onClose,
)
}
BackHandler { composeNavigateBack() }
Column(
modifier = modifier
.fillMaxSize()
.background(F7Colors.Surface)
.f7SwipeFromRightToDismiss(onDismiss = { composeNavigateBack() }),
) {
MailComposeHeader(
senderLabel = vm.senderLabel,
title = when (launch.mode) {
MailComposeMode.REPLY -> "Ответить на сообщение"
MailComposeMode.REPLY_ALL -> "Ответить всем"
MailComposeMode.FORWARD -> "Переслать сообщение"
MailComposeMode.NEW -> "Новое сообщение"
},
serverUrl = session.serverUrl,
onClose = onClose,
)
MailRecipientComposeField(
label = "Кому:",
value = state.to,
onValueChange = vm::setTo,
suggestions = if (state.activeRecipientField == MailRecipientField.TO) {
state.recipientSuggestions
} else {
emptyList()
},
showSuggestions = state.activeRecipientField == MailRecipientField.TO,
onSuggestionClick = { vm.selectSuggestion(MailRecipientField.TO, it) },
onDismissSuggestions = vm::dismissSuggestions,
trailingIconUrl = "$base/themes/forbion/images/mail/nav-chevron-down-gray.svg",
onTrailingClick = vm::toggleCcBcc,
)
if (state.showCcBcc) {
MailRecipientComposeField(
label = "Копия:",
value = state.cc,
onValueChange = vm::setCc,
suggestions = if (state.activeRecipientField == MailRecipientField.CC) {
state.recipientSuggestions
} else {
emptyList()
},
showSuggestions = state.activeRecipientField == MailRecipientField.CC,
onSuggestionClick = { vm.selectSuggestion(MailRecipientField.CC, it) },
onDismissSuggestions = vm::dismissSuggestions,
)
MailRecipientComposeField(
label = "Скрытая копия:",
value = state.bcc,
onValueChange = vm::setBcc,
suggestions = if (state.activeRecipientField == MailRecipientField.BCC) {
state.recipientSuggestions
} else {
emptyList()
},
showSuggestions = state.activeRecipientField == MailRecipientField.BCC,
onSuggestionClick = { vm.selectSuggestion(MailRecipientField.BCC, it) },
onDismissSuggestions = vm::dismissSuggestions,
)
}
MailComposeField(
label = "Тема сообщения",
value = state.subject,
onValueChange = vm::setSubject,
)
if (state.attachments.isNotEmpty()) {
MailComposeAttachmentsRow(
serverUrl = session.serverUrl,
attachments = state.attachments,
onRemove = vm::removeAttachment,
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White),
) {
MailRichTextEditor(
modifier = Modifier.fillMaxSize(),
controller = editor,
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(10.dp),
) {
if (state.attachmentMenuOpen) {
MailComposeAttachmentMenu(
serverUrl = session.serverUrl,
onUploadFromDevice = {
vm.setAttachmentMenuOpen(false)
pickAttachmentsLauncher.launch(arrayOf("*/*"))
},
onPickFromFiles = {
vm.prepareFilesPick(MailFilesPickMode.ATTACHMENT)
vm.filesPickIntent(context, session)?.let(filesPickerLauncher::launch)
},
onAddShareLink = {
vm.prepareFilesPick(MailFilesPickMode.SHARE_LINK)
vm.filesPickIntent(context, session)?.let(filesPickerLauncher::launch)
},
onDismiss = { vm.setAttachmentMenuOpen(false) },
)
}
Box(
modifier = Modifier
.size(32.dp)
.clickable(
enabled = !state.uploadingAttachments,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { vm.setAttachmentMenuOpen(!state.attachmentMenuOpen) },
),
contentAlignment = Alignment.Center,
) {
if (state.uploadingAttachments) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
} else {
AsyncImage(
model = "$base/themes/forbion/images/mail/file-up-gray.svg",
contentDescription = "Вложение",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
}
}
}
Spacer(Modifier.height(8.dp))
if (state.toolbarVisible) {
MailComposeFormattingToolbar(
serverUrl = session.serverUrl,
onCommand = { command, value -> editor.exec(command, value) },
)
}
MailComposeBottomBar(
serverUrl = session.serverUrl,
sending = state.sending,
moreMenuOpen = state.moreMenuOpen,
sendLaterMenuOpen = state.sendLaterMenuOpen,
requestMdn = state.requestMdn,
sendLaterOptions = vm.sendLaterOptions,
selectedSendPreset = state.selectedSendPreset,
customSendAt = state.customSendAt,
onToggleToolbar = vm::toggleToolbar,
onMoreMenuOpenChange = vm::setMoreMenuOpen,
onSendLaterMenuOpenChange = vm::setSendLaterMenuOpen,
onToggleRequestMdn = vm::toggleRequestMdn,
onSelectSendPreset = vm::selectSendPreset,
onCustomSendAtChange = vm::setCustomSendAt,
onSend = { vm.send(session, editor, onUnauthorized) },
)
}
}
@Composable
private fun MailComposeHeader(
senderLabel: String,
title: String,
serverUrl: String,
onClose: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
Column(
modifier = Modifier
.fillMaxWidth()
.background(F7Colors.Surface)
.padding(bottom = 8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/arrow-back-gray.svg",
contentDescription = "Назад",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
Column(modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
senderLabel,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/close-icon-gray.svg",
contentDescription = "Закрыть",
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(F7Colors.Border.copy(alpha = 0.5f)),
)
}
}
@Composable
private fun MailComposeAttachmentsRow(
serverUrl: String,
attachments: List<MailComposeAttachment>,
onRemove: (Int) -> Unit,
) {
val base = serverUrl.trimEnd('/')
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
) {
Text(
"Вложения: ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.height(6.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
attachments.forEach { attachment ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.PrimaryLight)
.border(1.dp, F7Colors.Border, RoundedCornerShape(100.dp))
.padding(start = 10.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AsyncImage(
model = when (attachment.type) {
MailComposeAttachmentType.CLOUD ->
"$base/themes/forbion/images/mail/folder-add-black.svg"
MailComposeAttachmentType.LOCAL ->
"$base/themes/forbion/images/mail/file-up-gray.svg"
},
contentDescription = null,
modifier = Modifier.size(14.dp),
contentScale = ContentScale.Fit,
)
Text(
attachment.fileName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 13.sp,
modifier = Modifier.widthIn(max = 180.dp),
)
AsyncImage(
model = "$base/themes/forbion/images/mail/close-icon-gray.svg",
contentDescription = "Удалить",
modifier = Modifier
.size(14.dp)
.clickable { onRemove(attachment.id) },
contentScale = ContentScale.Fit,
)
}
}
}
}
}
@Composable
private fun MailComposeField(
label: String,
value: String,
onValueChange: (String) -> Unit,
trailingIconUrl: String? = null,
onTrailingClick: (() -> Unit)? = null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
}
inner()
},
)
if (trailingIconUrl != null && onTrailingClick != null) {
AsyncImage(
model = trailingIconUrl,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.clickable(onClick = onTrailingClick),
contentScale = ContentScale.Fit,
)
}
}
}
@Composable
private fun MailComposeFormattingToolbar(
serverUrl: String,
onCommand: (String, String?) -> Unit,
) {
val base = serverUrl.trimEnd('/')
val scroll = rememberScrollState()
val row1 = listOf(
ToolbarAction("bold", "$base/themes/forbion/images/mail/text-bold-black.svg"),
ToolbarAction("italic", "$base/themes/forbion/images/mail/text-italic-black.svg"),
ToolbarAction("underline", "$base/themes/forbion/images/mail/text-color-black.svg"),
ToolbarAction("strikeThrough", "$base/themes/forbion/images/mail/text-cross-out-black.svg"),
ToolbarAction("insertUnorderedList", "$base/themes/forbion/images/mail/text-ul-black.svg"),
ToolbarAction("insertOrderedList", "$base/themes/forbion/images/mail/text-ol-black.svg"),
ToolbarAction("justifyLeft", "$base/themes/forbion/images/mail/text-left-black.svg"),
)
val row2 = listOf(
ToolbarAction("formatBlock", "$base/themes/forbion/images/mail/text-editor-font-black.svg", "p"),
ToolbarAction("indent", "$base/themes/forbion/images/mail/text-kov-black.svg"),
ToolbarAction("outdent", "$base/themes/forbion/images/mail/text-upper-black.svg"),
ToolbarAction("removeFormat", "$base/themes/forbion/images/mail/clear-icon-black.svg"),
ToolbarAction("undo", "$base/themes/forbion/images/mail/arrow-back-gray.svg"),
ToolbarAction("redo", "$base/themes/forbion/images/mail/reply-left-gray.svg"),
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(10.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(10.dp))
.background(Color.White)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(scroll),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row1.forEach { action ->
ToolbarIcon(action, onCommand)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row2.forEach { action ->
ToolbarIcon(action, onCommand)
}
}
}
}
private data class ToolbarAction(
val command: String,
val iconUrl: String,
val value: String? = null,
)
@Composable
private fun ToolbarIcon(
action: ToolbarAction,
onCommand: (String, String?) -> Unit,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { onCommand(action.command, action.value) },
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = action.iconUrl,
contentDescription = null,
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
}
}
@Composable
private fun MailComposeBottomBar(
serverUrl: String,
sending: Boolean,
moreMenuOpen: Boolean,
sendLaterMenuOpen: Boolean,
requestMdn: Boolean,
sendLaterOptions: List<MailSendLaterOption>,
selectedSendPreset: MailSendLaterPreset,
customSendAt: LocalDateTime,
onToggleToolbar: () -> Unit,
onMoreMenuOpenChange: (Boolean) -> Unit,
onSendLaterMenuOpenChange: (Boolean) -> Unit,
onToggleRequestMdn: () -> Unit,
onSelectSendPreset: (MailSendLaterPreset) -> Unit,
onCustomSendAtChange: (LocalDateTime) -> Unit,
onSend: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
val context = LocalContext.current
Box {
if (moreMenuOpen) {
MailComposeMoreMenu(
serverUrl = serverUrl,
requestMdn = requestMdn,
onToggleRequestMdn = onToggleRequestMdn,
onSendLaterClick = {
onMoreMenuOpenChange(false)
onSendLaterMenuOpenChange(true)
},
onDismiss = { onMoreMenuOpenChange(false) },
)
}
if (sendLaterMenuOpen) {
MailComposeSendLaterMenu(
options = sendLaterOptions,
selectedPreset = selectedSendPreset,
customSendAt = customSendAt,
onSelectPreset = onSelectSendPreset,
onCustomSendAtChange = onCustomSendAtChange,
onDismiss = { onSendLaterMenuOpenChange(false) },
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(F7Colors.Surface)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(100.dp))
.clickable(onClick = onToggleToolbar),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/format-size-icon.svg",
contentDescription = "Форматирование",
modifier = Modifier.size(22.dp),
contentScale = ContentScale.Fit,
)
}
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(100.dp))
.clickable { onMoreMenuOpenChange(!moreMenuOpen) },
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/dots-icon-black.svg",
contentDescription = "Ещё",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
Box(
modifier = Modifier
.weight(1f)
.height(44.dp)
.shadow(4.dp, RoundedCornerShape(100.dp), spotColor = F7Colors.Primary.copy(alpha = 0.2f))
.clip(RoundedCornerShape(100.dp))
.background(
Brush.linearGradient(
listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
),
)
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), RoundedCornerShape(100.dp))
.clickable(enabled = !sending, onClick = onSend),
contentAlignment = Alignment.Center,
) {
if (sending) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/send-message-white.svg",
contentDescription = null,
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
Text(
"Отправить",
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
)
}
}
}
}
}
}
@Composable
private fun MailComposeAttachmentMenu(
serverUrl: String,
onUploadFromDevice: () -> Unit,
onPickFromFiles: () -> Unit,
onAddShareLink: () -> Unit,
onDismiss: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
Popup(
alignment = Alignment.BottomEnd,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
Column(
modifier = Modifier
.padding(end = 4.dp, bottom = 40.dp)
.width(320.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(vertical = 6.dp),
) {
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/mail/file-up-gray.svg",
title = "Загрузить файл с телефона",
onClick = onUploadFromDevice,
)
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/mail/folder-add-black.svg",
title = "Из приложения «Файлы»",
onClick = onPickFromFiles,
)
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/mail/sharing-icon-black.svg",
title = "Добавить ссылку для общего доступа из Файлов",
onClick = onAddShareLink,
)
}
}
}
@Composable
private fun MailComposeMoreMenu(
serverUrl: String,
requestMdn: Boolean,
onToggleRequestMdn: () -> Unit,
onSendLaterClick: () -> Unit,
onDismiss: () -> Unit,
) {
val base = serverUrl.trimEnd('/')
Popup(
alignment = Alignment.BottomStart,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
Column(
modifier = Modifier
.padding(start = 12.dp, bottom = 72.dp)
.width(300.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(vertical = 6.dp),
) {
MailComposeMenuRow(
iconUrl = "$base/themes/forbion/images/send-clock-icon.svg",
title = "Отправить позже",
onClick = onSendLaterClick,
)
MailComposeMenuRow(
iconUrl = if (requestMdn) {
"$base/themes/forbion/images/mail/checkbox-checked-green.svg"
} else {
"$base/themes/forbion/images/mail/checkbox-outline-gray.svg"
},
title = "Запросить подтверждение прочтения",
onClick = onToggleRequestMdn,
)
}
}
}
@Composable
private fun MailComposeSendLaterMenu(
options: List<MailSendLaterOption>,
selectedPreset: MailSendLaterPreset,
customSendAt: LocalDateTime,
onSelectPreset: (MailSendLaterPreset) -> Unit,
onCustomSendAtChange: (LocalDateTime) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
Popup(
alignment = Alignment.BottomStart,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
Column(
modifier = Modifier
.padding(start = 12.dp, bottom = 72.dp)
.width(320.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(12.dp)
.verticalScroll(rememberScrollState()),
) {
Text("Отправить позже", fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
options.forEach { option ->
val selected = option.preset == selectedPreset
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (selected) F7Colors.PrimaryLight else Color.Transparent)
.clickable { onSelectPreset(option.preset) }
.padding(horizontal = 8.dp, vertical = 8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(18.dp)
.clip(RoundedCornerShape(100.dp))
.border(
2.dp,
if (selected) F7Colors.Primary else F7Colors.Border,
RoundedCornerShape(100.dp),
)
.background(if (selected) F7Colors.Primary.copy(alpha = 0.15f) else Color.Transparent),
)
Spacer(Modifier.width(10.dp))
Text(option.title, fontSize = 14.sp)
}
if (option.preset == MailSendLaterPreset.CUSTOM && selected) {
Spacer(Modifier.height(8.dp))
val formatted = MailComposeSchedule.formatCustom(customSendAt)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.clickable {
val date = customSendAt.toLocalDate()
DatePickerDialog(
context,
{ _, year, month, day ->
val updatedDate = java.time.LocalDate.of(year, month + 1, day)
TimePickerDialog(
context,
{ _, hour, minute ->
onCustomSendAtChange(
LocalDateTime.of(updatedDate, java.time.LocalTime.of(hour, minute)),
)
},
customSendAt.hour,
customSendAt.minute,
true,
).show()
},
date.year,
date.monthValue - 1,
date.dayOfMonth,
).show()
}
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(formatted, fontSize = 14.sp)
}
}
}
}
}
}
}
@Composable
private fun MailComposeMenuRow(
iconUrl: String,
title: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = iconUrl,
contentDescription = null,
modifier = Modifier.size(18.dp),
contentScale = ContentScale.Fit,
)
Text(title, fontSize = 14.sp, lineHeight = 18.sp)
}
}
@@ -0,0 +1,396 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.feature.contacts.ContactItem
import ru.forbion.f7cloud.feature.contacts.ContactRecipientHelper
import ru.forbion.f7cloud.feature.contacts.ContactsRepository
import ru.forbion.f7cloud.feature.files.FilesApiRepository
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
enum class MailRecipientField {
TO,
CC,
BCC,
}
data class MailComposeUiState(
val to: String = "",
val cc: String = "",
val bcc: String = "",
val subject: String = "",
val showCcBcc: Boolean = false,
val requestMdn: Boolean = false,
val toolbarVisible: Boolean = false,
val moreMenuOpen: Boolean = false,
val sendLaterMenuOpen: Boolean = false,
val selectedSendPreset: MailSendLaterPreset = MailSendLaterPreset.NOW,
val customSendAt: LocalDateTime = LocalDateTime.now().withSecond(0).withNano(0),
val attachments: List<MailComposeAttachment> = emptyList(),
val uploadingAttachments: Boolean = false,
val attachmentMenuOpen: Boolean = false,
val sending: Boolean = false,
val error: String? = null,
val finished: Boolean = false,
val activeRecipientField: MailRecipientField? = null,
val recipientSuggestions: List<ContactItem> = emptyList(),
)
class MailComposeViewModel(
private val launch: MailComposeLaunch,
private val repository: MailRepository = MailRepository(),
private val filesApiRepository: FilesApiRepository = FilesApiRepository(),
contactsRepository: ContactsRepository? = null,
) : ViewModel() {
private val contactsRepository = contactsRepository
private val _state = MutableStateFlow(MailComposeUiState())
val state: StateFlow<MailComposeUiState> = _state.asStateFlow()
private var cachedContacts: List<ContactItem> = emptyList()
private var nextCloudAttachmentId = -1
private var pendingFilesPickMode: MailFilesPickMode? = null
init {
val customSendAt = launch.initialCustomSendAtEpochSeconds?.let { epoch ->
LocalDateTime.ofInstant(Instant.ofEpochSecond(epoch), ZoneId.systemDefault())
} ?: LocalDateTime.now().withSecond(0).withNano(0)
_state.value = MailComposeUiState(
to = launch.initialTo,
cc = launch.initialCc,
subject = launch.initialSubject,
showCcBcc = launch.showCcBcc || launch.initialCc.isNotBlank(),
selectedSendPreset = launch.initialSendPreset,
customSendAt = customSendAt,
)
}
val sendLaterOptions: List<MailSendLaterOption> = MailComposeSchedule.options()
val senderLabel: String
get() {
val email = launch.accountEmail
val name = launch.accountName?.takeIf { it.isNotBlank() }
return if (name != null) "$name <$email>" else email
}
fun initContacts(session: AuthSession) {
val repo = contactsRepository ?: return
viewModelScope.launch {
launch(Dispatchers.IO) {
runCatching { repo.syncContacts(session) }
}
repo.observeContacts(session).collect { contacts ->
cachedContacts = contacts
val active = _state.value.activeRecipientField
if (active != null) {
val current = currentValue(active)
updateSuggestions(active, current)
}
}
}
}
fun setTo(value: String) = updateRecipient(MailRecipientField.TO, value)
fun setCc(value: String) = updateRecipient(MailRecipientField.CC, value)
fun setBcc(value: String) = updateRecipient(MailRecipientField.BCC, value)
fun selectSuggestion(field: MailRecipientField, contact: ContactItem) {
val current = currentValue(field)
val formatted = ContactRecipientHelper.formatRecipient(contact.displayName, contact.email)
val updated = ContactRecipientHelper.replaceCurrentToken(current, formatted) + ", "
_state.update {
when (field) {
MailRecipientField.TO -> it.copy(
to = updated,
activeRecipientField = field,
recipientSuggestions = emptyList(),
)
MailRecipientField.CC -> it.copy(
cc = updated,
activeRecipientField = field,
recipientSuggestions = emptyList(),
)
MailRecipientField.BCC -> it.copy(
bcc = updated,
activeRecipientField = field,
recipientSuggestions = emptyList(),
)
}
}
}
fun dismissSuggestions() {
_state.update { it.copy(activeRecipientField = null, recipientSuggestions = emptyList()) }
}
fun setSubject(value: String) = _state.update { it.copy(subject = value) }
fun toggleCcBcc() = _state.update { it.copy(showCcBcc = !it.showCcBcc) }
fun toggleToolbar() = _state.update {
it.copy(
toolbarVisible = !it.toolbarVisible,
moreMenuOpen = false,
sendLaterMenuOpen = false,
attachmentMenuOpen = false,
)
}
fun setMoreMenuOpen(open: Boolean) = _state.update {
it.copy(moreMenuOpen = open, sendLaterMenuOpen = false, attachmentMenuOpen = false)
}
fun setSendLaterMenuOpen(open: Boolean) = _state.update { it.copy(sendLaterMenuOpen = open, moreMenuOpen = false) }
fun toggleRequestMdn() = _state.update { it.copy(requestMdn = !it.requestMdn) }
fun selectSendPreset(preset: MailSendLaterPreset) {
_state.update {
it.copy(
selectedSendPreset = preset,
sendLaterMenuOpen = preset != MailSendLaterPreset.CUSTOM,
)
}
}
fun setCustomSendAt(value: LocalDateTime) {
_state.update {
it.copy(
customSendAt = value,
selectedSendPreset = MailSendLaterPreset.CUSTOM,
)
}
}
fun setAttachmentMenuOpen(open: Boolean) = _state.update {
it.copy(attachmentMenuOpen = open, moreMenuOpen = false, sendLaterMenuOpen = false)
}
fun prepareFilesPick(mode: MailFilesPickMode) {
pendingFilesPickMode = mode
setAttachmentMenuOpen(false)
}
fun filesPickIntent(context: Context, session: AuthSession): Intent? {
val mode = pendingFilesPickMode ?: return null
val pickMode = when (mode) {
MailFilesPickMode.ATTACHMENT -> MailFilesPickerActivity.MODE_ATTACHMENT
MailFilesPickMode.SHARE_LINK -> MailFilesPickerActivity.MODE_SHARE_LINK
}
return MailFilesPickerActivity.intent(context, session, pickMode)
}
fun cancelFilesPick() {
pendingFilesPickMode = null
}
fun handleFilesPickResult(
data: Intent,
session: AuthSession,
editor: MailRichTextEditorController,
onUnauthorized: () -> Unit,
) {
val mode = pendingFilesPickMode ?: return
pendingFilesPickMode = null
val path = data.getStringExtra(MailFilesPickerActivity.RESULT_PATH).orEmpty()
if (path.isBlank()) return
when (mode) {
MailFilesPickMode.ATTACHMENT -> addCloudAttachment(
path = path,
fileName = data.getStringExtra(MailFilesPickerActivity.RESULT_NAME).orEmpty().ifBlank {
path.substringAfterLast('/')
},
mimeType = data.getStringExtra(MailFilesPickerActivity.RESULT_MIME).orEmpty()
.ifBlank { "application/octet-stream" },
size = data.getLongExtra(MailFilesPickerActivity.RESULT_SIZE, 0L).takeIf { it > 0L },
)
MailFilesPickMode.SHARE_LINK -> insertShareLinkFromFile(
session = session,
path = path,
editor = editor,
onUnauthorized = onUnauthorized,
)
}
}
fun addAttachmentsFromUris(
context: Context,
session: AuthSession,
uris: List<android.net.Uri>,
onUnauthorized: () -> Unit,
) {
if (uris.isEmpty() || _state.value.uploadingAttachments) return
viewModelScope.launch {
_state.update { it.copy(uploadingAttachments = true, error = null) }
try {
val uploaded = kotlinx.coroutines.withContext(Dispatchers.IO) {
buildList {
uris.forEach { uri ->
val (name, bytes, mime) = MailAttachmentIO.readUri(context, uri)
add(repository.uploadLocalAttachment(session, name, bytes, mime))
}
}
}
_state.update {
it.copy(
attachments = it.attachments + uploaded,
uploadingAttachments = false,
)
}
} catch (_: UnauthorizedException) {
_state.update { it.copy(uploadingAttachments = false) }
onUnauthorized()
} catch (e: Exception) {
_state.update {
it.copy(
uploadingAttachments = false,
error = e.message ?: "Не удалось загрузить вложение",
)
}
}
}
}
private fun addCloudAttachment(
path: String,
fileName: String,
mimeType: String,
size: Long?,
) {
val cloudPath = if (path.startsWith("/")) path else "/$path"
val attachment = MailComposeAttachment(
id = nextCloudAttachmentId--,
fileName = fileName,
mimeType = mimeType,
type = MailComposeAttachmentType.CLOUD,
cloudPath = cloudPath,
size = size,
)
_state.update { it.copy(attachments = it.attachments + attachment) }
}
fun insertShareLinkFromFile(
session: AuthSession,
path: String,
editor: MailRichTextEditorController,
onUnauthorized: () -> Unit,
) {
if (_state.value.uploadingAttachments) return
viewModelScope.launch {
_state.update { it.copy(uploadingAttachments = true, error = null) }
try {
val shareUrl = kotlinx.coroutines.withContext(Dispatchers.IO) {
filesApiRepository.createPublicShareLink(session, path)
}
val html = """<a href="$shareUrl">$shareUrl</a>"""
editor.insertHtml(html)
_state.update { it.copy(uploadingAttachments = false) }
} catch (_: UnauthorizedException) {
_state.update { it.copy(uploadingAttachments = false) }
onUnauthorized()
} catch (e: Exception) {
_state.update {
it.copy(
uploadingAttachments = false,
error = e.message ?: "Не удалось создать ссылку",
)
}
}
}
}
fun removeAttachment(id: Int) {
_state.update { it.copy(attachments = it.attachments.filterNot { att -> att.id == id }) }
}
fun send(
session: AuthSession,
editor: MailRichTextEditorController,
onUnauthorized: () -> Unit,
) {
val current = _state.value
if (current.sending) return
val recipients = repository.parseRecipients(current.to)
if (recipients.isEmpty()) {
_state.update { it.copy(error = "Укажите получателя") }
return
}
_state.update { it.copy(sending = true, error = null, recipientSuggestions = emptyList()) }
viewModelScope.launch {
try {
val bodyHtml = editor.html()
val bodyPlain = editor.plainText()
val sendAt = when (current.selectedSendPreset) {
MailSendLaterPreset.NOW -> null
MailSendLaterPreset.TOMORROW_MORNING ->
sendLaterOptions.first { it.preset == MailSendLaterPreset.TOMORROW_MORNING }.sendAtEpochSeconds?.toInt()
MailSendLaterPreset.TOMORROW_AFTERNOON ->
sendLaterOptions.first { it.preset == MailSendLaterPreset.TOMORROW_AFTERNOON }.sendAtEpochSeconds?.toInt()
MailSendLaterPreset.MONDAY_MORNING ->
sendLaterOptions.first { it.preset == MailSendLaterPreset.MONDAY_MORNING }.sendAtEpochSeconds?.toInt()
MailSendLaterPreset.CUSTOM ->
current.customSendAt.atZone(java.time.ZoneId.systemDefault()).toEpochSecond().toInt()
}
repository.createAndSendMessage(
session = session,
accountId = launch.accountId,
to = recipients,
cc = repository.parseRecipients(current.cc),
bcc = repository.parseRecipients(current.bcc),
subject = current.subject,
bodyHtml = bodyHtml,
bodyPlain = bodyPlain,
editorBody = bodyHtml,
requestMdn = current.requestMdn,
sendAt = sendAt,
attachments = current.attachments,
)
_state.update { it.copy(sending = false, finished = true) }
} catch (_: UnauthorizedException) {
_state.update { it.copy(sending = false) }
onUnauthorized()
} catch (e: Exception) {
_state.update { it.copy(sending = false, error = e.message ?: "Ошибка отправки") }
}
}
}
private fun updateRecipient(field: MailRecipientField, value: String) {
_state.update {
when (field) {
MailRecipientField.TO -> it.copy(to = value, activeRecipientField = field)
MailRecipientField.CC -> it.copy(cc = value, activeRecipientField = field)
MailRecipientField.BCC -> it.copy(bcc = value, activeRecipientField = field)
}
}
updateSuggestions(field, value)
}
private fun updateSuggestions(field: MailRecipientField, value: String) {
val repo = contactsRepository
val token = ContactRecipientHelper.currentToken(value)
val suggestions = if (repo != null && token.isNotBlank()) {
repo.filterSuggestions(cachedContacts, token)
} else {
emptyList()
}
_state.update {
it.copy(
activeRecipientField = field,
recipientSuggestions = suggestions,
)
}
}
private fun currentValue(field: MailRecipientField): String = when (field) {
MailRecipientField.TO -> _state.value.to
MailRecipientField.CC -> _state.value.cc
MailRecipientField.BCC -> _state.value.bcc
}
}
@@ -0,0 +1,262 @@
package ru.forbion.f7cloud.feature.mail
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7Theme
import ru.forbion.f7cloud.core.network.UnauthorizedException
import ru.forbion.f7cloud.feature.files.FileItem
import ru.forbion.f7cloud.feature.files.FilesRepository
class MailFilesPickerActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val session = readSession() ?: run {
finish()
return
}
val mode = intent.getStringExtra(EXTRA_MODE) ?: MODE_ATTACHMENT
val title = if (mode == MODE_SHARE_LINK) {
"Ссылка из «Файлов»"
} else {
"Выбор файла"
}
setContent {
F7Theme {
MailFilesPickerScreen(
session = session,
title = title,
onCancel = { finish() },
onUnauthorized = { finish() },
onFileSelected = { file ->
setResult(
Activity.RESULT_OK,
Intent().apply {
putExtra(RESULT_PATH, "/${file.relativePath.trim('/')}")
putExtra(RESULT_NAME, file.name)
putExtra(RESULT_SIZE, file.size ?: 0L)
putExtra(RESULT_MIME, file.mimeType.orEmpty())
},
)
finish()
},
)
}
}
}
private fun readSession(): AuthSession? {
val username = intent.getStringExtra(EXTRA_USERNAME).orEmpty()
val serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty()
val password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty()
if (username.isBlank() || serverUrl.isBlank() || password.isBlank()) return null
return AuthSession(
serverUrl = serverUrl,
username = username,
appPassword = password,
trustAllCerts = intent.getBooleanExtra(EXTRA_TRUST_ALL_CERTS, false),
)
}
companion object {
const val EXTRA_USERNAME = "username"
const val EXTRA_PASSWORD = "password"
const val EXTRA_SERVER_URL = "server_url"
const val EXTRA_TRUST_ALL_CERTS = "trust_all_certs"
const val EXTRA_MODE = "mode"
const val MODE_ATTACHMENT = "attachment"
const val MODE_SHARE_LINK = "share_link"
const val RESULT_PATH = "result_path"
const val RESULT_NAME = "result_name"
const val RESULT_SIZE = "result_size"
const val RESULT_MIME = "result_mime"
fun intent(context: Context, session: AuthSession, mode: String): Intent =
Intent(context, MailFilesPickerActivity::class.java).apply {
putExtra(EXTRA_USERNAME, session.username)
putExtra(EXTRA_PASSWORD, session.appPassword)
putExtra(EXTRA_SERVER_URL, session.serverUrl)
putExtra(EXTRA_TRUST_ALL_CERTS, session.trustAllCerts)
putExtra(EXTRA_MODE, mode)
}
}
}
@Composable
private fun MailFilesPickerScreen(
session: AuthSession,
title: String,
onCancel: () -> Unit,
onUnauthorized: () -> Unit,
onFileSelected: (FileItem) -> Unit,
) {
val context = LocalContext.current
val repository = remember { FilesRepository(context.applicationContext) }
var currentPath by remember { mutableStateOf("") }
var items by remember { mutableStateOf<List<FileItem>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
val base = session.serverUrl.trimEnd('/')
LaunchedEffect(session.serverUrl, session.username, currentPath) {
loading = true
error = null
try {
items = withContext(Dispatchers.IO) {
repository.listFolder(session, currentPath)
.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
}
} catch (_: UnauthorizedException) {
onUnauthorized()
} catch (e: Exception) {
error = e.message ?: "Не удалось загрузить файлы"
items = emptyList()
} finally {
loading = false
}
}
BackHandler(enabled = currentPath.isNotBlank()) {
currentPath = parentPath(currentPath)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(F7Colors.Surface),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable(onClick = {
if (currentPath.isBlank()) onCancel() else currentPath = parentPath(currentPath)
}),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "$base/themes/forbion/images/mail/arrow-back-gray.svg",
contentDescription = "Назад",
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(title, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(
if (currentPath.isBlank()) "Файлы" else currentPath,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
when {
loading -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
error != null -> Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(error.orEmpty(), color = F7Colors.TextSecondary)
}
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(items, key = { it.relativePath }) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (item.isDirectory) {
currentPath = item.relativePath
} else {
onFileSelected(item)
}
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = if (item.isDirectory) {
"$base/themes/forbion/images/mail/folder-add-black.svg"
} else {
"$base/themes/forbion/images/mail/file-up-gray.svg"
},
contentDescription = null,
modifier = Modifier.size(20.dp),
contentScale = ContentScale.Fit,
)
Text(
item.name,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
private fun parentPath(path: String): String {
val trimmed = path.trim('/')
if (trimmed.isBlank()) return ""
val index = trimmed.lastIndexOf('/')
return if (index < 0) "" else trimmed.substring(0, index)
}
@@ -0,0 +1,153 @@
package ru.forbion.f7cloud.feature.mail
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import ru.forbion.f7cloud.core.auth.AuthSession
import java.util.concurrent.TimeUnit
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun MailMessageBodyView(
session: AuthSession,
messageId: Int,
html: String,
attachments: List<MailAttachment>,
client: OkHttpClient,
modifier: Modifier = Modifier,
) {
val baseUrl = "${session.serverUrl.trimEnd('/')}/"
val apiBase = "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/api"
val serverPrefix = remember(session.serverUrl) { session.serverUrl.trimEnd('/') }
val externalClient = remember {
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
val bodyHtml = remember(html, messageId, apiBase, attachments) {
MailBodyHtml.rewriteImageSources(
html = MailBodyHtml.normalizeForDisplay(html),
messageId = messageId,
apiBase = apiBase,
attachments = attachments,
)
}
val wrapped = remember(bodyHtml, baseUrl) {
"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${MailBodyHtml.READER_CSS}</style>
</head>
<body>$bodyHtml</body>
</html>
""".trimIndent()
}
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
settings.javaScriptEnabled = false
settings.domStorageEnabled = false
settings.loadsImagesAutomatically = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
isVerticalScrollBarEnabled = true
isNestedScrollingEnabled = true
setBackgroundColor(0x00000000)
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest,
): WebResourceResponse? {
val url = request.url?.toString() ?: return null
if (url.contains("/apps/mail/proxy", ignoreCase = true)) {
val directUrl = MailBodyHtml.unwrapProxyUrl(url)
if (directUrl != url) {
return fetchExternalImage(externalClient, directUrl)
}
}
if (!url.startsWith(serverPrefix, ignoreCase = true)) {
return null
}
if (!isServerMailResource(url)) {
return null
}
return fetchAuthedResource(client, url)
}
}
}
},
update = { webView ->
webView.onResume()
webView.resumeTimers()
val loadedId = webView.tag as? Int
if (loadedId != messageId) {
webView.tag = messageId
webView.loadDataWithBaseURL(baseUrl, wrapped, "text/html", "UTF-8", null)
}
},
onRelease = { webView ->
runCatching {
webView.stopLoading()
webView.onPause()
webView.pauseTimers()
webView.loadUrl("about:blank")
webView.webViewClient = WebViewClient()
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
},
)
}
private fun isServerMailResource(url: String): Boolean =
url.contains("/apps/mail/", ignoreCase = true)
private fun fetchAuthedResource(client: OkHttpClient, url: String): WebResourceResponse? =
runCatching {
val httpRequest = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.build()
client.newCall(httpRequest).execute().use(::toWebResourceResponse)
}.getOrNull()
private fun fetchExternalImage(client: OkHttpClient, url: String): WebResourceResponse? =
runCatching {
val httpRequest = Request.Builder().url(url).build()
client.newCall(httpRequest).execute().use(::toWebResourceResponse)
}.getOrNull()
private fun toWebResourceResponse(response: Response): WebResourceResponse? {
if (!response.isSuccessful || response.body == null) return null
val body = response.body!!
val contentType = body.contentType()
val mimeType = contentType?.let { "${it.type}/${it.subtype}" } ?: "application/octet-stream"
val encoding = when {
mimeType.startsWith("image/") -> null
mimeType.startsWith("video/") -> null
mimeType.startsWith("audio/") -> null
mimeType == "application/octet-stream" -> null
else -> contentType?.charset()?.name() ?: "utf-8"
}
return WebResourceResponse(mimeType, encoding, body.byteStream())
}
@@ -0,0 +1,332 @@
package ru.forbion.f7cloud.feature.mail
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
enum class MailListFilter {
ALL,
UNREAD,
STARRED,
}
data class MailSearchParams(
val subject: String = "",
val body: String = "",
val dateStart: String = "",
val dateEnd: String = "",
val from: String = "",
val to: String = "",
val cc: String = "",
val bcc: String = "",
val tags: String = "",
val important: Boolean = false,
val starred: Boolean = false,
val unread: Boolean = false,
val hasAttachments: Boolean = false,
val mentionsMe: Boolean = false,
) {
fun isActive(): Boolean = subject.isNotBlank() ||
body.isNotBlank() ||
dateStart.isNotBlank() ||
dateEnd.isNotBlank() ||
from.isNotBlank() ||
to.isNotBlank() ||
cc.isNotBlank() ||
bcc.isNotBlank() ||
tags.isNotBlank() ||
important ||
starred ||
unread ||
hasAttachments ||
mentionsMe
fun toFilterString(): String {
val parts = mutableListOf<String>()
fun addToken(prefix: String, value: String) {
val trimmed = value.trim()
if (trimmed.isNotBlank()) parts += "$prefix$trimmed"
}
addToken("subject:", subject)
addToken("body:", body)
addToken("start:", dateStart)
addToken("end:", dateEnd)
addToken("from:", from)
addToken("to:", to)
addToken("cc:", cc)
addToken("bcc:", bcc)
addToken("tags:", tags)
if (important) parts += "is:important"
if (starred) parts += "is:starred"
if (unread) parts += "is:unread"
if (hasAttachments) parts += "flags:attachments"
if (mentionsMe) parts += "mentions:true"
return parts.joinToString(" ")
}
}
enum class MailQuickFilter(val label: String) {
MENTIONS_ME("Мои"),
HAS_ATTACHMENTS("Имеет вложения"),
LAST_7_DAYS("Последние 7 дней"),
}
fun mailLast7DaysStartDate(): String {
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_YEAR, -7)
return SimpleDateFormat("yyyy-MM-dd", Locale.US).format(calendar.time)
}
fun MailSearchParams.isLast7DaysActive(): Boolean =
dateStart == mailLast7DaysStartDate() && dateEnd.isBlank()
fun MailSearchParams.isQuickFilterActive(filter: MailQuickFilter): Boolean = when (filter) {
MailQuickFilter.MENTIONS_ME -> mentionsMe
MailQuickFilter.HAS_ATTACHMENTS -> hasAttachments
MailQuickFilter.LAST_7_DAYS -> isLast7DaysActive()
}
fun MailSearchParams.toggleQuickFilter(filter: MailQuickFilter): MailSearchParams = when (filter) {
MailQuickFilter.MENTIONS_ME -> copy(mentionsMe = !mentionsMe)
MailQuickFilter.HAS_ATTACHMENTS -> copy(hasAttachments = !hasAttachments)
MailQuickFilter.LAST_7_DAYS -> if (isLast7DaysActive()) {
copy(dateStart = "", dateEnd = "")
} else {
copy(dateStart = mailLast7DaysStartDate(), dateEnd = "")
}
}
sealed interface MailInboxListItem {
data class YearHeader(val year: Int) : MailInboxListItem
data class MessageItem(val message: MailMessage) : MailInboxListItem
}
fun messageYear(dateInt: Long): Int? {
if (dateInt <= 0) return null
val calendar = Calendar.getInstance()
calendar.timeInMillis = dateInt * 1000L
return calendar.get(Calendar.YEAR)
}
fun buildMailInboxListItems(messages: List<MailMessage>): List<MailInboxListItem> {
if (messages.isEmpty()) return emptyList()
val out = mutableListOf<MailInboxListItem>()
var lastYear: Int? = null
for (message in messages) {
val year = messageYear(message.dateInt)
if (year != null && year != lastYear) {
out += MailInboxListItem.YearHeader(year)
lastYear = year
}
out += MailInboxListItem.MessageItem(message)
}
return out
}
fun mailFolderListTitle(
folder: MailFolderEntry?,
searchQuery: String,
searchParams: MailSearchParams,
): String? {
if (folder?.specialRole == "outbox") return null
if (searchQuery.isNotBlank() || searchParams.isActive()) return "Результаты поиска"
if (folder?.specialRole == "inbox" && folder.filter == MailListFilter.ALL) return null
return folder?.title
}
data class MailAccount(
val id: Int,
val email: String,
val name: String,
val draftsMailboxId: Int?,
val sentMailboxId: Int?,
val trashMailboxId: Int?,
val archiveMailboxId: Int?,
val junkMailboxId: Int?,
val snoozeMailboxId: Int? = null,
)
data class MailMailbox(
val id: Int,
val accountId: Int,
val displayName: String,
val name: String,
val specialRole: String?,
val unread: Int,
val isInbox: Boolean,
)
data class MailFolderEntry(
val mailboxId: Int,
val accountId: Int,
val title: String,
val specialRole: String?,
val filter: MailListFilter,
val unread: Int = 0,
) {
fun cacheKey(): String = "${mailboxId}_${filter.name}"
}
data class MailMessageFlags(
val seen: Boolean,
val flagged: Boolean,
val hasAttachments: Boolean,
val answered: Boolean,
val important: Boolean = false,
)
data class MailTag(
val id: Long = 0,
val displayName: String,
val colorHex: String = "",
val imapLabel: String = "",
)
data class MailMessage(
val id: Int,
val subject: String,
val from: String,
val fromEmail: String,
val preview: String,
val dateInt: Long,
val flags: MailMessageFlags,
val tags: List<MailTag> = emptyList(),
)
data class MailAttachment(
val id: String,
val fileName: String,
val mime: String,
val size: Long,
val cid: String? = null,
val downloadUrl: String? = null,
)
data class MailMessageDetail(
val id: Int,
val subject: String,
val from: String,
val fromEmail: String,
val to: String,
val cc: String,
val dateInt: Long,
val bodyHtml: String,
val bodyPlain: String,
val hasHtmlBody: Boolean,
val flags: MailMessageFlags,
val attachments: List<MailAttachment>,
val tags: List<MailTag> = emptyList(),
)
fun MailMessage.toDetailStub(): MailMessageDetail =
MailMessageDetail(
id = id,
subject = subject,
from = from,
fromEmail = fromEmail,
to = "",
cc = "",
dateInt = dateInt,
bodyHtml = "",
bodyPlain = "",
hasHtmlBody = false,
flags = flags,
attachments = emptyList(),
tags = tags,
)
fun MailMessageDetail.hasBodyContent(): Boolean =
bodyHtml.isNotBlank() || bodyPlain.isNotBlank()
private val HTML_BODY_HINT = Regex("""^\s*<(?:!DOCTYPE|html|head|body|table|div|p|span|br|meta)\b""", RegexOption.IGNORE_CASE)
fun MailMessageDetail.looksLikeHtml(): Boolean =
hasHtmlBody ||
HTML_BODY_HINT.containsMatchIn(bodyHtml) ||
HTML_BODY_HINT.containsMatchIn(bodyPlain)
fun MailMessageDetail.htmlBodyForDisplay(): String = when {
bodyHtml.isNotBlank() -> bodyHtml
bodyPlain.isNotBlank() && HTML_BODY_HINT.containsMatchIn(bodyPlain) -> bodyPlain
else -> ""
}
fun MailMessageDetail.plainBodyForDisplay(): String {
if (bodyPlain.isNotBlank() && !HTML_BODY_HINT.containsMatchIn(bodyPlain)) return bodyPlain
if (bodyHtml.isNotBlank() && !HTML_BODY_HINT.containsMatchIn(bodyHtml)) return bodyHtml
return ""
}
data class MailBootstrap(
val accounts: List<MailAccount>,
val mailboxes: List<MailMailbox>,
val folders: List<MailFolderEntry>,
val selectedFolder: MailFolderEntry?,
)
data class MailMessagesPage(
val messages: List<MailMessage>,
val nextCursor: Int?,
)
data class MailAppSettings(
val showThreaded: Boolean = true,
val highlightExternalAddresses: Boolean = false,
val allowNewAccounts: Boolean = true,
val trustedSenders: List<MailTrustedSender> = emptyList(),
val textBlocks: List<MailTextBlock> = emptyList(),
)
data class MailTrustedSender(
val id: Int,
val email: String,
val type: String,
)
data class MailTextBlock(
val id: Int,
val title: String,
val content: String,
val preview: String = "",
)
data class MailAccountSettings(
val id: Int,
val email: String,
val name: String,
val signature: String,
val draftsMailboxId: Int?,
val sentMailboxId: Int?,
val trashMailboxId: Int?,
val archiveMailboxId: Int?,
val junkMailboxId: Int?,
val searchBody: Boolean,
val classificationEnabled: Boolean,
val signatureAboveQuote: Boolean,
val imipCreate: Boolean,
val quotaPercentage: Int?,
val imapHost: String?,
val smtpHost: String?,
)
data class MailOutboxMessage(
val id: Int,
val accountId: Int,
val subject: String,
val toRecipients: String,
val preview: String,
val updatedAt: Long,
val sendAt: Long?,
val failed: Boolean,
val status: Int,
)
object MailVirtualFolders {
val OUTBOX = MailFolderEntry(
mailboxId = 0,
accountId = 0,
title = "Исходящие",
specialRole = "outbox",
filter = MailListFilter.ALL,
)
}
@@ -0,0 +1,109 @@
package ru.forbion.f7cloud.feature.mail
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import coil.compose.AsyncImage
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.feature.contacts.ContactItem
import ru.forbion.f7cloud.feature.contacts.ContactListRow
@Composable
fun MailRecipientComposeField(
label: String,
value: String,
onValueChange: (String) -> Unit,
suggestions: List<ContactItem>,
showSuggestions: Boolean,
onSuggestionClick: (ContactItem) -> Unit,
onDismissSuggestions: () -> Unit,
trailingIconUrl: String? = null,
onTrailingClick: (() -> Unit)? = null,
) {
Box(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp))
.background(Color.White)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
textStyle = TextStyle(fontSize = 15.sp, color = F7Colors.TextPrimary),
cursorBrush = SolidColor(F7Colors.Primary),
decorationBox = { inner ->
if (value.isEmpty()) {
Text(label, color = F7Colors.TextSecondary, fontSize = 15.sp)
}
inner()
},
)
if (trailingIconUrl != null && onTrailingClick != null) {
AsyncImage(
model = trailingIconUrl,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.clickable(onClick = onTrailingClick),
contentScale = ContentScale.Fit,
)
}
}
if (showSuggestions && suggestions.isNotEmpty()) {
Popup(
alignment = Alignment.TopStart,
onDismissRequest = onDismissSuggestions,
properties = PopupProperties(focusable = false),
) {
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 2.dp)
.fillMaxWidth()
.heightIn(max = 240.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White)
.border(1.dp, F7Colors.Border, RoundedCornerShape(8.dp)),
) {
suggestions.forEachIndexed { index, contact ->
ContactListRow(
contact = contact,
onClick = { onSuggestionClick(contact) },
)
if (index < suggestions.lastIndex) {
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.6f))
}
}
}
}
}
}
}
@@ -0,0 +1,121 @@
package ru.forbion.f7cloud.feature.mail
object MailReplyHelper {
fun extractEmail(raw: String): String {
if (raw.isBlank()) return ""
val match = Regex("<([^>]+)>").find(raw)
return match?.groupValues?.get(1)?.trim() ?: raw.trim()
}
fun replySubject(subject: String): String {
val trimmed = subject.trim().ifBlank { "(без темы)" }
return if (trimmed.startsWith("Re:", ignoreCase = true)) trimmed else "Re: $trimmed"
}
fun forwardSubject(subject: String): String {
val trimmed = subject.trim().ifBlank { "(без темы)" }
return when {
trimmed.startsWith("Fwd:", ignoreCase = true) -> trimmed
trimmed.startsWith("Fw:", ignoreCase = true) -> trimmed
else -> "Fwd: $trimmed"
}
}
fun buildComposeLaunch(
base: MailComposeLaunch,
detail: MailMessageDetail,
mode: MailComposeMode,
accountEmail: String,
): MailComposeLaunch {
val senderEmail = extractEmail(detail.from)
val senderLabel = detail.from.ifBlank { senderEmail }.ifBlank { "Неизвестный" }
val quotedBody = buildQuotedBodyHtml(detail, mode)
return when (mode) {
MailComposeMode.REPLY -> base.copy(
mode = mode,
initialTo = formatRecipient(senderLabel, senderEmail),
initialSubject = replySubject(detail.subject),
initialBodyHtml = quotedBody,
showCcBcc = false,
)
MailComposeMode.REPLY_ALL -> {
val cc = buildReplyAllCc(detail, accountEmail, senderEmail)
base.copy(
mode = mode,
initialTo = formatRecipient(senderLabel, senderEmail),
initialCc = cc,
initialSubject = replySubject(detail.subject),
initialBodyHtml = quotedBody,
showCcBcc = cc.isNotBlank(),
)
}
MailComposeMode.FORWARD -> base.copy(
mode = mode,
initialSubject = forwardSubject(detail.subject),
initialBodyHtml = quotedBody,
showCcBcc = false,
)
MailComposeMode.NEW -> base
}
}
private fun formatRecipient(label: String, email: String): String {
if (email.isBlank()) return label
if (label.isBlank() || label.equals(email, ignoreCase = true)) return email
return "$label <$email>"
}
private fun buildReplyAllCc(
detail: MailMessageDetail,
accountEmail: String,
senderEmail: String,
): String {
val own = accountEmail.lowercase()
val sender = senderEmail.lowercase()
return (detail.to + "," + detail.cc)
.split(',', ';')
.map { token -> extractEmail(token.trim()).ifBlank { token.trim() } }
.filter { email ->
email.isNotBlank() &&
!email.equals(own, ignoreCase = true) &&
!email.equals(sender, ignoreCase = true)
}
.distinct()
.joinToString(", ")
}
private fun buildQuotedBodyHtml(detail: MailMessageDetail, mode: MailComposeMode): String {
val body = if (detail.hasHtmlBody && detail.bodyHtml.isNotBlank()) {
MailBodyHtml.normalizeForDisplay(detail.bodyHtml)
} else {
val plain = MailBodyHtml.normalizePlainForDisplay(
detail.bodyPlain.ifBlank { detail.bodyHtml },
)
plain.replace("\n", "<br>")
}
val date = formatMessageDate(detail.dateInt)
val from = detail.from.ifBlank { "Неизвестный" }
val header = if (mode == MailComposeMode.FORWARD) {
"""
<p>-------- Пересылаемое сообщение --------</p>
<p><b>От:</b> ${escapeHtml(from)}</p>
<p><b>Дата:</b> ${escapeHtml(date)}</p>
<p><b>Тема:</b> ${escapeHtml(detail.subject)}</p>
<p><b>Кому:</b> ${escapeHtml(detail.to)}</p>
""".trimIndent()
} else {
"""
<p>${escapeHtml(date)}, ${escapeHtml(from)} писал(а):</p>
""".trimIndent()
}
return "<br><br><blockquote class=\"quote\">$header$body</blockquote>"
}
private fun escapeHtml(text: String): String =
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
}
@@ -0,0 +1,991 @@
package ru.forbion.f7cloud.feature.mail
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.UnauthorizedException
class MailRepository {
suspend fun loadBootstrap(session: AuthSession): MailBootstrap {
val client = authedClient(session)
val base = apiBase(session)
val accountsJson = getJsonArray(client, "$base/accounts")
if (accountsJson.length() == 0) {
return MailBootstrap(emptyList(), emptyList(), emptyList(), null)
}
val accounts = parseAccounts(accountsJson).distinctBy { it.email.lowercase() }
val allMailboxes = mutableListOf<MailMailbox>()
val allFolders = mutableListOf<MailFolderEntry>()
for (account in accounts) {
val mailboxes = parseMailboxes(getJson(client, "$base/mailboxes?accountId=${account.id}"))
allMailboxes += mailboxes
allFolders += buildFolderList(account, mailboxes)
}
val selected = allFolders.firstOrNull { it.specialRole == "inbox" && it.filter == MailListFilter.ALL }
?: allFolders.firstOrNull()
return MailBootstrap(accounts, allMailboxes, allFolders, selected)
}
suspend fun loadMessages(
session: AuthSession,
folder: MailFolderEntry,
searchQuery: String = "",
searchParams: MailSearchParams = MailSearchParams(),
cursor: Int? = null,
limit: Int = PAGE_SIZE,
): MailMessagesPage {
val client = authedClient(session)
val base = apiBase(session)
val filter = buildFilterQuery(folder.filter, searchQuery, searchParams)
val urlBuilder = StringBuilder("$base/messages?mailboxId=${folder.mailboxId}&limit=$limit&view=singleton")
if (filter.isNotBlank()) urlBuilder.append("&filter=").append(java.net.URLEncoder.encode(filter, "UTF-8"))
if (cursor != null) urlBuilder.append("&cursor=$cursor")
val messages = parseMessages(getJson(client, urlBuilder.toString()))
return pageFromMessages(messages, limit)
}
private fun pageFromMessages(messages: List<MailMessage>, limit: Int): MailMessagesPage {
// API cursor is sent_at (dateInt), not database message id.
val nextCursor = if (messages.size >= limit) {
messages.lastOrNull()?.dateInt?.takeIf { it > 0 }?.toInt()
} else {
null
}
return MailMessagesPage(messages, nextCursor)
}
suspend fun loadMessage(session: AuthSession, messageId: Int): MailMessageDetail {
val client = authedClient(session)
val base = apiBase(session)
val json = getJson(client, "$base/messages/$messageId/body") as JSONObject
val body = json.optString("body")
val hasHtml = json.optBoolean("hasHtmlBody", false)
val flagsJson = json.optJSONObject("flags")
return MailMessageDetail(
id = json.optInt("databaseId", messageId),
subject = json.optString("subject").ifBlank { "(без темы)" },
from = parseAddressLabel(json.opt("from")),
fromEmail = parseAddressEmail(json.opt("from")),
to = parseAddressList(json.opt("to")),
cc = parseAddressList(json.opt("cc")),
dateInt = json.optLong("dateInt", 0L),
bodyHtml = if (hasHtml) body else "",
bodyPlain = if (hasHtml) "" else body,
hasHtmlBody = hasHtml,
flags = parseFlags(flagsJson),
attachments = parseAttachments(json.optJSONArray("attachments"), session.serverUrl),
tags = parseTags(json.optJSONObject("tags")),
)
}
suspend fun loadThread(session: AuthSession, messageId: Int): List<MailMessage> {
val client = authedClient(session)
val base = apiBase(session)
return runCatching {
parseMessages(getJson(client, "$base/messages/$messageId/thread"))
}.getOrDefault(emptyList())
}
suspend fun loadAppSettings(session: AuthSession): MailAppSettings {
val client = authedClient(session)
val base = apiBase(session)
val showThreaded = loadPreference(client, base, "layout-message-view", "threaded") == "threaded"
val highlightExternal = loadPreference(client, base, "internal-addresses", "false") == "true"
val allowNewAccounts = loadPreference(client, base, "allow-new-accounts", "true") != "false"
val trustedSenders = loadTrustedSenders(client, base)
val textBlocks = loadTextBlocks(client, base)
return MailAppSettings(
showThreaded = showThreaded,
highlightExternalAddresses = highlightExternal,
allowNewAccounts = allowNewAccounts,
trustedSenders = trustedSenders,
textBlocks = textBlocks,
)
}
suspend fun savePreference(session: AuthSession, key: String, value: String): String {
val client = authedClient(session)
val payload = JSONObject().put("value", value).toString()
val request = Request.Builder()
.url("${apiBase(session)}/preferences/${java.net.URLEncoder.encode(key, "UTF-8")}")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.put(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val json = JSONObject(response.body!!.string())
return json.optString("value", value)
}
}
suspend fun removeTrustedSender(session: AuthSession, email: String, type: String) {
val client = authedClient(session)
val encodedEmail = java.net.URLEncoder.encode(email, "UTF-8")
val encodedType = java.net.URLEncoder.encode(type, "UTF-8")
val request = Request.Builder()
.url("${apiBase(session)}/trustedsenders/$encodedEmail?type=$encodedType")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun createTextBlock(session: AuthSession, title: String, content: String): MailTextBlock {
val client = authedClient(session)
val payload = JSONObject()
.put("title", title)
.put("content", content)
.toString()
val request = Request.Builder()
.url("${apiBase(session)}/textBlocks")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.post(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val json = unwrapData(JSONObject(response.body!!.string()))
return parseTextBlock(json as JSONObject)
}
}
suspend fun deleteTextBlock(session: AuthSession, id: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/textBlocks/$id")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun loadAccountSettings(session: AuthSession, accountId: Int): MailAccountSettings {
val client = authedClient(session)
val json = getJson(client, "${apiBase(session)}/accounts/$accountId") as JSONObject
return parseAccountSettings(json)
}
suspend fun patchAccountSettings(
session: AuthSession,
accountId: Int,
draftsMailboxId: Int? = null,
sentMailboxId: Int? = null,
trashMailboxId: Int? = null,
archiveMailboxId: Int? = null,
junkMailboxId: Int? = null,
searchBody: Boolean? = null,
classificationEnabled: Boolean? = null,
signatureAboveQuote: Boolean? = null,
imipCreate: Boolean? = null,
): MailAccountSettings {
val payload = JSONObject()
draftsMailboxId?.let { payload.put("draftsMailboxId", it) }
sentMailboxId?.let { payload.put("sentMailboxId", it) }
trashMailboxId?.let { payload.put("trashMailboxId", it) }
archiveMailboxId?.let { payload.put("archiveMailboxId", it) }
junkMailboxId?.let { payload.put("junkMailboxId", it) }
searchBody?.let { payload.put("searchBody", it) }
classificationEnabled?.let { payload.put("classificationEnabled", it) }
signatureAboveQuote?.let { payload.put("signatureAboveQuote", it) }
imipCreate?.let { payload.put("imipCreate", it) }
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/accounts/$accountId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.patch(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val body = response.body!!.string()
return parseAccountSettings(JSONObject(body))
}
}
suspend fun updateAccountSignature(session: AuthSession, accountId: Int, signature: String) {
val payload = JSONObject().put("signature", signature).toString()
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/accounts/$accountId/signature")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.put(payload.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun loadOutboxMessages(session: AuthSession): List<MailOutboxMessage> {
val client = authedClient(session)
val json = getJson(client, "${apiBase(session)}/outbox")
return parseOutboxMessages(json)
}
suspend fun sendOutboxMessage(session: AuthSession, messageId: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/outbox/$messageId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось отправить (HTTP ${response.code})")
}
}
suspend fun deleteOutboxMessage(session: AuthSession, messageId: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/outbox/$messageId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось удалить (HTTP ${response.code})")
}
}
suspend fun setMessageFlags(
session: AuthSession,
messageId: Int,
seen: Boolean? = null,
flagged: Boolean? = null,
important: Boolean? = null,
) {
val flags = JSONObject()
seen?.let { flags.put("seen", it) }
flagged?.let { flags.put("flagged", it) }
important?.let { flags.put("important", it) }
if (flags.length() == 0) return
val body = JSONObject().put("flags", flags).toString()
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId/flags")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.put(body.toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun moveMessage(session: AuthSession, messageId: Int, destMailboxId: Int) {
val client = authedClient(session)
val url = "${apiBase(session)}/messages/$messageId/move?destFolderId=$destMailboxId"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось переместить (HTTP ${response.code})")
}
}
suspend fun snoozeMessage(
session: AuthSession,
messageId: Int,
unixTimestamp: Long,
destMailboxId: Int,
) {
val client = authedClient(session)
val url = "${apiBase(session)}/messages/$messageId/snooze" +
"?unixTimestamp=$unixTimestamp&destMailboxId=$destMailboxId"
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось отложить (HTTP ${response.code})")
}
}
suspend fun setMessageTag(session: AuthSession, messageId: Int, imapLabel: String, add: Boolean) {
val client = authedClient(session)
val encoded = java.net.URLEncoder.encode(imapLabel, Charsets.UTF_8.name())
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId/tags/$encoded")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.let { builder ->
if (add) {
builder.put("".toRequestBody("application/json".toMediaType()))
} else {
builder.delete()
}
}
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Не удалось изменить метку (HTTP ${response.code})")
}
}
suspend fun deleteMessage(session: AuthSession, messageId: Int) {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId")
.header("OCS-APIRequest", "true")
.delete()
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) error("Mail API HTTP ${response.code}")
}
}
suspend fun downloadAttachment(
session: AuthSession,
messageId: Int,
attachmentId: String,
): ByteArray {
val client = authedClient(session)
val request = Request.Builder()
.url("${apiBase(session)}/messages/$messageId/attachment/$attachmentId")
.header("OCS-APIRequest", "true")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Вложение HTTP ${response.code}")
}
return response.body!!.bytes()
}
}
fun composeUrl(session: AuthSession): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/compose"
}
suspend fun uploadLocalAttachment(
session: AuthSession,
fileName: String,
bytes: ByteArray,
mimeType: String,
): MailComposeAttachment {
val client = authedClient(session)
val mediaType = mimeType.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType()
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"attachment",
fileName,
bytes.toRequestBody(mediaType),
)
.build()
val request = Request.Builder()
.url("${apiBase(session)}/attachments")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post(body)
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить вложение (HTTP ${response.code})")
}
val json = JSONObject(response.body!!.string())
val data = json.optJSONObject("data") ?: json
val id = data.optInt("id", 0)
if (id <= 0) error("Не удалось загрузить вложение")
return MailComposeAttachment(
id = id,
fileName = data.optString("fileName", fileName),
mimeType = data.optString("mimeType", mimeType),
)
}
}
suspend fun createAndSendMessage(
session: AuthSession,
accountId: Int,
to: List<MailRecipient>,
cc: List<MailRecipient>,
bcc: List<MailRecipient>,
subject: String,
bodyHtml: String,
bodyPlain: String,
editorBody: String,
requestMdn: Boolean,
sendAt: Int?,
attachments: List<MailComposeAttachment> = emptyList(),
) {
val client = authedClient(session)
val payload = JSONObject().apply {
put("accountId", accountId)
put("subject", subject)
put("bodyPlain", bodyPlain)
put("bodyHtml", bodyHtml)
put("editorBody", editorBody)
put("isHtml", true)
put("smimeSign", false)
put("smimeEncrypt", false)
put("requestMdn", requestMdn)
put("isPgpMime", false)
put("to", recipientsJson(to))
put("cc", recipientsJson(cc))
put("bcc", recipientsJson(bcc))
put("attachments", attachmentsJson(attachments))
if (sendAt != null) put("sendAt", sendAt)
}
val createRequest = Request.Builder()
.url("${apiBase(session)}/outbox")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
val messageId = client.newCall(createRequest).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Не удалось создать письмо (HTTP ${response.code})")
}
val body = response.body!!.string()
val json = JSONObject(body)
val data = json.optJSONObject("data") ?: json
data.optInt("id", data.optInt("databaseId", 0))
}
if (messageId <= 0) error("Не удалось создать письмо")
val sendRequest = Request.Builder()
.url("${apiBase(session)}/outbox/$messageId")
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.post("".toRequestBody("application/json".toMediaType()))
.build()
client.newCall(sendRequest).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful) {
error("Не удалось отправить письмо (HTTP ${response.code})")
}
}
}
private fun attachmentsJson(attachments: List<MailComposeAttachment>): JSONArray {
val array = JSONArray()
attachments.forEach { attachment ->
array.put(
when (attachment.type) {
MailComposeAttachmentType.LOCAL -> JSONObject().apply {
put("type", "local")
put("id", attachment.id)
}
MailComposeAttachmentType.CLOUD -> JSONObject().apply {
put("type", "cloud")
put(
"fileName",
attachment.cloudPath ?: "/${attachment.fileName.trim('/')}",
)
attachment.size?.let { put("size", it) }
}
},
)
}
return array
}
private fun recipientsJson(recipients: List<MailRecipient>): JSONArray {
val array = JSONArray()
recipients.forEach { recipient ->
array.put(
JSONObject().apply {
put("email", recipient.email)
put("label", recipient.label.ifBlank { recipient.email })
},
)
}
return array
}
fun parseRecipients(raw: String): List<MailRecipient> {
if (raw.isBlank()) return emptyList()
return raw.split(',', ';')
.map { it.trim() }
.filter { it.isNotBlank() }
.map { token ->
val match = Regex("<([^>]+)>").find(token)
if (match != null) {
val email = match.groupValues[1].trim()
val label = token.replace(match.value, "").trim().trim('"')
MailRecipient(email = email, label = label.ifBlank { email })
} else {
MailRecipient(email = token, label = token)
}
}
}
fun replyUrl(session: AuthSession, mailboxId: Int, messageId: Int): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/box/$mailboxId/thread/$messageId"
}
private fun loadPreference(client: OkHttpClient, base: String, key: String, default: String): String {
val json = getJson(client, "$base/preferences/${java.net.URLEncoder.encode(key, "UTF-8")}") as JSONObject
return json.optString("value", default)
}
private fun loadTrustedSenders(client: OkHttpClient, base: String): List<MailTrustedSender> {
return runCatching {
val data = unwrapData(getJson(client, "$base/trustedsenders"))
parseTrustedSenders(data)
}.getOrDefault(emptyList())
}
private fun loadTextBlocks(client: OkHttpClient, base: String): List<MailTextBlock> {
return runCatching {
val data = unwrapData(getJson(client, "$base/textBlocks"))
parseTextBlocks(data)
}.getOrDefault(emptyList())
}
private fun unwrapData(json: Any): Any {
if (json is JSONObject && json.optString("status") == "success") {
return json.opt("data") ?: json
}
return json
}
private fun parseTrustedSenders(data: Any): List<MailTrustedSender> {
val array = when (data) {
is JSONArray -> data
is JSONObject -> data.optJSONArray("data") ?: JSONArray()
else -> JSONArray()
}
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val email = obj.optString("email")
if (email.isBlank()) continue
add(
MailTrustedSender(
id = obj.optInt("id"),
email = email,
type = obj.optString("type", "individual"),
),
)
}
}.sortedBy { it.email.lowercase() }
}
private fun parseTextBlocks(data: Any): List<MailTextBlock> {
val array = when (data) {
is JSONArray -> data
is JSONObject -> data.optJSONArray("data") ?: JSONArray()
else -> JSONArray()
}
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optInt("id")
if (id <= 0) continue
add(parseTextBlock(obj))
}
}.sortedBy { it.title.lowercase() }
}
private fun parseTextBlock(obj: JSONObject): MailTextBlock {
return MailTextBlock(
id = obj.optInt("id"),
title = obj.optString("title"),
content = obj.optString("content"),
preview = obj.optString("preview"),
)
}
private fun authedClient(session: AuthSession): OkHttpClient {
return NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
private fun apiBase(session: AuthSession): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/mail/api"
}
private fun buildFilterQuery(
filter: MailListFilter,
search: String,
searchParams: MailSearchParams = MailSearchParams(),
): String {
val parts = mutableListOf<String>()
when (filter) {
MailListFilter.UNREAD -> parts += "is:unread"
MailListFilter.STARRED -> parts += "is:starred"
MailListFilter.ALL -> Unit
}
val advanced = searchParams.toFilterString()
if (advanced.isNotBlank()) parts += advanced
if (search.isNotBlank()) parts += search.trim()
return parts.joinToString(" ")
}
private fun buildFolderList(account: MailAccount, mailboxes: List<MailMailbox>): List<MailFolderEntry> {
val byId = mailboxes.associateBy { it.id }
val result = mutableListOf<MailFolderEntry>()
fun add(mailbox: MailMailbox?, title: String, role: String?, filter: MailListFilter = MailListFilter.ALL) {
if (mailbox == null) return
result += MailFolderEntry(
mailboxId = mailbox.id,
accountId = account.id,
title = title,
specialRole = role,
filter = filter,
unread = mailbox.unread,
)
}
fun mailboxForRole(role: String, configuredId: Int?): MailMailbox? {
mailboxes.firstOrNull { it.specialRole == role }?.let { return it }
val configured = configuredId?.let { byId[it] }
if (configured != null && (role != "sent" || configured.specialRole != "inbox" && !configured.isInbox)) {
return configured
}
return null
}
val inbox = mailboxes.firstOrNull { it.isInbox || it.specialRole == "inbox" }
add(inbox, "Входящие", "inbox")
if (inbox != null) {
add(inbox, "Непрочитанные", "inbox", MailListFilter.UNREAD)
add(inbox, "Избранное", "inbox", MailListFilter.STARRED)
}
add(mailboxForRole("sent", account.sentMailboxId), "Отправленные", "sent")
add(mailboxForRole("drafts", account.draftsMailboxId), "Черновики", "drafts")
add(mailboxForRole("archive", account.archiveMailboxId), "Архив", "archive")
add(mailboxForRole("trash", account.trashMailboxId), "Корзина", "trash")
add(mailboxForRole("junk", account.junkMailboxId), "Спам", "junk")
val usedIds = result.map { it.mailboxId }.toSet()
mailboxes.filter { it.id !in usedIds && !it.isInbox && it.specialRole.isNullOrBlank() }
.sortedBy { it.displayName.lowercase() }
.forEach { add(it, it.displayName, null) }
return result
}
private fun parseAccounts(array: JSONArray): List<MailAccount> {
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optInt("accountId", obj.optInt("id", 0))
if (id <= 0) continue
add(
MailAccount(
id = id,
email = obj.optString("emailAddress").ifBlank { obj.optString("email") },
name = obj.optString("name"),
draftsMailboxId = obj.optInt("draftsMailboxId").takeIf { it > 0 },
sentMailboxId = obj.optInt("sentMailboxId").takeIf { it > 0 },
trashMailboxId = obj.optInt("trashMailboxId").takeIf { it > 0 },
archiveMailboxId = obj.optInt("archiveMailboxId").takeIf { it > 0 },
junkMailboxId = obj.optInt("junkMailboxId").takeIf { it > 0 },
snoozeMailboxId = obj.optInt("snoozeMailboxId").takeIf { it > 0 },
),
)
}
}
}
private fun getJsonArray(client: OkHttpClient, url: String): JSONArray {
return when (val json = getJson(client, url)) {
is JSONArray -> json
else -> JSONArray()
}
}
private fun getJson(client: OkHttpClient, url: String): Any {
val request = Request.Builder()
.url(url)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (response.code == 412) error("Mail API: CSRF — обновите приложение")
if (!response.isSuccessful || response.body == null) {
error("Mail API HTTP ${response.code}")
}
val body = response.body!!.string().trim()
if (body.startsWith("<?xml", ignoreCase = true)) {
error("Mail API вернул XML — проверьте, что «Почта» включена на сервере")
}
if (body.startsWith("[")) return JSONArray(body)
if (body.startsWith("{")) return JSONObject(body)
error("Mail API: неожиданный ответ")
}
}
private fun parseMailboxes(data: Any): List<MailMailbox> {
val out = mutableListOf<MailMailbox>()
fun addFromObject(obj: JSONObject) {
val id = obj.optInt("databaseId", obj.optInt("id", 0))
if (id <= 0) return
val name = obj.optString("name")
val displayName = obj.optString("displayName").ifBlank { name }
val special = obj.optJSONArray("specialUse")
var specialRole = obj.optString("specialRole").ifBlank { null }
if (specialRole == null && special != null && special.length() > 0) {
specialRole = special.optString(0).removePrefix("\\").lowercase()
}
var isInbox = specialRole == "inbox"
if (!isInbox && special != null) {
for (i in 0 until special.length()) {
if (special.optString(i).contains("inbox", ignoreCase = true)) {
isInbox = true
specialRole = "inbox"
break
}
}
}
if (!isInbox && name.equals("INBOX", ignoreCase = true)) {
isInbox = true
specialRole = "inbox"
}
out += MailMailbox(
id = id,
accountId = obj.optInt("accountId"),
displayName = displayName.ifBlank { "Mailbox" },
name = name,
specialRole = specialRole,
unread = obj.optInt("unread", 0),
isInbox = isInbox,
)
obj.optJSONArray("mailboxes")?.let { nested ->
for (i in 0 until nested.length()) {
nested.optJSONObject(i)?.let { addFromObject(it) }
}
}
}
when (data) {
is JSONArray -> for (i in 0 until data.length()) {
data.optJSONObject(i)?.let { addFromObject(it) }
}
is JSONObject -> {
val mailboxes = data.optJSONArray("mailboxes")
if (mailboxes != null) {
for (i in 0 until mailboxes.length()) {
mailboxes.optJSONObject(i)?.let { addFromObject(it) }
}
} else if (data.has("databaseId")) {
addFromObject(data)
}
}
}
return out
}
private fun parseMessages(data: Any): List<MailMessage> {
val out = mutableListOf<MailMessage>()
fun addFromObject(obj: JSONObject) {
val id = obj.optInt("databaseId", obj.optInt("uid", 0))
if (id <= 0) return
val fromLabel = parseAddressLabel(obj.opt("from"))
val fromEmail = parseAddressEmail(obj.opt("from"))
out += MailMessage(
id = id,
subject = obj.optString("subject").ifBlank { "(без темы)" },
from = fromLabel.ifBlank { "Unknown" },
fromEmail = fromEmail,
preview = obj.optString("previewText").ifBlank { obj.optString("summary") },
dateInt = obj.optLong("dateInt", 0L),
flags = parseFlags(obj.optJSONObject("flags")),
tags = parseTags(obj.optJSONObject("tags")),
)
}
when (data) {
is JSONArray -> for (i in 0 until data.length()) {
data.optJSONObject(i)?.let { addFromObject(it) }
}
is JSONObject -> {
val keys = data.keys()
while (keys.hasNext()) {
data.optJSONObject(keys.next())?.let { addFromObject(it) }
}
}
}
return out.sortedByDescending { it.dateInt }
}
private fun parseTags(tags: JSONObject?): List<MailTag> {
if (tags == null) return emptyList()
return buildList {
val keys = tags.keys()
while (keys.hasNext()) {
val imapLabel = keys.next()
val tagObj = tags.optJSONObject(imapLabel) ?: continue
val name = tagObj.optString("displayName").trim()
if (name.isEmpty()) continue
add(
MailTag(
id = tagObj.optLong("id"),
displayName = name,
colorHex = tagObj.optString("color"),
imapLabel = imapLabel,
),
)
}
}
}
private fun parseFlags(flags: JSONObject?): MailMessageFlags {
return MailMessageFlags(
seen = flags?.optBoolean("seen", true) ?: true,
flagged = flags?.optBoolean("flagged", false) ?: false,
hasAttachments = flags?.optBoolean("hasAttachments", false) ?: false,
answered = flags?.optBoolean("answered", false) ?: false,
important = flags?.optBoolean("important", false) ?: false,
)
}
private fun parseAttachments(array: JSONArray?, serverUrl: String): List<MailAttachment> {
if (array == null) return emptyList()
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optString("id").ifBlank { obj.optInt("id", 0).toString() }
if (id == "0") continue
add(
MailAttachment(
id = id,
fileName = obj.optString("fileName").ifBlank { "attachment" },
mime = obj.optString("mime").ifBlank { "application/octet-stream" },
size = obj.optLong("size", 0L),
cid = obj.optString("cid").ifBlank { null },
downloadUrl = obj.optString("downloadUrl").ifBlank { null },
),
)
}
}
}
private fun parseAddressEmail(value: Any?): String {
when (value) {
is JSONObject -> return value.optString("email")
is JSONArray -> return value.optJSONObject(0)?.optString("email").orEmpty()
}
return ""
}
private fun parseAddressLabel(value: Any?): String {
when (value) {
is JSONObject -> return value.optString("label").ifBlank { value.optString("email") }
is JSONArray -> {
if (value.length() == 0) return ""
val first = value.optJSONObject(0) ?: return ""
return first.optString("label").ifBlank { first.optString("email") }
}
}
return ""
}
private fun parseAddressList(value: Any?): String {
if (value !is JSONArray) return ""
return buildList {
for (i in 0 until value.length()) {
val label = parseAddressLabel(value.opt(i))
if (label.isNotBlank()) add(label)
}
}.joinToString(", ")
}
private fun parseAccountSettings(json: JSONObject): MailAccountSettings {
val data = json.optJSONObject("data") ?: json
return MailAccountSettings(
id = data.optInt("accountId", data.optInt("id")),
email = data.optString("emailAddress").ifBlank { data.optString("email") },
name = data.optString("name"),
signature = data.optString("signature"),
draftsMailboxId = data.optInt("draftsMailboxId").takeIf { it > 0 },
sentMailboxId = data.optInt("sentMailboxId").takeIf { it > 0 },
trashMailboxId = data.optInt("trashMailboxId").takeIf { it > 0 },
archiveMailboxId = data.optInt("archiveMailboxId").takeIf { it > 0 },
junkMailboxId = data.optInt("junkMailboxId").takeIf { it > 0 },
searchBody = data.optBoolean("searchBody", false),
classificationEnabled = data.optBoolean("classificationEnabled", false),
signatureAboveQuote = data.optBoolean("signatureAboveQuote", false),
imipCreate = data.optBoolean("imipCreate", false),
quotaPercentage = data.optInt("quotaPercentage").takeIf { it > 0 },
imapHost = data.optString("imapHost").ifBlank { null },
smtpHost = data.optString("smtpHost").ifBlank { null },
)
}
private fun parseOutboxMessages(data: Any): List<MailOutboxMessage> {
val array = when (data) {
is JSONObject -> {
val root = data.optJSONObject("data") ?: data
root.optJSONArray("messages") ?: JSONArray()
}
is JSONArray -> data
else -> JSONArray()
}
return buildList {
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val id = obj.optInt("id")
if (id <= 0) continue
if (obj.optInt("status") == 12) continue
val preview = obj.optString("bodyPlain")
.ifBlank { obj.optString("editorBody") }
.ifBlank { obj.optString("bodyHtml") }
.replace(Regex("<[^>]+>"), " ")
.trim()
add(
MailOutboxMessage(
id = id,
accountId = obj.optInt("accountId"),
subject = obj.optString("subject").ifBlank { "(без темы)" },
toRecipients = parseAddressList(obj.opt("to")),
preview = preview,
updatedAt = obj.optLong("updatedAt"),
sendAt = obj.optLong("sendAt").takeIf { it > 0 },
failed = obj.optBoolean("failed"),
status = obj.optInt("status"),
),
)
}
}.sortedByDescending { it.updatedAt }
}
companion object {
const val PAGE_SIZE = 20
}
}
@@ -0,0 +1,159 @@
package ru.forbion.f7cloud.feature.mail
import android.annotation.SuppressLint
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class MailRichTextEditorController {
internal var webView: WebView? = null
private var ready = false
private val pending = mutableListOf<() -> Unit>()
fun exec(command: String, value: String? = null) {
val script = if (value == null) {
"document.execCommand('$command', false, null);"
} else {
"document.execCommand('$command', false, ${jsString(value)});"
}
runWhenReady { webView?.evaluateJavascript(script, null) }
}
fun focus() {
runWhenReady {
webView?.evaluateJavascript("document.getElementById('editor').focus();", null)
}
}
fun setHtml(html: String) {
if (html.isBlank()) return
val encoded = java.net.URLEncoder.encode(html, Charsets.UTF_8.name())
.replace("'", "\\'")
runWhenReady {
webView?.evaluateJavascript(
"document.getElementById('editor').innerHTML = decodeURIComponent('$encoded');",
null,
)
}
}
fun insertHtml(html: String) {
if (html.isBlank()) return
val encoded = java.net.URLEncoder.encode(html, Charsets.UTF_8.name())
.replace("'", "\\'")
runWhenReady {
webView?.evaluateJavascript(
"""
(function() {
var html = decodeURIComponent('$encoded');
var editor = document.getElementById('editor');
var sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
editor.innerHTML += html;
return;
}
var range = sel.getRangeAt(0);
range.deleteContents();
var template = document.createElement('template');
template.innerHTML = html;
range.insertNode(template.content);
})();
""".trimIndent(),
null,
)
}
}
suspend fun html(): String = query("document.getElementById('editor').innerHTML")
suspend fun plainText(): String = query("document.getElementById('editor').innerText")
internal fun markReady() {
ready = true
val actions = pending.toList()
pending.clear()
actions.forEach { it() }
}
private fun runWhenReady(action: () -> Unit) {
if (ready) action() else pending += action
}
private suspend fun query(script: String): String = suspendCancellableCoroutine { cont ->
runWhenReady {
webView?.evaluateJavascript(script) { value ->
cont.resume(value?.trim('"')?.replace("\\n", "\n")?.replace("\\\"", "\"") ?: "")
}
}
}
private fun jsString(value: String): String =
"'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun MailRichTextEditor(
modifier: Modifier = Modifier,
controller: MailRichTextEditorController = remember { MailRichTextEditorController() },
) {
val html = remember {
"""
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #fff; }
#editor {
min-height: 100%;
box-sizing: border-box;
padding: 12px;
outline: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 16px;
line-height: 1.45;
color: #151515;
word-break: break-word;
}
</style>
</head>
<body><div id="editor" contenteditable="true"></div></body>
</html>
""".trimIndent()
}
AndroidView(
modifier = modifier,
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
setBackgroundColor(0xFFFFFFFF.toInt())
addJavascriptInterface(
object {
@JavascriptInterface
fun onReady() {
post { controller.markReady() }
}
},
"AndroidEditor",
)
webViewClient = object : android.webkit.WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
evaluateJavascript("AndroidEditor.onReady();", null)
}
}
controller.webView = this
loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
},
onRelease = {
controller.webView = null
},
)
}
@@ -0,0 +1,943 @@
package ru.forbion.f7cloud.feature.mail
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import androidx.activity.compose.BackHandler
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.feature.files.LocalFileOpener
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File
@Composable
fun MailScreen(
session: AuthSession,
modifier: Modifier = Modifier,
openMessageId: Int? = null,
openMailboxId: Int? = null,
pushRefreshRequest: Int = 0,
sidebarOpen: Boolean = false,
onSidebarOpenChange: (Boolean) -> Unit = {},
settingsOpen: Boolean = false,
onSettingsOpenChange: (Boolean) -> Unit = {},
onOpenMessageConsumed: () -> Unit = {},
onMessageOpenStateChange: (Boolean) -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val context = LocalContext.current
val cacheRepository = remember { MailCacheRepository(context) }
val vm: MailViewModel = viewModel(
factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
MailViewModel(cacheRepository = cacheRepository) as T
},
)
val state by vm.state.collectAsState()
val httpClient = remember(session.username, session.appPassword, session.trustAllCerts) {
NetworkFactory.newAuthedClient(
session.username,
session.appPassword,
session.trustAllCerts,
)
}
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(pushRefreshRequest) {
if (pushRefreshRequest > 0) {
vm.load(session, forceRefresh = true)
}
}
LaunchedEffect(openMessageId) {
val messageId = openMessageId ?: return@LaunchedEffect
vm.openDeepLink(session, messageId, openMailboxId)
onOpenMessageConsumed()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
LaunchedEffect(state.selectedMessageId) {
onMessageOpenStateChange(state.selectedMessageId != null)
}
val composeLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
vm.load(session, forceRefresh = true)
}
val openCompose: () -> Unit = {
val account = state.selectedFolder?.let { folder ->
state.accounts.find { it.id == folder.accountId }
} ?: state.accounts.firstOrNull()
if (account == null || account.id <= 0 || account.email.isBlank()) {
Toast.makeText(context, "Учётная запись почты не найдена", Toast.LENGTH_SHORT).show()
} else {
composeLauncher.launch(
MailComposeActivity.intent(
context,
MailComposeLaunch(
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
accountId = account.id,
accountEmail = account.email,
accountName = account.name,
mailboxId = state.selectedFolder?.mailboxId,
),
),
)
}
}
val openComposeForMessageWithSchedule: (
MailComposeMode,
MailMessageDetail,
MailSendLaterPreset,
Long?,
) -> Unit = { mode, detail, sendPreset, customSendAtEpochSeconds ->
val account = state.selectedFolder?.let { folder ->
state.accounts.find { it.id == folder.accountId }
} ?: state.accounts.firstOrNull()
if (account == null || account.id <= 0 || account.email.isBlank()) {
Toast.makeText(context, "Учётная запись почты не найдена", Toast.LENGTH_SHORT).show()
} else {
val base = MailComposeLaunch(
username = session.username,
password = session.appPassword,
serverUrl = session.serverUrl,
trustAllCerts = session.trustAllCerts,
accountId = account.id,
accountEmail = account.email,
accountName = account.name,
mailboxId = state.selectedFolder?.mailboxId,
initialSendPreset = sendPreset,
initialCustomSendAtEpochSeconds = customSendAtEpochSeconds,
)
composeLauncher.launch(
MailComposeActivity.intent(
context,
MailReplyHelper.buildComposeLaunch(base, detail, mode, account.email),
),
)
}
}
val openComposeForMessage: (MailComposeMode, MailMessageDetail) -> Unit = { mode, detail ->
openComposeForMessageWithSchedule(mode, detail, MailSendLaterPreset.NOW, null)
}
val openScheduledReplyForMessage: (MailMessageDetail, MailSendLaterOption) -> Unit = { detail, option ->
if (option.preset != MailSendLaterPreset.NOW) {
openComposeForMessageWithSchedule(
MailComposeMode.REPLY,
detail,
option.preset,
option.sendAtEpochSeconds,
)
}
}
var searchFilterOpen by remember { mutableStateOf(false) }
var moveSheetOpen by remember { mutableStateOf(false) }
var tagsSheetOpen by remember { mutableStateOf(false) }
var snoozeSheetOpen by remember { mutableStateOf(false) }
val mailBackState = MailBackStackState(
searchFilterOpen = searchFilterOpen,
snoozeSheetOpen = snoozeSheetOpen,
tagsSheetOpen = tagsSheetOpen,
moveSheetOpen = moveSheetOpen,
settingsOpen = settingsOpen,
editingAccountId = state.editingAccountId,
sidebarOpen = sidebarOpen,
messageOpen = state.selectedMessageId != null,
)
val mailCanGoBack = mailBackState.canGoBack()
val mailNavigateBack: () -> Boolean = {
navigateMailBack(
state = mailBackState,
closeSearchFilter = { searchFilterOpen = false },
closeSnoozeSheet = { snoozeSheetOpen = false },
closeTagsSheet = { tagsSheetOpen = false },
closeMoveSheet = { moveSheetOpen = false },
closeAccountSettings = { vm.closeAccountSettings() },
closeSettings = {
vm.closeAccountSettings()
onSettingsOpenChange(false)
},
closeSidebar = { onSidebarOpenChange(false) },
closeMessage = { vm.closeMessage() },
)
}
MailSettingsSheet(
visible = settingsOpen,
serverUrl = session.serverUrl,
accounts = state.accounts,
mailboxes = state.mailboxes,
appSettings = state.appSettings,
appSettingsLoading = state.appSettingsLoading,
appSettingsSaving = state.appSettingsSaving,
editingAccountId = state.editingAccountId,
accountSettings = state.accountSettings,
accountSettingsLoading = state.accountSettingsLoading,
accountSettingsSaving = state.accountSettingsSaving,
onLoadAppSettings = { vm.loadAppSettings(session) },
onAccountClick = { vm.openAccountSettings(session, it) },
onAccountSettingsBack = { vm.closeAccountSettings() },
onSaveAccountSettings = { vm.saveAccountSettings(session, it) },
onShowThreadedChange = { vm.setShowThreaded(session, it) },
onHighlightExternalChange = { vm.setHighlightExternalAddresses(session, it) },
onRemoveTrustedSender = { vm.removeTrustedSender(session, it) },
onCreateTextBlock = { title, content -> vm.createTextBlock(session, title, content) },
onDeleteTextBlock = { vm.deleteTextBlock(session, it) },
onDismiss = {
vm.closeAccountSettings()
onSettingsOpenChange(false)
},
onSwipeBack = { mailNavigateBack() },
)
BackHandler(enabled = mailCanGoBack) {
mailNavigateBack()
}
F7OverlayDismissHandler(
enabled = mailCanGoBack,
onDismiss = { mailNavigateBack() },
)
MailSearchParamsSheet(
visible = searchFilterOpen,
params = state.searchParams,
onDismiss = { searchFilterOpen = false },
onSearch = { vm.applySearchParams(session, it) },
onClear = { vm.clearSearchParams(session) },
)
Box(
modifier = modifier
.fillMaxSize()
.f7SwipeFromRightToDismiss(
enabled = mailCanGoBack,
onDismiss = { mailNavigateBack() },
),
) {
if (state.selectedMessageId != null) {
val messageAccountId = state.selectedFolder?.accountId
?: state.accounts.firstOrNull()?.id
val moveMailboxes = remember(state.mailboxes, messageAccountId) {
state.mailboxes.filter { mailbox ->
messageAccountId == null || mailbox.accountId == messageAccountId
}
}
val availableTags = remember(state.messages) {
state.messages
.flatMap { it.tags }
.filter { it.imapLabel.isNotBlank() }
.distinctBy { it.imapLabel }
}
val snoozeOptions = remember { MailComposeSchedule.options() }
Box(
modifier = Modifier
.fillMaxSize()
.f7SwipeFromRightToDismiss(
enabled = mailCanGoBack,
onDismiss = { mailNavigateBack() },
),
) {
val detail = state.messageDetail
val htmlBodyReady = detail != null && detail.looksLikeHtml() && detail.htmlBodyForDisplay().isNotBlank()
Column(modifier = Modifier.fillMaxSize()) {
if (!state.error.isNullOrBlank()) {
Text(
text = state.error.orEmpty(),
color = F7Colors.Error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
if (htmlBodyReady) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
) {
MailMessageDetailHeader(
detail = detail,
threadMessages = state.threadMessages,
threadExpanded = state.threadExpanded,
session = session,
messageAccountId = messageAccountId,
vm = vm,
openComposeForMessage = openComposeForMessage,
onTagsSheetOpen = { tagsSheetOpen = true },
onMoveSheetOpen = { moveSheetOpen = true },
onSnoozeSheetOpen = { snoozeSheetOpen = true },
closeMessage = { mailNavigateBack() },
)
MailMessageDetailView(
session = session,
detail = detail,
loading = state.messageLoading,
httpClient = httpClient,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
showHeader = false,
htmlBodyExternal = true,
onAttachmentClick = { attachment ->
val messageId = detail.id
vm.downloadAttachment(session, messageId, attachment) { result ->
result.onSuccess { bytes ->
val safeName = attachment.fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val file = File(context.cacheDir, "mail_$safeName")
file.writeBytes(bytes)
LocalFileOpener.openExternal(context, file, attachment.mime)
}.onFailure {
Toast.makeText(context, it.message ?: "Ошибка загрузки", Toast.LENGTH_SHORT).show()
}
}
},
)
}
key(detail.id) {
MailMessageBodyView(
session = session,
messageId = detail.id,
html = detail.htmlBodyForDisplay(),
attachments = detail.attachments,
client = httpClient,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
} else {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
) {
MailMessageDetailHeader(
detail = detail,
threadMessages = state.threadMessages,
threadExpanded = state.threadExpanded,
session = session,
messageAccountId = messageAccountId,
vm = vm,
openComposeForMessage = openComposeForMessage,
onTagsSheetOpen = { tagsSheetOpen = true },
onMoveSheetOpen = { moveSheetOpen = true },
onSnoozeSheetOpen = { snoozeSheetOpen = true },
closeMessage = { mailNavigateBack() },
)
MailMessageDetailView(
session = session,
detail = detail,
loading = state.messageLoading,
httpClient = httpClient,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
showHeader = false,
htmlBodyExternal = false,
onAttachmentClick = { attachment ->
val messageId = detail?.id ?: return@MailMessageDetailView
vm.downloadAttachment(session, messageId, attachment) { result ->
result.onSuccess { bytes ->
val safeName = attachment.fileName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
val file = File(context.cacheDir, "mail_$safeName")
file.writeBytes(bytes)
LocalFileOpener.openExternal(context, file, attachment.mime)
}.onFailure {
Toast.makeText(context, it.message ?: "Ошибка загрузки", Toast.LENGTH_SHORT).show()
}
}
},
)
Spacer(Modifier.height(120.dp))
}
}
}
// Зона свайпа справа — WebView не отдаёт жесты Compose, поэтому ловим на краю поверх.
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(24.dp)
.f7SwipeFromRightToDismiss(
enabled = mailCanGoBack,
edgeFraction = 1f,
onDismiss = { mailNavigateBack() },
),
)
state.messageDetail?.let { detail ->
MailReplyFab(
serverUrl = session.serverUrl,
onClick = { openComposeForMessage(MailComposeMode.REPLY, detail) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 88.dp),
)
}
MailMoveMailboxSheet(
visible = moveSheetOpen,
mailboxes = moveMailboxes,
onDismiss = { moveSheetOpen = false },
onSelect = { mailbox ->
state.messageDetail?.let { detail ->
vm.moveMessage(session, detail.id, mailbox.id)
}
},
)
MailMessageTagsSheet(
visible = tagsSheetOpen,
availableTags = availableTags,
selectedTags = state.messageDetail?.tags.orEmpty(),
onDismiss = { tagsSheetOpen = false },
onToggle = { tag ->
val detail = state.messageDetail ?: return@MailMessageTagsSheet
val hasTag = detail.tags.any { it.imapLabel == tag.imapLabel }
vm.toggleMessageTag(session, detail.id, tag, add = !hasTag)
},
)
MailSnoozeSheet(
visible = snoozeSheetOpen,
options = snoozeOptions,
onDismiss = { snoozeSheetOpen = false },
onSelect = { option ->
val detail = state.messageDetail ?: return@MailSnoozeSheet
openScheduledReplyForMessage(detail, option)
},
)
}
} else {
MailInboxScreen(
session = session,
modifier = Modifier.fillMaxSize(),
selectedFolder = state.selectedFolder,
loading = state.loading && state.messages.isEmpty() && state.outboxMessages.isEmpty(),
loadingMore = state.loadingMore,
error = state.error,
searchQuery = state.searchQuery,
searchParams = state.searchParams,
isOutbox = state.selectedFolder?.specialRole == "outbox",
messages = state.messages,
outboxMessages = state.outboxMessages,
nextCursor = state.nextCursor,
onSearchChange = { vm.setSearchQuery(session, it) },
onQuickFilterToggle = { vm.toggleQuickFilter(session, it) },
onComposeClick = openCompose,
onFilterClick = { searchFilterOpen = true },
onMessageClick = { vm.openMessage(session, it) },
onOutboxRetry = { vm.retryOutboxMessage(session, it) },
onOutboxDelete = { vm.deleteOutboxMessage(session, it) },
onLoadMore = { vm.loadMore(session) },
)
}
MailNavigationSidebar(
serverUrl = session.serverUrl,
visible = sidebarOpen,
accounts = state.accounts,
folders = state.folders,
selected = state.selectedFolder,
collapsedAccountIds = state.collapsedAccountIds,
onDismiss = { onSidebarOpenChange(false) },
onComposeClick = {
onSidebarOpenChange(false)
openCompose()
},
onRefreshClick = { vm.load(session, forceRefresh = true) },
onFolderClick = {
vm.selectFolder(session, it)
onSidebarOpenChange(false)
},
onToggleAccountCollapsed = { vm.toggleAccountCollapsed(it) },
onPriorityInboxClick = {
state.folders.firstOrNull { it.specialRole == "inbox" && it.filter == MailListFilter.ALL }
?.let {
vm.selectFolder(session, it)
onSidebarOpenChange(false)
}
},
onOutboxClick = {
vm.selectOutbox(session)
onSidebarOpenChange(false)
},
)
}
}
@Composable
private fun MailInboxScreen(
session: AuthSession,
modifier: Modifier = Modifier,
selectedFolder: MailFolderEntry?,
loading: Boolean,
loadingMore: Boolean,
error: String?,
searchQuery: String,
searchParams: MailSearchParams,
isOutbox: Boolean,
messages: List<MailMessage>,
outboxMessages: List<MailOutboxMessage>,
nextCursor: Int?,
onSearchChange: (String) -> Unit,
onQuickFilterToggle: (MailQuickFilter) -> Unit,
onComposeClick: () -> Unit,
onFilterClick: () -> Unit,
onMessageClick: (Int) -> Unit,
onOutboxRetry: (Int) -> Unit,
onOutboxDelete: (Int) -> Unit,
onLoadMore: () -> Unit,
) {
val listItems = remember(messages) { buildMailInboxListItems(messages) }
val folderTitle = mailFolderListTitle(selectedFolder, searchQuery, searchParams)
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (!isOutbox) {
MailInboxToolbar(
serverUrl = session.serverUrl,
query = searchQuery,
onQueryChange = onSearchChange,
onComposeClick = onComposeClick,
onFilterClick = onFilterClick,
)
MailQuickFilterChips(
searchParams = searchParams,
onFilterToggle = onQuickFilterToggle,
)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Исходящие",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
if (outboxMessages.isNotEmpty()) {
Text(
"${outboxMessages.size}",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
)
}
}
}
if (!folderTitle.isNullOrBlank()) {
Text(
folderTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
}
if (!error.isNullOrBlank()) {
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
if (loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return@Box
}
if (isOutbox) {
if (outboxMessages.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text("Нет неотправленных писем", color = F7Colors.TextSecondary)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(outboxMessages, key = { it.id }) { message ->
MailOutboxRow(
message = message,
onRetry = { onOutboxRetry(message.id) },
onDelete = { onOutboxDelete(message.id) },
)
}
}
}
return@Box
}
if (messages.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
if (searchQuery.isNotBlank() || searchParams.isActive()) {
"Ничего не найдено"
} else {
"Нет писем"
},
color = F7Colors.TextSecondary,
)
}
} else {
val listState = rememberLazyListState()
LaunchedEffect(listState, messages.size, nextCursor, loadingMore) {
snapshotFlow {
val info = listState.layoutInfo
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0
val total = info.totalItemsCount
total > 0 && lastVisible >= total - 3
}
.distinctUntilChanged()
.collect { nearEnd ->
if (nearEnd && nextCursor != null && !loadingMore) {
onLoadMore()
}
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
listItems,
key = { item ->
when (item) {
is MailInboxListItem.YearHeader -> "year-${item.year}"
is MailInboxListItem.MessageItem -> item.message.id
}
},
) { item ->
when (item) {
is MailInboxListItem.YearHeader -> MailYearHeader(item.year)
is MailInboxListItem.MessageItem -> MailEnvelopeRow(
message = item.message,
serverUrl = session.serverUrl,
onClick = { onMessageClick(item.message.id) },
)
}
}
if (loadingMore) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.height(24.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
}
}
}
}
}
}
}
}
@Composable
private fun MailMessageDetailHeader(
detail: MailMessageDetail?,
threadMessages: List<MailMessage>,
threadExpanded: Boolean,
session: AuthSession,
messageAccountId: Int?,
vm: MailViewModel,
openComposeForMessage: (MailComposeMode, MailMessageDetail) -> Unit,
onTagsSheetOpen: () -> Unit,
onMoveSheetOpen: () -> Unit,
onSnoozeSheetOpen: () -> Unit,
closeMessage: () -> Unit,
) {
val message = detail ?: return
MailThreadSection(
currentMessageId = message.id,
threadMessages = threadMessages,
expanded = threadExpanded,
onToggleExpanded = { vm.toggleThreadExpanded() },
onMessageClick = { vm.openMessage(session, it) },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
MailMessageSenderCard(
fromName = message.from,
fromEmail = message.fromEmail,
subject = message.subject,
to = message.to,
cc = message.cc,
dateInt = message.dateInt,
flagged = message.flags.flagged,
seen = message.flags.seen,
important = message.flags.important,
serverUrl = session.serverUrl,
onReply = { openComposeForMessage(MailComposeMode.REPLY, message) },
onForward = { openComposeForMessage(MailComposeMode.FORWARD, message) },
onToggleStar = { vm.toggleStar(session, message.id) },
onMarkUnread = {
if (message.flags.seen) vm.toggleRead(session, message.id)
},
onDelete = {
vm.deleteMessage(session, message.id)
closeMessage()
},
onToggleImportant = { vm.toggleImportant(session, message.id) },
onMarkSpam = {
messageAccountId?.let { vm.markAsSpam(session, message.id, it) }
},
onEditTags = onTagsSheetOpen,
onMove = onMoveSheetOpen,
onSnooze = onSnoozeSheetOpen,
)
}
@Composable
private fun MailMessageDetailView(
session: AuthSession,
detail: MailMessageDetail?,
loading: Boolean,
httpClient: okhttp3.OkHttpClient,
modifier: Modifier = Modifier,
showHeader: Boolean = true,
htmlBodyExternal: Boolean = false,
onAttachmentClick: (MailAttachment) -> Unit,
) {
if (detail == null) {
if (!showHeader) return
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return
}
val bodyLoading = loading && !detail.hasBodyContent()
val embedded = !showHeader
Column(
modifier = modifier.then(
if (showHeader) Modifier.fillMaxSize() else Modifier.fillMaxWidth(),
),
) {
if (showHeader) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(F7Colors.Background)
.padding(bottom = 8.dp),
) {
Text(
detail.subject.ifBlank { "(без темы)" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
MailAvatar(name = detail.from, email = detail.fromEmail, modifier = Modifier.size(40.dp))
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"От:",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
Spacer(Modifier.width(6.dp))
Text(
detail.from.ifBlank { "Неизвестный" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
if (detail.to.isNotBlank()) {
Text(
"Кому: ${detail.to}",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
if (detail.cc.isNotBlank()) {
Text(
"Копия: ${detail.cc}",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
if (detail.dateInt > 0) {
Text(
formatMessageDate(detail.dateInt),
style = MaterialTheme.typography.labelSmall,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
if (detail.attachments.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
detail.attachments.forEach { attachment ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(F7Colors.SurfaceMuted)
.clickable { onAttachmentClick(attachment) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("📎", modifier = Modifier.padding(end = 8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
attachment.fileName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val sizeLabel = formatAttachmentSize(attachment.size)
if (sizeLabel.isNotBlank()) {
Text(sizeLabel, style = MaterialTheme.typography.labelSmall, color = F7Colors.TextSecondary)
}
}
}
Spacer(Modifier.height(4.dp))
}
}
if (bodyLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.height(28.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
}
} else if (!htmlBodyExternal) {
if (detail.looksLikeHtml() && detail.htmlBodyForDisplay().isNotBlank()) {
key(detail.id) {
MailMessageBodyView(
session = session,
messageId = detail.id,
html = detail.htmlBodyForDisplay(),
attachments = detail.attachments,
client = httpClient,
modifier = Modifier
.fillMaxWidth()
.then(if (showHeader) Modifier.weight(1f) else Modifier),
)
}
} else {
val plainBody = MailBodyHtml.normalizePlainForDisplay(
detail.plainBodyForDisplay().ifBlank { "(пустое письмо)" },
)
if (embedded) {
Text(
plainBody,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
style = MaterialTheme.typography.bodyMedium,
)
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(top = 8.dp),
) {
Text(plainBody, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}
@@ -0,0 +1,774 @@
package ru.forbion.f7cloud.feature.mail
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
import ru.forbion.f7cloud.core.designsystem.f7SwipeFromRightToDismiss
private val MailSettingsCard = Color(0xFFFDFDFD)
private val MailSettingsAccountCard = Color(0xFFE8F5E0)
@Composable
fun MailSettingsSheet(
visible: Boolean,
serverUrl: String,
accounts: List<MailAccount>,
mailboxes: List<MailMailbox>,
appSettings: MailAppSettings?,
appSettingsLoading: Boolean,
appSettingsSaving: Boolean,
editingAccountId: Int?,
accountSettings: MailAccountSettings?,
accountSettingsLoading: Boolean,
accountSettingsSaving: Boolean,
onLoadAppSettings: () -> Unit,
onAccountClick: (Int) -> Unit,
onAccountSettingsBack: () -> Unit,
onSaveAccountSettings: (MailAccountSettings) -> Unit,
onShowThreadedChange: (Boolean) -> Unit,
onHighlightExternalChange: (Boolean) -> Unit,
onRemoveTrustedSender: (MailTrustedSender) -> Unit,
onCreateTextBlock: (String, String) -> Unit,
onDeleteTextBlock: (Int) -> Unit,
onDismiss: () -> Unit,
onSwipeBack: () -> Unit = onDismiss,
) {
if (!visible) return
LaunchedEffect(visible, editingAccountId) {
if (visible && editingAccountId == null) {
onLoadAppSettings()
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxSize()
.f7SwipeFromRightToDismiss(onDismiss = onSwipeBack),
color = F7Colors.Surface,
) {
if (editingAccountId != null) {
MailAccountSettingsScreen(
settings = accountSettings,
loading = accountSettingsLoading,
saving = accountSettingsSaving,
mailboxes = mailboxes.filter { it.accountId == editingAccountId },
onBack = onAccountSettingsBack,
onSave = onSaveAccountSettings,
)
} else {
MailAppSettingsScreen(
serverUrl = serverUrl,
accounts = accounts,
appSettings = appSettings,
loading = appSettingsLoading,
saving = appSettingsSaving,
onAccountClick = onAccountClick,
onShowThreadedChange = onShowThreadedChange,
onHighlightExternalChange = onHighlightExternalChange,
onRemoveTrustedSender = onRemoveTrustedSender,
onCreateTextBlock = onCreateTextBlock,
onDeleteTextBlock = onDeleteTextBlock,
onDismiss = onDismiss,
)
}
}
}
}
@Composable
private fun MailAppSettingsScreen(
serverUrl: String,
accounts: List<MailAccount>,
appSettings: MailAppSettings?,
loading: Boolean,
saving: Boolean,
onAccountClick: (Int) -> Unit,
onShowThreadedChange: (Boolean) -> Unit,
onHighlightExternalChange: (Boolean) -> Unit,
onRemoveTrustedSender: (MailTrustedSender) -> Unit,
onCreateTextBlock: (String, String) -> Unit,
onDeleteTextBlock: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val baseUrl = serverUrl.trimEnd('/')
var textBlockDialogOpen by remember { mutableStateOf(false) }
var textBlockTitle by remember { mutableStateOf("") }
var textBlockContent by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Параметры эл. почты",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
IconButton(onClick = onDismiss) {
Text("", style = MaterialTheme.typography.titleMedium)
}
}
Spacer(Modifier.height(16.dp))
if (loading && appSettings == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
MailSettingsSectionTitle("Основные")
F7SecondaryButton(
text = "Установить как почтовое приложение по умолчанию",
onClick = {
runCatching {
context.startActivity(Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS))
}.onFailure {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("mailto:"))
context.startActivity(Intent.createChooser(intent, "Почтовое приложение"))
}
},
modifier = Modifier.fillMaxWidth(),
)
MailSettingsFormGroup(
label = "Параметры учётной записи",
) {
if (accounts.isEmpty()) {
Text(
"Учётные записи не найдены",
color = F7Colors.TextSecondary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
accounts.forEach { account ->
MailSettingsAccountRow(
email = account.email.ifBlank { account.name },
subtitle = account.name.takeIf { it.isNotBlank() && it != account.email },
onClick = { onAccountClick(account.id) },
)
}
}
if (appSettings?.allowNewAccounts != false) {
F7SecondaryButton(
text = "Добавить учётную запись",
onClick = {
val url = "$baseUrl/index.php/apps/mail/setup"
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
modifier = Modifier.fillMaxWidth(),
)
}
}
MailSettingsSectionTitle("Внешний вид")
MailSettingsSwitchCard(
label = "Показать все сообщения в ветке",
description = "Если выключено, будет показано только выбранное сообщение",
checked = appSettings?.showThreaded == true,
enabled = !saving,
onCheckedChange = onShowThreadedChange,
)
MailSettingsSectionTitle("Текстовые шаблоны")
Text(
"Повторно используемые фрагменты текста, которые можно вставлять в письма",
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
appSettings?.textBlocks.orEmpty().forEach { block ->
MailSettingsListRow(
title = block.title,
subtitle = block.preview.ifBlank { block.content.replace(Regex("<[^>]+>"), " ").trim() },
onDelete = { onDeleteTextBlock(block.id) },
)
}
F7SecondaryButton(
text = "Новый текстовый блок",
onClick = { textBlockDialogOpen = true },
modifier = Modifier.fillMaxWidth(),
)
MailSettingsSectionTitle("Конфиденциальность")
MailSettingsFormGroup(label = "Всегда показывать изображения из") {
val senders = appSettings?.trustedSenders.orEmpty()
if (senders.isEmpty()) {
Text(
"Сейчас нет доверенных отправителей.",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextSecondary,
modifier = Modifier.padding(vertical = 8.dp),
)
} else {
senders.forEach { sender ->
MailSettingsListRow(
title = sender.email,
subtitle = if (sender.type == "domain") "домен" else "адрес",
onDelete = { onRemoveTrustedSender(sender) },
)
}
}
}
MailSettingsSectionTitle("Безопасность")
MailSettingsSwitchCard(
label = "Выделять внешние адреса",
description = "Управляйте внутренними адресами и доменами, чтобы контакты оставались без пометки",
checked = appSettings?.highlightExternalAddresses == true,
enabled = !saving,
onCheckedChange = onHighlightExternalChange,
)
MailSettingsFormGroup(label = "S/MIME") {
F7SecondaryButton(
text = "Управление сертификатами",
onClick = {
val url = "$baseUrl/index.php/apps/mail"
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
},
modifier = Modifier.fillMaxWidth(),
)
}
}
if (saving) {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
color = F7Colors.Primary,
strokeWidth = 2.dp,
)
}
}
}
if (textBlockDialogOpen) {
Dialog(onDismissRequest = { textBlockDialogOpen = false }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = F7Colors.Surface,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
"Новый текстовый блок",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(12.dp))
MailSettingsTextArea(
label = "Название",
value = textBlockTitle,
onValueChange = { textBlockTitle = it },
minHeight = 48.dp,
)
Spacer(Modifier.height(8.dp))
MailSettingsTextArea(
label = "Содержимое",
value = textBlockContent,
onValueChange = { textBlockContent = it },
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
F7SecondaryButton(
text = "Отмена",
onClick = {
textBlockDialogOpen = false
textBlockTitle = ""
textBlockContent = ""
},
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(
enabled = textBlockTitle.isNotBlank() && textBlockContent.isNotBlank(),
) {
onCreateTextBlock(textBlockTitle.trim(), textBlockContent.trim())
textBlockDialogOpen = false
textBlockTitle = ""
textBlockContent = ""
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
"OK",
color = F7Colors.TextOnPrimary,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
}
}
@Composable
private fun MailSettingsSectionTitle(title: String) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
}
@Composable
private fun MailSettingsFormGroup(
label: String,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
content()
}
}
}
@Composable
private fun MailSettingsAccountRow(
email: String,
subtitle: String?,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsAccountCard)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
)
}
}
Text("", color = F7Colors.TextSecondary)
}
}
@Composable
private fun MailSettingsSwitchCard(
label: String,
description: String? = null,
checked: Boolean,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
if (!description.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(description, style = MaterialTheme.typography.bodySmall, color = F7Colors.TextSecondary)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = F7Colors.Primary,
),
)
}
}
@Composable
private fun MailSettingsListRow(
title: String,
subtitle: String? = null,
onDelete: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(start = 14.dp, end = 4.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = F7Colors.TextSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
IconButton(onClick = onDelete) {
Text("", color = F7Colors.TextSecondary)
}
}
}
@Composable
private fun MailAccountSettingsScreen(
settings: MailAccountSettings?,
loading: Boolean,
saving: Boolean,
mailboxes: List<MailMailbox>,
onBack: () -> Unit,
onSave: (MailAccountSettings) -> Unit,
) {
var draft by remember { mutableStateOf<MailAccountSettings?>(null) }
LaunchedEffect(settings) {
if (settings != null) draft = settings
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Text("", style = MaterialTheme.typography.headlineSmall)
}
Text(
"Параметры учётной записи",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
modifier = Modifier.weight(1f),
)
}
if (loading || draft == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
return
}
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
MailSettingsInfoRow("Email", draft!!.email)
if (draft!!.name.isNotBlank()) {
MailSettingsInfoRow("Имя", draft!!.name)
}
if (!draft!!.imapHost.isNullOrBlank()) {
MailSettingsInfoRow("IMAP", draft!!.imapHost.orEmpty())
}
if (!draft!!.smtpHost.isNullOrBlank()) {
MailSettingsInfoRow("SMTP", draft!!.smtpHost.orEmpty())
}
draft!!.quotaPercentage?.let { quota ->
MailSettingsInfoRow("Квота", "$quota%")
}
MailSettingsTextArea(
label = "Подпись",
value = draft!!.signature,
onValueChange = { draft = draft!!.copy(signature = it) },
)
MailSettingsMailboxPicker(
label = "Черновики",
mailboxes = mailboxes,
selectedId = draft!!.draftsMailboxId,
onSelected = { draft = draft!!.copy(draftsMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Отправленные",
mailboxes = mailboxes,
selectedId = draft!!.sentMailboxId,
onSelected = { draft = draft!!.copy(sentMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Корзина",
mailboxes = mailboxes,
selectedId = draft!!.trashMailboxId,
onSelected = { draft = draft!!.copy(trashMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Архив",
mailboxes = mailboxes,
selectedId = draft!!.archiveMailboxId,
onSelected = { draft = draft!!.copy(archiveMailboxId = it) },
)
MailSettingsMailboxPicker(
label = "Спам",
mailboxes = mailboxes,
selectedId = draft!!.junkMailboxId,
onSelected = { draft = draft!!.copy(junkMailboxId = it) },
)
MailSettingsToggleRow(
label = "Искать в теле письма",
checked = draft!!.searchBody,
onCheckedChange = { draft = draft!!.copy(searchBody = it) },
)
MailSettingsToggleRow(
label = "Приоритетные входящие",
checked = draft!!.classificationEnabled,
onCheckedChange = { draft = draft!!.copy(classificationEnabled = it) },
)
MailSettingsToggleRow(
label = "Подпись над цитатой",
checked = draft!!.signatureAboveQuote,
onCheckedChange = { draft = draft!!.copy(signatureAboveQuote = it) },
)
MailSettingsToggleRow(
label = "Создавать события из приглашений",
checked = draft!!.imipCreate,
onCheckedChange = { draft = draft!!.copy(imipCreate = it) },
)
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(100.dp))
.background(F7Colors.Primary)
.clickable(enabled = !saving) { onSave(draft!!) }
.padding(vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
) {
if (saving) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
color = F7Colors.TextOnPrimary,
strokeWidth = 2.dp,
)
} else {
Text(
"Сохранить",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextOnPrimary,
)
}
}
}
}
@Composable
private fun MailSettingsInfoRow(label: String, value: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
Spacer(Modifier.height(4.dp))
Text(value, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
}
}
@Composable
private fun MailSettingsTextArea(
label: String,
value: String,
onValueChange: (String) -> Unit,
minHeight: androidx.compose.ui.unit.Dp = 80.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
Spacer(Modifier.height(6.dp))
androidx.compose.foundation.text.BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight),
textStyle = MaterialTheme.typography.bodyMedium.copy(color = F7Colors.TextPrimary),
)
}
}
@Composable
private fun MailSettingsToggleRow(
label: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.clickable { onCheckedChange(!checked) }
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
)
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun MailSettingsMailboxPicker(
label: String,
mailboxes: List<MailMailbox>,
selectedId: Int?,
onSelected: (Int?) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedName = mailboxes.firstOrNull { it.id == selectedId }?.displayName ?: "Не выбрано"
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MailSettingsCard)
.border(1.dp, F7Colors.Border, RoundedCornerShape(12.dp))
.clickable { expanded = true }
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
Text(label, style = MaterialTheme.typography.labelMedium, color = F7Colors.TextSecondary)
Spacer(Modifier.height(4.dp))
Text(
selectedName,
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text("Не выбрано") },
onClick = {
onSelected(null)
expanded = false
},
)
mailboxes.forEach { mailbox ->
DropdownMenuItem(
text = { Text(mailbox.displayName) },
onClick = {
onSelected(mailbox.id)
expanded = false
},
)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
package ru.forbion.f7cloud.feature.mail
/** Подмена брендинга в WebView почты (как f7cloud_branding.js на сервере). */
object MailWebBranding {
fun brandingScriptUrl(serverUrl: String): String {
return "${serverUrl.trimEnd('/')}/themes/forbion/js/f7cloud_branding.js"
}
fun replaceInText(text: String): String {
return text
.replace(Regex("Nextcloud", RegexOption.IGNORE_CASE), "F7cloud")
.replace(Regex("NEXTCLOUD"), "F7CLOUD")
.replace(Regex("nextcloud\\.com", RegexOption.IGNORE_CASE), "f7cloud.ru")
}
/** Загружает f7cloud_branding.js с сервера, затем выполняет [body]. */
fun jsAfterBranding(serverUrl: String, body: String): String {
val url = brandingScriptUrl(serverUrl).replace("\\", "\\\\").replace("'", "\\'")
return """
(function() {
var run = function() { $body };
if (window.__f7cloudBranding) { run(); return; }
var s = document.createElement('script');
s.src = '$url';
s.onload = run;
s.onerror = run;
(document.head || document.documentElement).appendChild(s);
})();
""".trimIndent()
}
}