From ac67b9cf12247e9e38facdfae4b161b1b6c0ff35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 22:01:48 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(mail):=20Konten=20wie=20in=20der=20Out?= =?UTF-8?q?look-App,=20sp=C3=A4ter=20geladene=20Inhalte=20und=20Anh=C3=A4n?= =?UTF-8?q?ge=20(Mail=201.1.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account.avatar { mediaId, mime } (nur Microsoft): MailAvatars lädt das Bild über media/, hält es im Speicher und in der Offline-Kopie an der mediaId - MailAccountAvatar: Profilbild oder eigenes einfaches Anbieterzeichen („O“, vier Quadrate, „G“, „GMX“, „W“, „T“, sonst „@“ in Kontofarbe), kleiner Punkt in Kontofarbe; im Seitenmenü mit Name und Adresse, in den Mail-Einstellungen, auf der Kontoseite und bei der Absender-Auswahl - Live-Ereignis mail mit messageId, threadId, attachmentId - bodyMissing: „Der Inhalt wird noch vom Anbieter geladen …“ mit „Erneut versuchen“ und der Vorschau; das Ereignis mit derselben messageId oder threadId lädt die Mail neu; die Vorschau ersetzt keine volle Kopie - 409 busy (loading) bei Anhängen: „Wird gerade geladen …“, Neuversuch nach retryAfter (höchstens 3-mal) oder beim Ereignis mit attachmentId; eingebettete Bilder laden danach neu - Tests: MailJson (Avatar, Ereignis, busy), MailLogic (Zeichen, Ereignis, Wartezeit, cid) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/mail/MailApi.kt | 33 ++-- .../gatecontrol/android/mail/MailAvatars.kt | 72 ++++++++ .../com/gatecontrol/android/mail/MailCache.kt | 15 ++ .../com/gatecontrol/android/mail/MailJson.kt | 24 ++- .../com/gatecontrol/android/mail/MailLogic.kt | 33 ++++ .../gatecontrol/android/mail/MailModels.kt | 31 +++- .../android/mail/MailRepository.kt | 1 + .../android/ui/mail/MailComponents.kt | 103 +++++++++++ .../android/ui/mail/MailComposeScreen.kt | 6 +- .../android/ui/mail/MailHomeScreen.kt | 34 +++- .../android/ui/mail/MailHtmlView.kt | 7 +- .../android/ui/mail/MailMoreScreens.kt | 10 +- .../android/ui/mail/MailReadScreen.kt | 26 ++- .../android/ui/mail/MailReadViewModel.kt | 163 ++++++++++++++++-- app/src/main/res/values-de/strings_mail.xml | 6 +- app/src/main/res/values/strings_mail.xml | 6 +- .../gatecontrol/android/mail/MailJsonTest.kt | 34 ++++ .../gatecontrol/android/mail/MailLogicTest.kt | 28 +++ 18 files changed, 580 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/mail/MailAvatars.kt diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt index 445dc6f7..d5954a44 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt @@ -48,6 +48,7 @@ class MailApi @Inject constructor( private const val UPLOAD_TIMEOUT_MS = 30 * 60_000L private const val IMAGE_TIMEOUT_MS = 20_000L private const val IMAGE_MAX_BYTES = 6L * 1024 * 1024 + private const val AVATAR_MAX_BYTES = 2L * 1024 * 1024 } private val _unauthorized = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) @@ -217,21 +218,23 @@ class MailApi @Inject constructor( } /** - * A picture the mail's HTML asks for ([MailLogic.interceptPath]), with the app's own sign-in; null when it cannot - * be had. `img/` answers `{ mime, size, data }` (Base64, at most 700 KB); `cid//` answers - * `{ mediaId, mime, size }` and the picture is the plugin's media file. Only raster pictures (never SVG), a few MB. + * A picture the mail's HTML asks for ([MailLogic.interceptPath]), with the app's own sign-in. `img/` answers + * `{ mime, size, data }` (Base64, at most 700 KB); `cid//` answers `{ mediaId, mime, size }` and the picture + * is the plugin's media file, or `409 busy { reason: 'loading', retryAfter }` while the provider still sends it + * ([MPicture.Busy]). Only raster pictures (never SVG), a few MB. */ - suspend fun picture(path: String): Pair? { + suspend fun picture(path: String): MPicture { val url = setupRepository.getServerUrl() - if (url.isBlank() || !setupRepository.isConfigured()) return null + if (url.isBlank() || !setupRepository.isConfigured()) return MPicture.None return try { withTimeoutOrNull(IMAGE_TIMEOUT_MS) { val r = apiClientProvider.getClient(url).pluginGet(MAIL_PLUGIN, path) val o = r.body()?.takeIf { r.isSuccessful && !MailJson.isRefusal(it) } ?: run { - r.errorBody()?.close() - return@withTimeoutOrNull null + val err = if (r.isSuccessful) r.body() else errorJson(r) + val code = MailJson.failureCode(r.code(), err) + return@withTimeoutOrNull MailJson.busyRetryAfter(code, err)?.let { MPicture.Busy(it) } ?: MPicture.None } - val picture = MailJson.picture(o) ?: return@withTimeoutOrNull null + val picture = MailJson.picture(o) ?: return@withTimeoutOrNull MPicture.None val bytes = picture.data ?: picture.mediaId?.let { media -> val m = apiClientProvider.mediaClient(url).pluginMediaGet(MAIL_PLUGIN, media) val body = m.body() @@ -242,16 +245,22 @@ class MailApi @Inject constructor( } else { body.use { b -> b.byteStream().use { input -> readAtMost(input, IMAGE_MAX_BYTES) } } } - } ?: return@withTimeoutOrNull null - bytes to picture.mime - } + } ?: return@withTimeoutOrNull MPicture.None + MPicture.Got(bytes, picture.mime) + } ?: MPicture.None } catch (e: CancellationException) { throw e } catch (e: Exception) { - null + MPicture.None } } + /** The account's picture ([MAccount.avatar]): the plugin's media file, at most [AVATAR_MAX_BYTES]; null when not had. */ + suspend fun avatar(mediaId: String): ByteArray? = when (val r = media(mediaId)) { + is MResult.Ok -> runCatching { r.value.use { b -> b.byteStream().use { readAtMost(it, AVATAR_MAX_BYTES) } } }.getOrNull() + else -> null + } + /** All of [input], null when it is longer than [max]. */ private fun readAtMost(input: java.io.InputStream, max: Long): ByteArray? { val out = java.io.ByteArrayOutputStream() diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailAvatars.kt b/app/src/main/java/com/gatecontrol/android/mail/MailAvatars.kt new file mode 100644 index 00000000..9d61f296 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailAvatars.kt @@ -0,0 +1,72 @@ +package com.gatecontrol.android.mail + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.LruCache +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The accounts' pictures (`Account.avatar`, Microsoft only): the plugin's media file, kept by its media id — in memory + * and in the offline copy ([MailCache], gone with it). A new picture comes with a new media id (the live event `mail` + * makes `GET me` load again), so a kept one is never stale. + */ +@Singleton +class MailAvatars @Inject constructor( + private val api: MailApi, + private val cache: MailCache, +) { + private val memory = LruCache(MAX_KEPT) + private val inFlight = ConcurrentHashMap>() + + /** Already decoded (no I/O). */ + fun peek(mediaId: String): Bitmap? = memory.get(mediaId) + + /** The picture [mediaId], from the copy or the server; null when it cannot be had (offline, gone, no picture). */ + suspend fun bitmap(mediaId: String): Bitmap? { + memory.get(mediaId)?.let { return it } + inFlight[mediaId]?.let { return it.await() } + val mine = CompletableDeferred() + val other = inFlight.putIfAbsent(mediaId, mine) + if (other != null) return other.await() + return try { + val kept = cache.avatar(mediaId) + val bytes = kept ?: api.avatar(mediaId) + val bmp = bytes?.let { withContext(Dispatchers.IO) { decode(it) } } + if (bmp != null) { + memory.put(mediaId, bmp) + if (kept == null) cache.putAvatar(mediaId, bytes) + } + mine.complete(bmp) + bmp + } catch (e: CancellationException) { + mine.complete(null) + throw e + } catch (e: Exception) { + mine.complete(null) + null + } finally { + inFlight.remove(mediaId, mine) + } + } + + /** Small enough for a round avatar (at most [SIDE_PX] on the longer side). */ + private fun decode(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= SIDE_PX) sample *= 2 + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } + + private companion object { + const val MAX_KEPT = 16 + const val SIDE_PX = 192 + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt index e4ccc7bb..6b818307 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt @@ -71,6 +71,20 @@ class MailCache @Inject constructor( suspend fun folders(): Map>? = io { dir -> read(File(dir, "folders.json"))?.let(MailJson::folders) } + // ─── account pictures ─────────────────────────────────────────── + + /** An account's picture as it came ([MAccount.avatar]); the media id changes with the picture. */ + suspend fun putAvatar(mediaId: String, bytes: ByteArray) { + io { dir -> + val avatars = File(dir, "avatars").apply { mkdirs() } + File(avatars, name(mediaId)).writeBytes(bytes) + // only the newest few stay (a picture changed: the old one is of no use) + avatars.listFiles()?.sortedByDescending { it.lastModified() }?.drop(MAX_AVATARS)?.forEach { it.delete() } + } + } + + suspend fun avatar(mediaId: String): ByteArray? = io { dir -> File(dir, "avatars/${name(mediaId)}").takeIf { it.isFile }?.readBytes() } + // ─── lists ────────────────────────────────────────────────────── /** @@ -151,6 +165,7 @@ class MailCache @Inject constructor( companion object { private const val DIR = "mail" + private const val MAX_AVATARS = 20 /** Attachments opened or shared (FileProvider `mail_open`). */ const val OPEN_DIR = "mail-open" /** Device files on their way to the server (private). */ diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt index ec7fcf3e..a0475f18 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt @@ -153,6 +153,11 @@ object MailJson { ai = o.bool("ai") ?: false, attachments = o.str("attachments")?.takeIf { it in MAccount.ATTACHMENTS } ?: "off", signature = o.str("signature")?.takeIf { it.isNotBlank() }, + avatar = o.obj("avatar")?.let { av -> + val media = av.str("mediaId")?.takeIf { TOKEN.matches(it) } ?: return@let null + val mime = av.str("mime")?.lowercase()?.substringBefore(';')?.trim()?.takeIf { PICTURE_MIME.matches(it) } ?: return@let null + MAvatar(media, mime) + }, ) } @@ -484,7 +489,24 @@ object MailJson { val o = data?.takeIf { it.isJsonObject }?.asJsonObject ?: return null val root = o.obj("mail") ?: o if (root.str("kind") != "changed") return null - return MChanged(root.id("accountId"), root.id("folderId")) + return MChanged(root.id("accountId"), root.id("folderId"), root.id("messageId"), root.id("threadId"), root.id("attachmentId")) + } + + /** + * `409 busy { reason, retryAfter }` (the error object of [MResult.Failed]): when to ask again, in seconds (1…60, + * 5 without a value); null when the refusal is no `busy`. + */ + fun busyRetryAfter(code: String?, error: JsonObject?): Int? { + if (code != "busy") return null + val err = error?.obj("error") ?: error + return (err?.int("retryAfter") ?: 5).coerceIn(1, 60) + } + + /** `busy` with `reason: 'loading'`: an attachment (or embedded picture) still on its way from the provider. */ + fun busyLoading(code: String?, error: JsonObject?): Boolean { + if (code != "busy") return false + val err = error?.obj("error") ?: error + return err?.str("reason") == "loading" } // ─── request bodies ───────────────────────────────────────────── diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt index 7c822070..3bdd18d1 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt @@ -193,6 +193,39 @@ object MailLogic { fun nextColor(used: List): String = COLORS.firstOrNull { c -> used.none { it.equals(c, ignoreCase = true) } } ?: COLORS[used.size % COLORS.size] + // ─── accounts: the provider's mark ────────────────────────────── + + /** The round mark of an account without a picture (own simple signs, no provider logos). */ + enum class Mark { OUTLOOK, M365, GMAIL, GMX, WEBDE, TONLINE, OTHER } + + fun mark(provider: String?): Mark = when (provider) { + MAccount.PROVIDER_OUTLOOK -> Mark.OUTLOOK + MAccount.PROVIDER_M365 -> Mark.M365 + MAccount.PROVIDER_GMAIL -> Mark.GMAIL + MAccount.PROVIDER_GMX -> Mark.GMX + MAccount.PROVIDER_WEBDE -> Mark.WEBDE + MAccount.PROVIDER_TONLINE -> Mark.TONLINE + else -> Mark.OTHER + } + + // ─── loading later (Mail 1.1.2) ───────────────────────────────── + + /** How often an attachment or embedded picture still loading (`busy`) is asked for again by itself. */ + const val BUSY_RETRIES = 3 + + /** `busy`'s `retryAfter` (seconds) as a wait; never below a second, never above a minute. */ + fun retryDelayMs(retryAfter: Int?): Long = (retryAfter ?: 5).coerceIn(1, 60) * 1000L + + /** Whether a live event `mail` concerns the open mail [messageId] (or its conversation [threadId]). */ + fun concerns(changed: MChanged, messageId: Long, threadId: Long?): Boolean = + changed.messageId == messageId || (threadId != null && changed.threadId == threadId) + + /** The attachment of an embedded picture's path `cid//`; null for anything else. */ + fun cidAttachment(path: String): Long? { + val parts = path.split('/') + return if (parts.size == 3 && parts[0] == "cid") parts[2].toLongOrNull()?.takeIf { it > 0 } else null + } + /** Quota in percent (0…100), null without a quota. */ fun quotaPercent(used: Long, quota: Long): Int? = if (quota <= 0) null else ((used.coerceAtLeast(0) * 100.0) / quota).toInt().coerceIn(0, 100) diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt index 69906c8b..38b78037 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -84,6 +84,8 @@ data class MAccount( /** off | suggest | auto */ val attachments: String, val signature: String?, + /** The account's picture at the provider (Microsoft only, since Mail 1.1.2), loaded as the plugin's media. */ + val avatar: MAvatar? = null, ) { val label: String get() = name?.takeIf { it.isNotBlank() } ?: email @@ -108,6 +110,9 @@ data class MAccount( } } +/** `Account.avatar`: `{ mediaId, mime }`. */ +data class MAvatar(val mediaId: String, val mime: String) + /** `GET providers`: one entry of "Konto hinzufügen". */ data class MProvider( val id: String, @@ -234,7 +239,10 @@ data class MMessage( val trackers: Int, val attachments: List, val unsubscribe: MUnsubscribe?, - /** The body is not on the server (any more) and could not be fetched from the provider. */ + /** + * The body is not here yet: the server did not get it from the provider in time (`text` is the snippet then); the + * live event `mail` with this mail's id (or its thread's) tells when it is there. + */ val bodyMissing: Boolean = false, ) { val id: Long get() = summary.id @@ -328,10 +336,27 @@ data class MFilingItem( /** `GET messages//attachments/`: where its file lies. */ data class MAttachmentMedia(val mediaId: String, val name: String, val mime: String, val size: Long) +/** A picture of a mail's HTML (`img/…`, `cid/…`) as the app got it. */ +sealed class MPicture { + class Got(val bytes: ByteArray, val mime: String) : MPicture() + /** `409 busy { reason: 'loading' }`: an embedded picture still on its way — ask again after [retryAfter] s. */ + data class Busy(val retryAfter: Int) : MPicture() + data object None : MPicture() +} + data class MSaveAll(val saved: List>, val failed: List>) /** `POST messages//attachments//drive/suggest`. */ data class MSuggest(val suggestion: MSuggestion?, val duplicate: Boolean) -/** Realtime event `mail` `{ kind: 'changed', accountId, folderId }`. */ -data class MChanged(val accountId: Long?, val folderId: Long?) +/** + * Realtime event `mail` `{ kind: 'changed', accountId, folderId }` — since Mail 1.1.2 also with [messageId] (a body + * the answer did not wait for is there; with [attachmentId]: that attachment) or [threadId]. + */ +data class MChanged( + val accountId: Long?, + val folderId: Long?, + val messageId: Long? = null, + val threadId: Long? = null, + val attachmentId: Long? = null, +) diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt index 384d9b35..35a03de3 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt @@ -210,4 +210,5 @@ class MailStartup @Inject constructor( @InstallIn(SingletonComponent::class) interface MailEntryPoint { fun mailStartup(): MailStartup + fun mailAvatars(): MailAvatars } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt index e64cd39a..65252464 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt @@ -7,6 +7,21 @@ import android.provider.CalendarContract import android.widget.Toast import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.Dp +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import com.gatecontrol.android.mail.MailEntryPoint +import dagger.hilt.android.EntryPointAccessors import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures @@ -90,6 +105,93 @@ internal fun MailDot(color: Color, size: Int = 8) { Box(Modifier.size(size.dp).clip(CircleShape).background(color)) } +/** An account's picture ([MAccount.avatar]) as the app keeps it; null without one, while loading or offline. */ +@Composable +private fun rememberMailAvatar(mediaId: String?): ImageBitmap? { + val context = LocalContext.current.applicationContext + val preview = LocalInspectionMode.current + val store = remember(context, preview) { + if (preview) null else runCatching { EntryPointAccessors.fromApplication(context, MailEntryPoint::class.java).mailAvatars() }.getOrNull() + } + val bitmap by produceState(mediaId?.let { store?.peek(it) }?.asImageBitmap(), mediaId, store) { + value = if (mediaId == null || store == null) null else value ?: store.bitmap(mediaId)?.asImageBitmap() + } + return bitmap +} + +/** + * An account as the Outlook app shows it: round, its picture (Microsoft) or the provider's mark — own simple signs + * in the provider's color, no logos ("O", four squares, "G", "GMX", "W", "T", else "@" in the account's color) — + * with a small dot in the account's color. + */ +@Composable +internal fun MailAccountAvatar(account: MAccount?, size: Dp = 36.dp, dot: Boolean = true, ring: Color = MaterialTheme.colorScheme.surface) { + val picture = rememberMailAvatar(account?.avatar?.mediaId) + val color = accountColor(account) + Box(Modifier.size(size)) { + Box(Modifier.matchParentSize().clip(CircleShape), contentAlignment = Alignment.Center) { + if (picture != null) { + Image(picture, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize()) + } else { + ProviderMark(MailLogic.mark(account?.provider), color, size) + } + } + if (dot) { + val d = (size * 0.34f).coerceAtLeast(9.dp) + Box( + Modifier.align(Alignment.BottomEnd).offset(x = 2.dp, y = 2.dp).size(d).clip(CircleShape).background(ring).padding(2.dp).clip(CircleShape).background(color), + ) + } + } +} + +@Composable +private fun BoxScope.ProviderMark(mark: MailLogic.Mark, accountColor: Color, size: Dp) { + val density = LocalDensity.current + @Composable + fun letter(text: String, bg: Color, fg: Color, scale: Float = 0.46f) { + Box(Modifier.matchParentSize().background(bg)) + Text(text, color = fg, fontWeight = FontWeight.Bold, fontSize = with(density) { (size * scale).toSp() }, maxLines = 1) + } + when (mark) { + MailLogic.Mark.OUTLOOK -> letter("O", Color(0xFF0F6CBD), Color.White) + MailLogic.Mark.M365 -> { + Box(Modifier.matchParentSize().background(Color(0xFFF3F4F6))) + Canvas(Modifier.size(size * 0.44f)) { + val gap = this.size.width * 0.08f + val side = (this.size.width - gap) / 2 + val tiles = listOf(Color(0xFFF25022), Color(0xFF7FBA00), Color(0xFF00A4EF), Color(0xFFFFB900)) + tiles.forEachIndexed { i, c -> + drawRect(c, topLeft = Offset((i % 2) * (side + gap), (i / 2) * (side + gap)), size = Size(side, side)) + } + } + } + MailLogic.Mark.GMAIL -> letter("G", Color.White, Color(0xFFEA4335)) + MailLogic.Mark.GMX -> letter("GMX", Color(0xFF1C449B), Color.White, scale = 0.27f) + MailLogic.Mark.WEBDE -> letter("W", Color(0xFFFFD800), Color(0xFF1A1A1A)) + MailLogic.Mark.TONLINE -> letter("T", Color(0xFFE20074), Color.White) + MailLogic.Mark.OTHER -> letter("@", accountColor, Color(0xFF0D1015)) + } +} + +/** The account's name and below its address (only the address when it has no name). */ +@Composable +internal fun MailAccountText(account: MAccount, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.onSurface, bold: Boolean = false) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + account.label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (bold) FontWeight.SemiBold else FontWeight.Normal, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (account.label != account.email) { + Text(account.email, style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + /** The time column of a mail: the time today, "Gestern", the weekday, else the date. */ @Composable internal fun mailTime(date: Long): String { @@ -331,6 +433,7 @@ internal fun mailText(context: Context, code: String?): String = context.getStri "not_waiting" -> R.string.mail_err_not_waiting "exists" -> R.string.mail_err_exists "links_disabled" -> R.string.mail_err_links_disabled + "busy" -> R.string.mail_err_busy else -> R.string.mail_err_failed }, ) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt index 17fa212a..e535d06d 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt @@ -295,7 +295,7 @@ private fun FromRow(me: MMe?, account: MAccount?, onPick: (Long) -> Unit) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - MailDot(accountColor(account)) + MailAccountAvatar(account, size = 24.dp, dot = false) Text(account?.email.orEmpty(), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) account?.name?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted, maxLines = 1) } if (accounts.size > 1) Icon(GcIcons.ChevronDown, contentDescription = null, modifier = Modifier.size(16.dp)) @@ -303,8 +303,8 @@ private fun FromRow(me: MMe?, account: MAccount?, onPick: (Long) -> Unit) { DropdownMenu(expanded = open, onDismissRequest = { open = false }) { accounts.forEach { a -> DropdownMenuItem( - leadingIcon = { MailDot(accountColor(a), size = 10) }, - text = { Column { Text(a.label); Text(a.email, style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) } }, + leadingIcon = { MailAccountAvatar(a, size = 32.dp, ring = MaterialTheme.colorScheme.surfaceContainer) }, + text = { MailAccountText(a) }, onClick = { open = false; onPick(a.id) }, ) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt index 34529858..68f6bfb6 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -596,13 +596,13 @@ private fun MailDrawer( ) LazyColumn(Modifier.weight(1f)) { item(key = "all") { - DrawerItem(MailIcons.Inbox, null, stringResource(R.string.mail_all_inboxes), me?.unreadAll ?: 0, on = box.all, accent = true) { onBox(MailLogic.Box()) } + DrawerItem(MailIcons.Inbox, stringResource(R.string.mail_all_inboxes), me?.unreadAll ?: 0, on = box.all, accent = true) { onBox(MailLogic.Box()) } } if (me?.has(MMe.FEATURE_DRIVE) == true) { - item(key = "filing") { DrawerItem(GcIcons.Folder, null, stringResource(R.string.mail_filing), filing, on = false) { onFiling() } } + item(key = "filing") { DrawerItem(GcIcons.Folder, stringResource(R.string.mail_filing), filing, on = false) { onFiling() } } } if (outbox > 0) { - item(key = "outbox") { DrawerItem(MailIcons.Outbox, null, stringResource(R.string.mail_outbox), outbox, on = false) { onOutbox() } } + item(key = "outbox") { DrawerItem(MailIcons.Outbox, stringResource(R.string.mail_outbox), outbox, on = false) { onOutbox() } } } val accounts = me?.accounts.orEmpty() if (accounts.isNotEmpty()) { @@ -610,7 +610,7 @@ private fun MailDrawer( } accounts.forEach { a -> item(key = "a${a.id}") { - DrawerItem(null, accountColor(a), a.label, me?.unreadByAccount?.get(a.id) ?: 0, on = box.accountId == a.id && box.folderId == null) { + DrawerAccount(a, me?.unreadByAccount?.get(a.id) ?: 0, on = box.accountId == a.id && box.folderId == null) { onBox(MailLogic.Box(a.id)) } } @@ -628,7 +628,7 @@ private fun MailDrawer( .selectable(selected = box.folderId == f.id, role = Role.Tab) { onBox(MailLogic.Box(a.id, f.id.takeIf { f.role != MFolder.ROLE_INBOX })) } - .padding(start = 40.dp, end = 12.dp), + .padding(start = 58.dp, end = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Text(label, style = MaterialTheme.typography.bodyMedium, color = if (box.folderId == f.id) extra.accentText else MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -668,7 +668,7 @@ private fun DrawerLabel(text: String) { } @Composable -private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, dot: Color?, label: String, count: Int, on: Boolean, accent: Boolean = false, onClick: () -> Unit) { +private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, label: String, count: Int, on: Boolean, accent: Boolean = false, onClick: () -> Unit) { val extra = GateControlTheme.extraColors val fg = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface Row( @@ -683,7 +683,6 @@ private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, d horizontalArrangement = Arrangement.spacedBy(12.dp), ) { if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(20.dp)) - if (dot != null) MailDot(dot, size = 10) Text(label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal, color = fg, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) if (count > 0 || accent) { Text(count.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = if (accent && count > 0) extra.accentText else extra.muted) @@ -691,6 +690,27 @@ private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, d } } +/** An account in the menu (like the Outlook app): its picture or the provider's mark, the name, the address below. */ +@Composable +private fun DrawerAccount(a: MAccount, count: Int, on: Boolean, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (on) extra.accentBg else Color.Transparent) + .selectable(selected = on, role = Role.Tab, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + MailAccountAvatar(a, size = 34.dp, ring = if (on) extra.accentBg else MaterialTheme.colorScheme.surface) + MailAccountText(a, Modifier.weight(1f), color = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface, bold = on) + if (count > 0) Text(count.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = extra.muted) + } +} + @Composable private fun DrawerIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) { val extra = GateControlTheme.extraColors diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt index bb3d3df9..f53eea0f 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt @@ -37,6 +37,8 @@ internal fun MailHtmlView( loader: suspend (String) -> Pair?, onLink: (String) -> Unit, modifier: Modifier = Modifier, + /** Counts up when the page is to load its pictures again (an embedded one the provider was still sending came). */ + round: Int = 0, ) { val load = rememberUpdatedState(loader) val link = rememberUpdatedState(onLink) @@ -91,8 +93,9 @@ internal fun MailHtmlView( }, onRelease = { web -> web.destroy() }, update = { web -> - if (web.tag != page) { - web.tag = page + val key = page to round + if (web.tag != key) { + web.tag = key web.loadDataWithBaseURL(MailLogic.BASE_URL, page, "text/html", "utf-8", null) } }, diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt index e4511bbd..64afd106 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt @@ -321,7 +321,7 @@ fun MailSettingsScreen(onBack: () -> Unit, onAccount: (Long) -> Unit, onAdd: () title = a.label, description = a.email, onClick = { onAccount(a.id) }, - leading = { MailDot(accountColor(a), size = 12) }, + leading = { MailAccountAvatar(a, size = 36.dp) }, trailing = { GcChevron() }, ) } @@ -367,7 +367,13 @@ fun MailAccountScreen(onBack: () -> Unit, onRelogin: (Long) -> Unit, viewModel: var password by rememberSaveable(a.id) { mutableStateOf("") } var askRemove by remember { mutableStateOf(false) } Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(a.email, style = MaterialTheme.typography.bodyMedium, color = extra.muted) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { + MailAccountAvatar(a, size = 56.dp, ring = MaterialTheme.colorScheme.background) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(a.label, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (a.label != a.email) Text(a.email, style = MaterialTheme.typography.bodyMedium, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } when (a.state) { MAccount.STATE_AUTH_FAILED -> GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert) { Text(stringResource(R.string.mail_account_auth_failed, a.label), style = MaterialTheme.typography.bodySmall) diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt index edcf721f..26838ee3 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt @@ -187,16 +187,24 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode } } } - if (m.bodyMissing && m.html == null && m.text.isNullOrBlank()) { - GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert) { - Text(stringResource(R.string.mail_body_missing), style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) - TextButton(onClick = viewModel::load) { Text(stringResource(R.string.mail_retry)) } + if (m.bodyMissing) { + // the provider has not sent the body yet: the snippet below, the body comes with the live event + GcBanner(tone = GcTone.Info, icon = GcIcons.Clock) { + Text(stringResource(R.string.mail_body_loading), style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + if (state.loading) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + TextButton(onClick = viewModel::load) { Text(stringResource(R.string.mail_retry)) } + } } } + if (state.picturesLoading) { + Text(stringResource(R.string.mail_pictures_loading), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } val html = m.html if (html != null) { val page = remember(html, m.attachments) { MailLogic.rewriteCid(html, m.id, m.attachments) } - MailHtmlView(page, loader = viewModel::picture, onLink = onLink) + MailHtmlView(page, loader = viewModel::picture, onLink = onLink, round = state.pictureRound) } else { SelectionContainer { Text(m.text.orEmpty(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.85f)) @@ -416,9 +424,13 @@ private fun AttachmentCard( FileBadge(a.attachment.name, a.attachment.mime) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(a.attachment.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) - Text(mailSize(a.attachment.size), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Text( + if (a.loading) stringResource(R.string.mail_attachment_loading) else mailSize(a.attachment.size), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) } - if (a.busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + if (a.busy || a.loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) } if (!drive) return@Column val saved = a.savedTo diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt index 67a0c8fb..48484112 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.viewModelScope import com.gatecontrol.android.R import com.gatecontrol.android.mail.MAi import com.gatecontrol.android.mail.MAttachment +import com.gatecontrol.android.mail.MChanged +import com.gatecontrol.android.mail.MPicture import com.gatecontrol.android.mail.MMe import com.gatecontrol.android.mail.MMessage import com.gatecontrol.android.mail.MResult @@ -14,12 +16,16 @@ import com.gatecontrol.android.mail.MSavedTo import com.gatecontrol.android.mail.MSuggestion import com.gatecontrol.android.mail.MSummary import com.gatecontrol.android.mail.MailCache +import com.gatecontrol.android.mail.MailJson import com.gatecontrol.android.mail.MailLogic import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.mail.notOk import com.gatecontrol.android.navigation.Screen import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,6 +33,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject /** The AI bar of a mail. */ @@ -45,6 +52,8 @@ data class MailAttachmentUi( val savedTo: MSavedTo? = attachment.savedTo, val duplicate: Boolean = false, val busy: Boolean = false, + /** The provider still sends it (`409 busy { reason: 'loading' }`): asked again by itself. */ + val loading: Boolean = false, ) data class MailReadState( @@ -58,6 +67,10 @@ data class MailReadState( val ai: MailAiState = MailAiState.Off, val imagesBusy: Boolean = false, val savingAll: Boolean = false, + /** Embedded pictures the provider still sends (`cid/…` answered `busy`). */ + val picturesLoading: Boolean = false, + /** Counts up when the HTML is to load its pictures again (one came in the meantime). */ + val pictureRound: Int = 0, ) /** Reading a mail (mockup "Mail lesen"): the AI bar, the attachments with "In Drive speichern", pictures, the thread. */ @@ -82,9 +95,28 @@ class MailReadViewModel @Inject constructor( val event: StateFlow = _event.asStateFlow() private var aiTried = false + private var extrasLoaded = false + private var loadJob: Job? = null + /** An event came while `GET messages/` was still on its way: ask once more when it answers without a body. */ + private var loadAgain = false + + /** Attachments to open once the provider sent them: their id → how often they were asked for again. */ + private val waitingOpen = ConcurrentHashMap() + private val openRetry = ConcurrentHashMap() + + /** Embedded pictures the provider still sends (attachment ids) and the HTML's next try. */ + private val waitingCid: MutableSet = ConcurrentHashMap.newKeySet() + private var pictureTries = 0 + private var pictureRetry: Job? = null + + /** The HTML's pictures as they came (the page loads them again after a [MailReadState.pictureRound]). */ + private val pictures = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = picturesBytes() > PICTURES_MAX_BYTES + } init { load() + viewModelScope.launch { repository.changed.collect(::onChanged) } } fun load() { @@ -92,23 +124,54 @@ class MailReadViewModel @Inject constructor( _state.value = MailReadState(loading = false, error = "not_found") return } - viewModelScope.launch { + if (loadJob?.isActive == true) return + loadJob = viewModelScope.launch { if (_state.value.message == null) cache.message(id)?.let { show(it, loading = true) } + loadAgain = false when (val r = api.message(id)) { is MResult.Ok -> { val (m, raw) = r.value - show(m, loading = false) - cache.putMessage(raw) + // the snippet stands in for a body not here yet: a full copy kept earlier stays + if (m.bodyMissing) cache.message(id)?.takeIf { !it.bodyMissing && (it.html != null || !it.text.isNullOrBlank()) }?.let { show(it, loading = false) } ?: show(m, loading = false) + else { + show(m, loading = false) + cache.putMessage(raw) + } if (m.summary.unread) markRead(m) - loadExtras(m) + if (!m.bodyMissing && !extrasLoaded) { + extrasLoaded = true + loadExtras(m) + } } else -> _state.update { it.copy(loading = false, offline = r is MResult.Offline && it.message != null, error = if (it.message == null) MailHomeViewModel.code(r) else null) } } + }.also { job -> + job.invokeOnCompletion { + if (loadAgain && _state.value.message?.bodyMissing == true) viewModelScope.launch { load() } + } } } + /** + * The live event `mail` (Mail 1.1.2): this mail's body (or its conversation's) is there now → load it again; an + * attachment or embedded picture the provider was still sending is there → open it, or the pictures load again. + */ + private fun onChanged(c: MChanged) { + val m = _state.value.message + if (m != null && m.bodyMissing && MailLogic.concerns(c, id, m.summary.threadId)) { + if (loadJob?.isActive == true) loadAgain = true else load() + } + val aid = c.attachmentId ?: return + if (c.messageId != id) return + if (waitingOpen.containsKey(aid)) { + openRetry.remove(aid)?.cancel() + _state.value.attachments.firstOrNull { it.attachment.id == aid }?.let { viewModelScope.launch { openNow(it.attachment) } } + } + if (waitingCid.remove(aid)) reloadPictures() + } + private fun show(m: MMessage, loading: Boolean) { _state.update { s -> s.copy( @@ -281,14 +344,39 @@ class MailReadViewModel @Inject constructor( /** An attachment opened in another app: fetched into `cache/mail-open/-/`. */ fun open(a: MAttachment) { + if (waitingOpen.containsKey(a.id)) return + waitingOpen[a.id] = 0 + viewModelScope.launch { openNow(a) } + } + + /** + * Fetches and opens [a]. While the provider still sends it (`409 busy { reason: 'loading', retryAfter }`) it shows + * "Wird gerade geladen …" and asks again after `retryAfter` (at most [MailLogic.BUSY_RETRIES] times), or at once + * when the live event names it. + */ + private suspend fun openNow(a: MAttachment) { updateAttachment(a.id) { it.copy(busy = true) } - viewModelScope.launch { - val file = fetch(a) - updateAttachment(a.id) { it.copy(busy = false) } - _event.value = when (file) { - is MResult.Ok -> MailReadEvent.Open(file.value, a.mime) - else -> MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(file))) + val file = fetch(a) + val busy = (file as? MResult.Failed)?.takeIf { MailJson.busyRetryAfter(it.code, it.error) != null } + val tries = waitingOpen[a.id] ?: 0 + if (busy != null && tries < MailLogic.BUSY_RETRIES) { + waitingOpen[a.id] = tries + 1 + updateAttachment(a.id) { it.copy(busy = false, loading = true) } + openRetry.remove(a.id)?.cancel() + openRetry[a.id] = viewModelScope.launch { + delay(MailLogic.retryDelayMs(MailJson.busyRetryAfter(busy.code, busy.error))) + openRetry.remove(a.id) + openNow(a) } + return + } + waitingOpen.remove(a.id) + openRetry.remove(a.id)?.cancel() + updateAttachment(a.id) { it.copy(busy = false, loading = false) } + _event.value = when { + file is MResult.Ok -> MailReadEvent.Open(file.value, a.mime) + busy != null && MailJson.busyLoading(busy.code, busy.error) -> MailReadEvent.Toast(context.getString(R.string.mail_attachment_still_loading)) + else -> MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(file))) } } @@ -297,7 +385,7 @@ class MailReadViewModel @Inject constructor( val target = File(dir, MailLogic.fileName(a.name)) if (target.isFile && target.length() > 0) return MResult.Ok(target) val media = api.attachmentMedia(id, a.id) - if (media !is MResult.Ok) return media.let { MResult.Failed(MailHomeViewModel.code(it), 0) } + if (media !is MResult.Ok) return media.notOk() val body = api.media(media.value.mediaId) if (body !is MResult.Ok) return MResult.Failed(MailHomeViewModel.code(body), 0) return withContext(Dispatchers.IO) { @@ -319,8 +407,56 @@ class MailReadViewModel @Inject constructor( root.listFiles()?.filter { it.lastModified() < cutoff }?.forEach { it.deleteRecursively() } } - /** A picture of the HTML (`img/…`, `cid/…`) through the plugin, with the app's sign-in. */ - suspend fun picture(path: String): Pair? = api.picture(path) + /** + * A picture of the HTML (`img/…`, `cid/…`) through the plugin, with the app's sign-in (called on the WebView's + * threads). An embedded picture the provider still sends (`busy`) is left out for now: the page loads its pictures + * again after `retryAfter` (at most [MailLogic.BUSY_RETRIES] times) or when the live event names it. + */ + suspend fun picture(path: String): Pair? { + synchronized(pictures) { pictures[path] }?.let { return it.bytes to it.mime } + return when (val r = api.picture(path)) { + is MPicture.Got -> { + synchronized(pictures) { pictures[path] = r } + MailLogic.cidAttachment(path)?.let { aid -> + if (waitingCid.remove(aid) && waitingCid.isEmpty()) _state.update { it.copy(picturesLoading = false) } + } + r.bytes to r.mime + } + is MPicture.Busy -> { + MailLogic.cidAttachment(path)?.let { aid -> pictureBusy(aid, r.retryAfter) } + null + } + MPicture.None -> null + } + } + + private fun picturesBytes(): Long = pictures.values.sumOf { it.bytes.size.toLong() } + + private fun pictureBusy(attachmentId: Long, retryAfter: Int) { + waitingCid.add(attachmentId) + if (pictureTries >= MailLogic.BUSY_RETRIES) { + _state.update { it.copy(picturesLoading = false) } + return + } + _state.update { it.copy(picturesLoading = true) } + synchronized(this) { + if (pictureRetry?.isActive == true) return + pictureRetry = viewModelScope.launch { + delay(MailLogic.retryDelayMs(retryAfter)) + pictureTries++ + reloadPictures(fromTimer = true) + } + } + } + + /** The page asks for its pictures again (those it has come from [pictures]). */ + private fun reloadPictures(fromTimer: Boolean = false) { + if (!fromTimer) synchronized(this) { + pictureRetry?.cancel() + pictureRetry = null + } + _state.update { it.copy(pictureRound = it.pictureRound + 1) } + } fun consumed() { _event.value = null @@ -328,6 +464,7 @@ class MailReadViewModel @Inject constructor( private companion object { const val MAX_SUGGEST = 5 + const val PICTURES_MAX_BYTES = 16L * 1024 * 1024 } } diff --git a/app/src/main/res/values-de/strings_mail.xml b/app/src/main/res/values-de/strings_mail.xml index e93961ba..5776c1d5 100644 --- a/app/src/main/res/values-de/strings_mail.xml +++ b/app/src/main/res/values-de/strings_mail.xml @@ -223,7 +223,11 @@ Nicht gefunden – vielleicht wurde es gelöscht. Das hat nicht geklappt – der Stand hat sich geändert. Dateien aus einem Tresor können nicht angehängt werden. - Der Inhalt dieser Mail konnte nicht vom Anbieter geholt werden. + Der Inhalt wird noch vom Anbieter geladen … + Wird gerade geladen … + Der Anhang wird noch vom Anbieter geladen – bitte gleich noch einmal öffnen. + Eingebettete Bilder werden gerade geladen … + Der Anbieter ist gerade beschäftigt – bitte gleich noch einmal versuchen. Die Microsoft-Anmeldung ist auf dem Server nicht eingerichtet. Microsoft-Konten können keine Dateien vom Gerät oder aus Mails anderer Konten senden. Hänge sie aus Drive an (als Link) oder entferne sie. Bilder bleiben für diese Mail blockiert. diff --git a/app/src/main/res/values/strings_mail.xml b/app/src/main/res/values/strings_mail.xml index 2c9ebdf9..3f293026 100644 --- a/app/src/main/res/values/strings_mail.xml +++ b/app/src/main/res/values/strings_mail.xml @@ -223,7 +223,11 @@ Not found – it may have been deleted. That did not work – the state has changed. Files from a vault cannot be attached. - The content of this mail could not be fetched from the provider. + The content is still being loaded from the provider … + Loading … + The attachment is still being loaded from the provider – please open it again in a moment. + Embedded pictures are loading … + The provider is busy right now – please try again in a moment. Microsoft sign-in is not set up on the server. Microsoft accounts cannot send files from the device or from another account’s mail. Attach them from Drive (as a link) or remove them. Pictures stay blocked for this mail. diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt index 21acf085..b3cec8f5 100644 --- a/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt +++ b/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt @@ -215,6 +215,40 @@ class MailJsonTest { assertNull(MailJson.changed(null)) } + @Test + fun `the realtime event since 1_1_2 - a body, an attachment, a thread`() { + assertEquals( + MChanged(3, null, messageId = 42, attachmentId = 7), + MailJson.changed(JsonParser.parseString("""{"kind":"changed","accountId":3,"folderId":null,"messageId":42,"attachmentId":7}""")), + ) + assertEquals(MChanged(3, 10, threadId = 9), MailJson.changed(JsonParser.parseString("""{"mail":{"kind":"changed","accountId":3,"folderId":10,"threadId":9}}"""))) + assertEquals(MChanged(3, null), MailJson.changed(JsonParser.parseString("""{"kind":"changed","accountId":3,"messageId":0,"attachmentId":"x"}"""))) + } + + @Test + fun `account picture - media id and a raster mime, else none`() { + val a = MailJson.account(obj("""{"id":5,"provider":"m365","email":"t@firma.de","avatar":{"mediaId":"ab_12","mime":"image/jpeg"}}"""))!! + assertEquals(MAvatar("ab_12", "image/jpeg"), a.avatar) + assertNull(MailJson.account(obj("""{"id":5,"provider":"m365","email":"t@firma.de","avatar":null}"""))!!.avatar) + assertNull(MailJson.account(obj(account))!!.avatar, "older servers send none") + assertNull(MailJson.account(obj("""{"id":5,"email":"t@firma.de","avatar":{"mediaId":"../x","mime":"image/png"}}"""))!!.avatar) + assertNull(MailJson.account(obj("""{"id":5,"email":"t@firma.de","avatar":{"mediaId":"m1","mime":"image/svg+xml"}}"""))!!.avatar) + } + + @Test + fun `busy - when to ask again and whether it is still loading`() { + val loading = obj("""{"code":"busy","reason":"loading","retryAfter":5}""") + assertEquals(5, MailJson.busyRetryAfter("busy", loading)) + assertTrue(MailJson.busyLoading("busy", loading)) + assertEquals(12, MailJson.busyRetryAfter("busy", obj("""{"error":{"code":"busy","retryAfter":12}}"""))) + assertFalse(MailJson.busyLoading("busy", obj("""{"error":{"code":"busy","retryAfter":12}}""")), "throttled, not loading") + assertEquals(5, MailJson.busyRetryAfter("busy", null)) + assertEquals(60, MailJson.busyRetryAfter("busy", obj("""{"retryAfter":3600}"""))) + assertEquals(1, MailJson.busyRetryAfter("busy", obj("""{"retryAfter":0}"""))) + assertNull(MailJson.busyRetryAfter("not_found", loading)) + assertFalse(MailJson.busyLoading("conflict", loading)) + } + @Test fun `bodies - drafts with every kind of attachment, flags, moves, accounts`() { val body = MailJson.draftBody( diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt index ffdd29ef..58d63d44 100644 --- a/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt +++ b/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt @@ -221,4 +221,32 @@ class MailLogicTest { assertEquals("attachment", MailLogic.fileName(null)) assertNotNull(MailLogic.isEmail("a@b.de")) } + + @Test + fun `the provider's mark of an account without a picture`() { + assertEquals(MailLogic.Mark.OUTLOOK, MailLogic.mark("outlook")) + assertEquals(MailLogic.Mark.M365, MailLogic.mark("m365")) + assertEquals(MailLogic.Mark.GMAIL, MailLogic.mark("gmail")) + assertEquals(MailLogic.Mark.GMX, MailLogic.mark("gmx")) + assertEquals(MailLogic.Mark.WEBDE, MailLogic.mark("webde")) + assertEquals(MailLogic.Mark.TONLINE, MailLogic.mark("tonline")) + assertEquals(MailLogic.Mark.OTHER, MailLogic.mark("imap")) + assertEquals(MailLogic.Mark.OTHER, MailLogic.mark(null)) + } + + @Test + fun `loading later - the event of the open mail, waits, embedded pictures`() { + assertTrue(MailLogic.concerns(MChanged(1, null, messageId = 42), 42, null)) + assertTrue(MailLogic.concerns(MChanged(1, null, threadId = 9), 42, 9)) + assertFalse(MailLogic.concerns(MChanged(1, 10), 42, 9), "a plain list change is not the body") + assertFalse(MailLogic.concerns(MChanged(1, null, messageId = 43, threadId = 8), 42, 9)) + assertFalse(MailLogic.concerns(MChanged(1, null), 42, null)) + assertEquals(5_000L, MailLogic.retryDelayMs(5)) + assertEquals(5_000L, MailLogic.retryDelayMs(null)) + assertEquals(1_000L, MailLogic.retryDelayMs(0)) + assertEquals(60_000L, MailLogic.retryDelayMs(999)) + assertEquals(7L, MailLogic.cidAttachment("cid/42/7")) + assertNull(MailLogic.cidAttachment("img/abc")) + assertNull(MailLogic.cidAttachment("cid/42/x")) + } } From 5ca0025b49da2e9b2893e09e4454a87177589e56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 22:55:47 +0000 Subject: [PATCH 2/2] feat(mail): breite Mails passen, Bilder sofort, Ordner auf- und zuklappen, Unterhaltung, Absender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Breite Mails (Newsletter mit fester Tabellenbreite) passen in die Breite: Bilder, Tabellen, lange Links und pre bleiben im Bildschirm; was danach noch breiter ist, misst die WebView von außen (Inhaltsbreite, kein Skript) und die Mail wird per CSS zoom kleiner gesetzt (MailLogic.fitZoom, höchstens 3 Runden, nie unter 25 %). Aufziehen zum Vergrößern bleibt möglich, die Mail selbst bleibt ohne Skript (JavaScript aus, CSP script-src 'none') - „Bilder laden“: das HTML bleibt dasselbe (img/), daher lud die WebView nicht neu. Jetzt übernimmt der State die zurückgegebene Mail und zählt pictureRound hoch; Antworten der Seite gehen mit Cache-Control: no-store, verweigerte Bilder werden nie gehalten - Seitenmenü: ein Tipp auf die Kontozeile (mit Pfeil) klappt den Ordnerbaum auf und zu, das Menü bleibt offen; „Posteingang“ steht immer im Baum, erst die Wahl eines Ordners schließt das Menü. Der Klappzustand pro Konto bleibt für die Sitzung (MailRepository.folds) - Unterhaltung: „Unterhaltung · n Mails“ mit den Absendern; ein Tipp zeigt alle Mails (GET threads/), älteste zuerst, die geöffnete aufgeklappt, die neueste unten; jede andere Mail mit Absender, Datum und Vorschau aufklappbar, mit „Mail öffnen“ - Absender antippen: Absender-Seite mit Initialen, Name, Adresse, „Mail schreiben“ und „Adresse kopieren“, darunter seine Mails im Konto (GET search?from=, Seiten über den Cursor); Name aus contacts?q= bei source 'person'; Platz für die Kontaktdaten des geplanten Kontakte-Plugins - Tests: Seitenaufbau und Skalierung, Klappzustand, Unterhaltung, Absender (MailLogic), State nach „Bilder laden“, Laden der Unterhaltung und der Absender-Mails (ViewModels), Route der Absender-Seite Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xX1efcZF1f6G9rhmaJNLD --- .../com/gatecontrol/android/mail/MailLogic.kt | 84 ++++++- .../gatecontrol/android/mail/MailModels.kt | 8 +- .../android/mail/MailRepository.kt | 10 + .../android/navigation/MailNavigation.kt | 14 ++ .../gatecontrol/android/navigation/Screen.kt | 11 + .../android/ui/mail/MailComponents.kt | 6 +- .../android/ui/mail/MailHomeScreen.kt | 79 ++++-- .../android/ui/mail/MailHomeViewModel.kt | 5 + .../android/ui/mail/MailHtmlView.kt | 71 +++++- .../android/ui/mail/MailReadScreen.kt | 202 ++++++++++++--- .../android/ui/mail/MailReadViewModel.kt | 55 +++- .../android/ui/mail/MailSenderScreen.kt | 136 ++++++++++ .../android/ui/mail/MailSenderViewModel.kt | 98 ++++++++ app/src/main/res/values-de/strings_mail.xml | 19 ++ app/src/main/res/values/strings_mail.xml | 19 ++ .../android/mail/MailReaderUxTest.kt | 124 +++++++++ .../android/navigation/MainTabsTest.kt | 2 + .../ui/mail/MailReaderViewModelsTest.kt | 235 ++++++++++++++++++ 18 files changed, 1092 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt create mode 100644 app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt create mode 100644 app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt create mode 100644 app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt index 3bdd18d1..9a4f2fae 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt @@ -74,6 +74,51 @@ object MailLogic { /** Whether a mail still belongs to the list it is in after [action] (archive, delete, junk, move take it away). */ fun leavesList(action: String): Boolean = action in setOf(SWIPE_ARCHIVE, SWIPE_DELETE, "junk", "move", "tab") + // ─── the menu: an account's folders ───────────────────────────── + + /** + * Whether the menu shows [accountId]'s folders: as the person folded it this session ([folds]), else open for the + * account of the [box] shown. + */ + fun foldOpen(folds: Map, accountId: Long, box: Box): Boolean = folds[accountId] ?: (box.accountId == accountId) + + /** [folds] after a tap on the account (its folders open or close; the menu stays). */ + fun toggleFold(folds: Map, accountId: Long, box: Box): Map = + folds + (accountId to !foldOpen(folds, accountId, box)) + + // ─── a conversation ───────────────────────────────────────────── + + /** + * A conversation (`GET threads/`) around the open mail [currentId]: the mails before it and those after it, + * each oldest first (the newest at the bottom, as in Outlook). Without the open mail in it, all count as before. + */ + fun conversation(items: List, currentId: Long): Pair, List> { + val sorted = items.distinctBy { it.id }.sortedWith(compareBy({ it.summary.date }, { it.id })) + val at = sorted.indexOfFirst { it.id == currentId } + if (at < 0) return sorted to emptyList() + return sorted.subList(0, at) to sorted.subList(at + 1, sorted.size) + } + + /** The senders of a conversation for its line ("Anna, Tim"), each once, at most [max]. */ + fun participants(items: List, max: Int = 3): List = + items.map { it.from }.distinctBy { it.email.lowercase() }.map { it.label }.filter { it.isNotBlank() }.take(max) + + // ─── a sender ─────────────────────────────────────────────────── + + /** `GET search` for the mails of [email] in the account [accountId] (all accounts without one). */ + fun senderQuery(email: String, accountId: Long?, cursor: String? = null): Map = buildMap { + put("from", email.trim().lowercase()) + put("box", accountId?.toString() ?: BOX_ALL) + cursor?.let { put("cursor", it) } + } + + /** The server's `from` matches a part of the address: only the mails of exactly [email] stay. */ + fun fromSender(items: List, email: String): List = items.filter { it.from.email.trim().equals(email.trim(), ignoreCase = true) } + + /** The name of [email] in the person's contacts (`contacts?q=`, a `person` hit), null when there is none. */ + fun contactOf(contacts: List, email: String): MContact? = + contacts.firstOrNull { it.source == MContact.SOURCE_PERSON && it.email.trim().equals(email.trim(), ignoreCase = true) && !it.name.isNullOrBlank() } + // ─── swipes ───────────────────────────────────────────────────── const val SWIPE_ARCHIVE = MSettings.SWIPE_ARCHIVE @@ -311,21 +356,50 @@ object MailLogic { return parseRecipients(addr).addresses.firstOrNull() } + /** The page's margin (CSS px) around the mail. */ + const val PAGE_PADDING = 12 + + /** Below this a mail is not made any smaller to fit (pinching still zooms in). */ + const val MIN_ZOOM = 0.25 + + /** How often a page is laid out again to fit (each time only smaller, so it always ends). */ + const val FIT_ROUNDS = 3 + /** * The page the WebView shows: the mail's (already cleaned) HTML with a strict content policy (no scripts, no - * frames, pictures only from the app's own address) and a width that fits the phone. + * frames, pictures only from the app's own address) and a width that fits the phone: pictures, tables, long + * links and preformatted text stay inside the screen. What is still wider (a newsletter's table of a fixed 600 px) + * is made smaller as a whole with [zoom] (CSS `zoom` on the mail, as mail apps do), measured from outside by + * [fitZoom] — the page itself never runs a script. Pinching still zooms in. */ - fun page(html: String, dark: Boolean = false): String { + fun page(html: String, dark: Boolean = false, zoom: Double = 1.0): String { val csp = "default-src 'none'; img-src $BASE_URL data:; style-src 'unsafe-inline'; font-src data:; " + "script-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'" val colors = if (dark) "color-scheme: light;" else "" + val z = zoom.coerceIn(MIN_ZOOM, 1.0) + val fit = if (z < 1.0) "#gc-fit{zoom:${String.format(java.util.Locale.ROOT, "%.3f", z)}}" else "" return "" + "" + "" + - "" + - html + "" + "img{max-width:100%;height:auto}table{max-width:100%}td,th{overflow-wrap:anywhere}" + + "a{overflow-wrap:anywhere;word-break:break-word}pre{white-space:pre-wrap;overflow-wrap:anywhere}$fit" + + "
" + html + "
" + } + + /** + * The [page]'s zoom so the mail fits without scrolling sideways, from what the WebView measured: [viewCss] its + * width and [contentCss] the width of what it shows (both in CSS px, the content at the [current] zoom). Null + * when it fits already (1 px of rounding allowed) or would not get smaller; never below [MIN_ZOOM]. + */ + fun fitZoom(viewCss: Double, contentCss: Double, current: Double = 1.0): Double? { + if (viewCss <= 2 * PAGE_PADDING || contentCss <= viewCss + 1.0 || current <= MIN_ZOOM) return null + // the mail at zoom 1 (the margin on the left is not zoomed; one on the right may count or not) + val mail = (contentCss - PAGE_PADDING) / current + val zoom = ((viewCss - 2 * PAGE_PADDING) / mail).coerceIn(MIN_ZOOM, 1.0) + val rounded = kotlin.math.floor(zoom * 1000) / 1000 + return rounded.takeIf { it < current - 0.001 } } // ─── the body of a draft ──────────────────────────────────────── diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt index 38b78037..29980096 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -320,7 +320,13 @@ data class MOutboxItem( val error: String?, ) -data class MContact(val name: String?, val email: String, val source: String) +data class MContact(val name: String?, val email: String, val source: String) { + companion object { + /** A contact of the person (the coming contacts plugin); `recent`: someone written with lately. */ + const val SOURCE_PERSON = "person" + const val SOURCE_RECENT = "recent" + } +} /** "Zum Ablegen": an attachment the AI was not sure about. */ data class MFilingItem( diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt index 35a03de3..3276ee81 100644 --- a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt +++ b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -63,6 +64,15 @@ class MailRepository @Inject constructor( private val _undo = MutableStateFlow(null) val undo: StateFlow = _undo.asStateFlow() + /** The menu's folded accounts (account id → folders shown), kept while the app runs. */ + private val _folds = MutableStateFlow>(emptyMap()) + val folds: StateFlow> = _folds.asStateFlow() + + /** A tap on an account in the menu: its folders open or close ([MailLogic.toggleFold]). */ + fun toggleFold(accountId: Long, box: MailLogic.Box) { + _folds.update { MailLogic.toggleFold(it, accountId, box) } + } + @Volatile private var meAt = 0L private var refreshJob: Job? = null diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt index 7b28104b..9ef61f6b 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -16,6 +16,8 @@ import com.gatecontrol.android.ui.mail.MailOutboxScreen import com.gatecontrol.android.ui.mail.MailReadNav import com.gatecontrol.android.ui.mail.MailReadScreen import com.gatecontrol.android.ui.mail.MailSearchScreen +import com.gatecontrol.android.ui.mail.MailSenderNav +import com.gatecontrol.android.ui.mail.MailSenderScreen import com.gatecontrol.android.ui.mail.MailSettingsScreen /** Mail's ways between its screens (the tab, a page beside Start and the routes on top share them). */ @@ -71,6 +73,18 @@ internal fun NavGraphBuilder.mailRoutes( compose = { mode, reply -> if (id != null) go(Screen.MailCompose.create(ref = id, mode = mode, reply = reply)) }, mailTo = { to -> go(Screen.MailCompose.create(to = to)) }, open = { other -> navController.navigate(Screen.MailMessage.create(other)) }, + sender = { email, name, account -> go(Screen.MailSender.create(email, name, account)) }, + ), + ) + } + } + composable(Screen.MailSender.route, arguments = listOf(optional(Screen.MailSender.ARG_EMAIL), optional(Screen.MailSender.ARG_NAME), optional(Screen.MailSender.ARG_ACCOUNT))) { + GcContentWidth { + MailSenderScreen( + MailSenderNav( + back = back, + write = { to, account -> go(Screen.MailCompose.create(account = account, to = to)) }, + open = { id -> navController.navigate(Screen.MailMessage.create(id)) }, ), ) } diff --git a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt index 17ffac74..ce817ac6 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -160,6 +160,17 @@ sealed class Screen(val route: String) { } } data object MailSearch : Screen("mail/search") + /** A sender's page: their mails in the account `account`. */ + data object MailSender : Screen("mail/sender?email={email}&name={name}&account={account}") { + const val ARG_EMAIL = "email" + const val ARG_NAME = "name" + const val ARG_ACCOUNT = "account" + fun create(email: String, name: String? = null, account: Long? = null): String { + fun enc(v: String) = java.net.URLEncoder.encode(v, "UTF-8").replace("+", "%20") + val args = listOfNotNull("email=" + enc(email), name?.takeIf { it.isNotBlank() }?.let { "name=" + enc(it.take(200)) }, account?.let { "account=$it" }) + return "mail/sender?" + args.joinToString("&") + } + } /** "Zum Ablegen": attachments the AI was not sure about. */ data object MailFiling : Screen("mail/filing") /** "Konto hinzufügen"; `account` = sign in to that Microsoft account again. */ diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt index 65252464..20f5e0b4 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt @@ -235,9 +235,9 @@ internal fun FileBadge(name: String, mime: String, size: Int = 40) { /** The round avatar with the sender's initials in the account's color. */ @Composable -internal fun SenderAvatar(name: String?, email: String, color: Color) { - Box(Modifier.size(40.dp).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { - Text(MailLogic.initials(name, email), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = 14.sp) +internal fun SenderAvatar(name: String?, email: String, color: Color, size: Dp = 40.dp) { + Box(Modifier.size(size).clip(CircleShape).background(color), contentAlignment = Alignment.Center) { + Text(MailLogic.initials(name, email), color = Color(0xFF0D1015), fontWeight = FontWeight.Bold, fontSize = (size.value * 0.35f).sp) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt index 94c5e5ec..0740eb47 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -114,6 +115,7 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) val undo by viewModel.undo.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() val reopen by viewModel.reopen.collectAsStateWithLifecycle() + val folds by viewModel.folds.collectAsStateWithLifecycle() val context = mailContext() val scope = rememberCoroutineScope() val drawer = rememberDrawerState(DrawerValue.Closed) @@ -165,6 +167,8 @@ fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) box = box, filing = filing, outbox = outbox, + folds = folds, + onFold = viewModel::toggleFold, onBox = { close(); viewModel.selectBox(it) }, onFiling = { close(); nav.filing() }, onOutbox = { close(); nav.outbox() }, @@ -573,8 +577,9 @@ internal object MailFolders { /** * The menu (mockup "Seitenmenü"): "Mail", "Alle Posteingänge", "Zum Ablegen" (with Drive), "Postausgang" (when - * something waits), the accounts — the chosen one with its folders — and below "Konto hinzufügen" and the settings. - * Calendar and resources come later (phase 2/3). + * something waits), the accounts with their folders and below "Konto hinzufügen" and the settings. A tap on an + * account opens or closes its folders (the menu stays, [folds] keeps it for the session); picking its "Posteingang" + * or a folder closes the menu. Calendar and resources come later (phase 2/3). */ @Composable private fun MailDrawer( @@ -583,6 +588,8 @@ private fun MailDrawer( box: MailLogic.Box, filing: Int, outbox: Int, + folds: Map, + onFold: (Long) -> Unit, onBox: (MailLogic.Box) -> Unit, onFiling: () -> Unit, onOutbox: () -> Unit, @@ -611,31 +618,26 @@ private fun MailDrawer( item(key = "accounts") { DrawerLabel(stringResource(R.string.mail_accounts)) } } accounts.forEach { a -> + val open = MailLogic.foldOpen(folds, a.id, box) item(key = "a${a.id}") { - DrawerAccount(a, me?.unreadByAccount?.get(a.id) ?: 0, on = box.accountId == a.id && box.folderId == null) { - onBox(MailLogic.Box(a.id)) - } + DrawerAccount(a, me?.unreadByAccount?.get(a.id) ?: 0, on = !open && box.accountId == a.id, open = open) { onFold(a.id) } } - if (box.accountId == a.id) { + if (open) { val list = MailFolders.ordered(folders[a.id].orEmpty()) + // the account's inbox is always there (also before its folders came) + if (list.none { it.role == MFolder.ROLE_INBOX }) { + item(key = "i${a.id}") { + DrawerFolder(stringResource(R.string.mail_inbox), 0, on = box.accountId == a.id && box.folderId == null) { onBox(MailLogic.Box(a.id)) } + } + } list.forEach { f -> item(key = "f${f.id}") { - val label = MailFolders.label(f) - Row( - Modifier - .fillMaxWidth() - .heightIn(min = 40.dp) - .clip(RoundedCornerShape(12.dp)) - .background(if (box.folderId == f.id) extra.accentBg else Color.Transparent) - .selectable(selected = box.folderId == f.id, role = Role.Tab) { - onBox(MailLogic.Box(a.id, f.id.takeIf { f.role != MFolder.ROLE_INBOX })) - } - .padding(start = 58.dp, end = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium, color = if (box.folderId == f.id) extra.accentText else MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - if (f.unread > 0) Text(f.unread.toString(), style = MaterialTheme.typography.labelMedium, color = extra.muted, fontWeight = FontWeight.Bold) - } + val inbox = f.role == MFolder.ROLE_INBOX + DrawerFolder( + MailFolders.label(f), + f.unread, + on = if (inbox) box.accountId == a.id && box.folderId == null else box.folderId == f.id, + ) { onBox(MailLogic.Box(a.id, f.id.takeIf { !inbox })) } } } } @@ -692,24 +694,49 @@ private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, l } } -/** An account in the menu (like the Outlook app): its picture or the provider's mark, the name, the address below. */ +/** A folder of an account in the menu (indented below it). */ @Composable -private fun DrawerAccount(a: MAccount, count: Int, on: Boolean, onClick: () -> Unit) { +private fun DrawerFolder(label: String, unread: Int, on: Boolean, onClick: () -> Unit) { val extra = GateControlTheme.extraColors Row( Modifier .fillMaxWidth() - .heightIn(min = 56.dp) + .heightIn(min = 40.dp) .clip(RoundedCornerShape(12.dp)) .background(if (on) extra.accentBg else Color.Transparent) .selectable(selected = on, role = Role.Tab, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(start = 58.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + if (unread > 0) Text(unread.toString(), style = MaterialTheme.typography.labelMedium, color = extra.muted, fontWeight = FontWeight.Bold) + } +} + +/** + * An account in the menu (like the Outlook app): its picture or the provider's mark, the name, the address below and + * the arrow; a tap opens or closes its folders. + */ +@Composable +private fun DrawerAccount(a: MAccount, count: Int, on: Boolean, open: Boolean, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val state = stringResource(if (open) R.string.mail_folders_shown else R.string.mail_folders_hidden) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (on) extra.accentBg else Color.Transparent) + .clickable(role = Role.Button, onClickLabel = stringResource(if (open) R.string.mail_folders_hide else R.string.mail_folders_show), onClick = onClick) + .semantics { stateDescription = state } + .padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { MailAccountAvatar(a, size = 34.dp, ring = if (on) extra.accentBg else MaterialTheme.colorScheme.surface) MailAccountText(a, Modifier.weight(1f), color = if (on) extra.accentText else MaterialTheme.colorScheme.onSurface, bold = on) if (count > 0) Text(count.toString(), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = extra.muted) + Icon(if (open) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt index 504fef92..8421f9df 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt @@ -53,6 +53,8 @@ class MailHomeViewModel @Inject constructor( val meError: StateFlow = repository.meError val folders: StateFlow>> = repository.folders val undo: StateFlow = repository.undo + /** The menu's folded accounts ([MailLogic.foldOpen]). */ + val folds: StateFlow> = repository.folds private val _box = MutableStateFlow(MailLogic.Box(saved.get(KEY_ACCOUNT)?.takeIf { it > 0 }, saved.get(KEY_FOLDER)?.takeIf { it > 0 })) val box: StateFlow = _box.asStateFlow() @@ -107,6 +109,9 @@ class MailHomeViewModel @Inject constructor( } } + /** A tap on an account in the menu: its folders open or close, the menu stays. */ + fun toggleFold(accountId: Long) = repository.toggleFold(accountId, _box.value) + fun selectBox(box: MailLogic.Box) { if (box == _box.value) return _box.value = box diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt index 5206b200..ca67e5b4 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt @@ -1,6 +1,7 @@ package com.gatecontrol.android.ui.mail import android.annotation.SuppressLint +import android.content.Context import android.graphics.Color import android.view.ViewGroup import android.webkit.WebResourceRequest @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier @@ -28,7 +30,11 @@ import java.io.ByteArrayInputStream * file or content access, no forms, storage or windows; the page's requests are answered by the app — the plugin's * pictures (`img/`, `cid//`) loaded with the app's own sign-in through [loader], everything * else answered empty, so the mail never reaches the network by itself. A tap on a link leaves the app ([onLink]). - * Shown on a light sheet: mails are made for one. + * Shown on a light sheet: mails are made for one (the app has no dark rendering of mails). + * + * Wide mails (a newsletter's table of a fixed width) fit the screen: the page keeps pictures and tables inside it, + * and what is still wider is measured from outside (the WebView's content width, no script) and laid out again + * smaller ([MailLogic.fitZoom], at most [MailLogic.FIT_ROUNDS] times). Pinching still zooms in. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -42,12 +48,15 @@ internal fun MailHtmlView( ) { val load = rememberUpdatedState(loader) val link = rememberUpdatedState(onLink) - val page = remember(html) { MailLogic.page(html) } + // the zoom that makes the mail fit (1 until measured), and how often it was laid out again + val zoom = remember(html) { mutableDoubleStateOf(1.0) } + val rounds = remember(html) { intArrayOf(0) } + val page = remember(html, zoom.doubleValue) { MailLogic.page(html, zoom = zoom.doubleValue) } Box(modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(androidx.compose.ui.graphics.Color.White)) { AndroidView( modifier = Modifier.fillMaxWidth(), factory = { ctx -> - WebView(ctx).apply { + MailWebView(ctx).apply { layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) setBackgroundColor(Color.WHITE) isVerticalScrollBarEnabled = false @@ -81,9 +90,18 @@ internal fun MailHtmlView( val path = MailLogic.interceptPath(url) if (path != null && request.method.equals("GET", ignoreCase = true)) { val got = runCatching { runBlocking { load.value(path) } }.getOrNull() - if (got != null) return WebResourceResponse(got.second, null, ByteArrayInputStream(got.first)) + // never kept by the WebView: a picture refused while blocked comes once "Bilder laden" allowed it + if (got != null) return WebResourceResponse(got.second, null, 200, "OK", NO_STORE, ByteArrayInputStream(got.first)) } - return WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) + return WebResourceResponse("text/plain", "utf-8", 404, "Not Found", NO_STORE, ByteArrayInputStream(ByteArray(0))) + } + + override fun onPageFinished(view: WebView, url: String?) { + (view as? MailWebView)?.measureSoon() + } + + override fun onScaleChanged(view: WebView, oldScale: Float, newScale: Float) { + (view as? MailWebView)?.scaleNow = newScale } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { @@ -96,6 +114,13 @@ internal fun MailHtmlView( }, onRelease = { web -> web.destroy() }, update = { web -> + web.onWide = { viewCss, contentCss -> + val next = MailLogic.fitZoom(viewCss, contentCss, zoom.doubleValue) + if (next != null && rounds[0] < MailLogic.FIT_ROUNDS) { + rounds[0]++ + zoom.doubleValue = next + } + } val key = page to round if (web.tag != key) { web.tag = key @@ -105,3 +130,39 @@ internal fun MailHtmlView( ) } } + +/** Answers of the page's requests are never kept by the WebView. */ +private val NO_STORE = mapOf("Cache-Control" to "no-store") + +/** + * The mail's WebView, which can tell how wide its content is ([onWide], in CSS px): after the page loaded and whenever + * its size changes (pictures came). + */ +private class MailWebView(context: Context) : WebView(context) { + /** The page's scale now (the WebView's pixels per CSS px times the pinch). */ + var scaleNow: Float = context.resources.displayMetrics.density + var onWide: ((viewCss: Double, contentCss: Double) -> Unit)? = null + private val measure = Runnable { measureNow() } + + fun measureSoon() { + removeCallbacks(measure) + postDelayed(measure, MEASURE_DELAY_MS) + } + + private fun measureNow() { + // the page is laid out device-wide (viewport width=device-width); the content's width at the scale now (pinched + // or not) in CSS px + val density = resources.displayMetrics.density + if (width <= 0 || scaleNow <= 0f) return + onWide?.invoke(width / density.toDouble(), computeHorizontalScrollRange() / scaleNow.toDouble()) + } + + override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) { + super.onSizeChanged(w, h, ow, oh) + measureSoon() + } + + private companion object { + const val MEASURE_DELAY_MS = 150L + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt index 26838ee3..96f0c037 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -72,12 +73,15 @@ data class MailReadNav( val mailTo: (String) -> Unit, /** Another mail of the conversation. */ val open: (Long) -> Unit, + /** The sender's page (their mails in the account [accountId]). */ + val sender: (email: String, name: String?, accountId: Long) -> Unit = { _, _, _ -> }, ) /** - * A mail (mockup "Mail lesen mit KI und Anhang nach Drive"): archive, delete and more on top; subject, sender and - * account; the AI bar ("Kurz gesagt", to-dos as chips); the attachments with Drive's suggestion; "Bilder laden"; - * the body; the earlier mails of the conversation; answer suggestions, "Antworten" and "Weiterleiten" below. + * A mail (mockup "Mail lesen mit KI und Anhang nach Drive"): archive, delete and more on top; subject, the + * conversation ("Unterhaltung · n Mails": a tap shows all its mails, oldest first, each unfolding), sender (a tap + * opens the sender's page) and account; the AI bar ("Kurz gesagt", to-dos as chips); the attachments with Drive's + * suggestion; "Bilder laden"; the body; answer suggestions, "Antworten" and "Weiterleiten" below. */ @Composable fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewModel()) { @@ -152,11 +156,35 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Header(m, account?.label, accountColor(account)) + Subject(m) + // the conversation: its line, and once tapped the mails before this one (oldest first) + val (older, newer) = remember(state.conversation, m.id) { MailLogic.conversation(state.conversation, m.id) } + val thread = me?.settings?.threads != false && m.summary.threadId != null && m.summary.threadCount > 1 + val threadMail: @Composable (MMessage) -> Unit = { t -> + ThreadMail( + t, + color = accountColor(me?.account(t.summary.accountId)), + unfolded = t.id in state.threadUnfolded, + onFold = { viewModel.toggleThreadMail(t.id) }, + onOpen = { nav.open(t.id) }, + loader = viewModel::picture, + onLink = onLink, + ) + } + if (thread) { + ThreadLine( + count = maxOf(m.summary.threadCount, state.conversation.size), + names = MailLogic.participants(state.conversation.map { it.summary }.ifEmpty { listOf(m.summary) }), + open = state.threadOpen, + loading = state.threadLoading, + onToggle = viewModel::toggleThread, + ) + if (state.threadOpen) older.forEach { threadMail(it) } + } + SenderLine(m, account?.label, accountColor(account), onSender = { nav.sender(m.summary.from.email, m.summary.from.name, m.summary.accountId) }) if (state.offline) { GcBanner(tone = GcTone.Neutral, icon = GcIcons.Wifi) { Text(stringResource(R.string.mail_offline_copy), style = MaterialTheme.typography.bodySmall) } } - if (state.thread.isNotEmpty()) ThreadBox(state.thread, nav.open) AiCard(state.ai, me, onRetry = { viewModel.runAi(force = true) }, onFamily = viewModel::todoToFamily, onCalendar = { todo -> addToCalendar(context, todo, todo.text) }) @@ -223,6 +251,8 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode ) } } + // the mails after this one (the newest at the bottom) + if (thread && state.threadOpen) newer.forEach { threadMail(it) } } BottomBar( replies = (state.ai as? MailAiState.Ready)?.ai?.replies.orEmpty(), @@ -254,28 +284,46 @@ fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewMode } @Composable -private fun Header(m: MMessage, accountLabel: String?, color: androidx.compose.ui.graphics.Color) { +private fun Subject(m: MMessage) { + Text( + m.summary.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.semantics { heading() }, + ) +} + +/** The sender (a tap: their page), the account and the time (a tap: the details). */ +@Composable +private fun SenderLine(m: MMessage, accountLabel: String?, color: androidx.compose.ui.graphics.Color, onSender: () -> Unit) { val extra = GateControlTheme.extraColors var details by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - m.summary.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.semantics { heading() }, - ) - Row( - Modifier.clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_details)) { details = !details }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - MailDot(color) - Text(m.summary.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - Text( - listOfNotNull(accountLabel?.let { stringResource(R.string.mail_to_account, it) }, mailTime(m.summary.date)).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = extra.muted, - maxLines = 1, - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier + .weight(1f, fill = false) + .heightIn(min = 32.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_sender_show), onClick = onSender), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MailDot(color) + Text(m.summary.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Row( + Modifier.heightIn(min = 32.dp).clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_details)) { details = !details }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + listOfNotNull(accountLabel?.let { stringResource(R.string.mail_to_account, it) }, mailTime(m.summary.date)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + ) + Icon(if (details) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + } } if (details) { SelectionContainer { @@ -301,25 +349,99 @@ private fun DetailLine(label: String, list: List) { ) } -/** The earlier mails of the conversation (tap: open one). */ +/** "Unterhaltung · n Mails" with its senders: a tap shows all its mails (or folds them again). */ @Composable -private fun ThreadBox(thread: List, onOpen: (Long) -> Unit) { +private fun ThreadLine(count: Int, names: List, open: Boolean, loading: Boolean, onToggle: () -> Unit) { val extra = GateControlTheme.extraColors + val state = stringResource(if (open) R.string.mail_thread_shown else R.string.mail_thread_hidden) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(role = Role.Button, onClickLabel = stringResource(if (open) R.string.mail_thread_hide else R.string.mail_thread_show), onClick = onToggle) + .semantics { stateDescription = state } + .padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(pluralStringResource(R.plurals.mail_thread, count, count), style = MaterialTheme.typography.labelMedium, color = extra.muted) + if (names.isNotEmpty()) { + Text(names.joinToString(", "), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (loading) CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Icon(if (open) GcIcons.ChevronUp else GcIcons.ChevronDown, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + } +} + +/** + * Another mail of the conversation: sender, time and preview; a tap unfolds it (its body, "Mail öffnen" for the + * attachments and answering). + */ +@Composable +private fun ThreadMail( + t: MMessage, + color: androidx.compose.ui.graphics.Color, + unfolded: Boolean, + onFold: () -> Unit, + onOpen: () -> Unit, + loader: suspend (String) -> Pair?, + onLink: (String) -> Unit, +) { + val extra = GateControlTheme.extraColors + val s = t.summary + val state = stringResource(if (unfolded) R.string.mail_thread_shown else R.string.mail_thread_hidden) Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface)) { - Text( - pluralStringResource(R.plurals.mail_thread, thread.size + 1, thread.size + 1), - style = MaterialTheme.typography.labelMedium, - color = extra.muted, - modifier = Modifier.padding(start = 14.dp, top = 10.dp, bottom = 4.dp), - ) - thread.forEach { t -> - Row( - Modifier.fillMaxWidth().heightIn(min = 44.dp).clickable { onOpen(t.id) }.padding(horizontal = 14.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(t.from.label, style = MaterialTheme.typography.bodyMedium, fontWeight = if (t.unread) FontWeight.Bold else FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - Text(mailTime(t.date), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Row( + Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .clickable(role = Role.Button, onClick = onFold) + .semantics { stateDescription = state } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + SenderAvatar(s.from.name, s.from.email, color, size = 32.dp) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + s.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (s.unread) FontWeight.Bold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text(mailTime(s.date), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + if (!unfolded) { + val preview = s.snippet.ifBlank { s.subject } + if (preview.isNotBlank()) Text(preview, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + if (unfolded) { + Column(Modifier.fillMaxWidth().padding(start = 8.dp, end = 8.dp, bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val html = t.html + if (html != null) { + val page = remember(html, t.attachments) { MailLogic.rewriteCid(html, t.id, t.attachments) } + MailHtmlView(page, loader = loader, onLink = onLink) + } else { + SelectionContainer { + Text(t.text.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), modifier = Modifier.padding(horizontal = 6.dp)) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (t.imagesBlocked || t.attachments.any { !it.inline }) { + Icon(if (t.imagesBlocked) GcIcons.Photo else GcIcons.Paperclip, contentDescription = null, tint = extra.muted, modifier = Modifier.padding(start = 6.dp).size(14.dp)) + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onOpen) { Text(stringResource(R.string.mail_thread_open)) } + } } } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt index 48484112..6bf7c961 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt @@ -61,8 +61,13 @@ data class MailReadState( val loading: Boolean = true, val offline: Boolean = false, val error: String? = null, - /** The other mails of the conversation, oldest first. */ - val thread: List = emptyList(), + /** The mails of the conversation (`GET threads/`, this one among them), oldest first; empty until loaded. */ + val conversation: List = emptyList(), + /** "Unterhaltung · n Mails" was tapped: all its mails show, the open one unfolded. */ + val threadOpen: Boolean = false, + val threadLoading: Boolean = false, + /** The other mails of the conversation unfolded. */ + val threadUnfolded: Set = emptySet(), val attachments: List = emptyList(), val ai: MailAiState = MailAiState.Off, val imagesBusy: Boolean = false, @@ -200,10 +205,7 @@ class MailReadViewModel @Inject constructor( val me = repository.me.value ?: repository.refresh().valueOrNull() val account = me?.account(m.summary.accountId) // the conversation (when the person groups by conversation) - val threadId = m.summary.threadId - if (me?.settings?.threads != false && threadId != null && m.summary.threadCount > 1) { - api.thread(threadId).valueOrNull()?.let { items -> _state.update { it.copy(thread = items.map { t -> t.summary }.filter { t -> t.id != m.id }) } } - } + if (me?.settings?.threads != false && hasThread(m)) loadThread() // the AI bar: only where the server offers AI and the account allows it if (me?.has(MMe.FEATURE_AI) == true && account?.ai != false) { if (_state.value.ai !is MailAiState.Ready) _state.update { it.copy(ai = MailAiState.Loading) } @@ -222,6 +224,35 @@ class MailReadViewModel @Inject constructor( } } + private fun hasThread(m: MMessage): Boolean = m.summary.threadId != null && m.summary.threadCount > 1 + + /** The conversation's mails (`GET threads/`, oldest first). */ + private suspend fun loadThread() { + val threadId = _state.value.message?.summary?.threadId ?: return + if (_state.value.threadLoading) return + _state.update { it.copy(threadLoading = true) } + val r = api.thread(threadId) + _state.update { s -> + when (r) { + is MResult.Ok -> s.copy(conversation = r.value, threadLoading = false) + else -> s.copy(threadLoading = false) + } + } + if (r !is MResult.Ok && _state.value.threadOpen) _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + + /** A tap on "Unterhaltung · n Mails": all mails of the conversation show (loaded now when not yet), or fold again. */ + fun toggleThread() { + val open = !_state.value.threadOpen + _state.update { it.copy(threadOpen = open) } + if (open && _state.value.conversation.isEmpty()) viewModelScope.launch { loadThread() } + } + + /** Another mail of the conversation unfolded or folded. */ + fun toggleThreadMail(id: Long) { + _state.update { s -> s.copy(threadUnfolded = if (id in s.threadUnfolded) s.threadUnfolded - id else s.threadUnfolded + id) } + } + private fun aiError(r: MResult<*>): MailAiState = when ((r as? MResult.Failed)?.code) { "ai_off" -> MailAiState.Off else -> MailAiState.Error(MailHomeViewModel.code(r)) @@ -258,6 +289,8 @@ class MailReadViewModel @Inject constructor( is MResult.Ok -> { show(r.value.first, loading = false) cache.putMessage(r.value.second) + // the HTML is the same as before (its pictures' addresses do not change): the page loads them now + imagesAllowed(sender) } else -> _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) } @@ -449,6 +482,16 @@ class MailReadViewModel @Inject constructor( } } + /** + * The pictures were allowed (`POST messages//images`): those the page was refused are asked for again — none of + * them is kept (a refusal never is, and the WebView keeps no answer); always for the [sender]: the conversation's + * mails come again with theirs. + */ + private fun imagesAllowed(sender: Boolean) { + reloadPictures() + if (sender && _state.value.conversation.isNotEmpty()) viewModelScope.launch { loadThread() } + } + /** The page asks for its pictures again (those it has come from [pictures]). */ private fun reloadPictures(fromTimer: Boolean = false) { if (!fromTimer) synchronized(this) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt new file mode 100644 index 00000000..13d0e1d5 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderScreen.kt @@ -0,0 +1,136 @@ +package com.gatecontrol.android.ui.mail + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +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.text.selection.SelectionContainer +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** Where the sender's page leads. */ +data class MailSenderNav( + val back: () -> Unit, + /** A new mail to [to] from the account [accountId]. */ + val write: (to: String, accountId: Long?) -> Unit, + val open: (Long) -> Unit, +) + +/** + * A sender (a tap on the sender of a mail): avatar or initials, name and address, "Mail schreiben" and "Adresse + * kopieren"; below all their mails in the account, page by page. + */ +@Composable +fun MailSenderScreen(nav: MailSenderNav, viewModel: MailSenderViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + val account = me?.account(state.accountId) + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.mail_sender_title), onBack = nav.back, backDescription = stringResource(R.string.mail_back)) + LazyColumn(Modifier.weight(1f)) { + item(key = "head") { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SenderAvatar(state.label.takeIf { it != state.email }, state.email, accountColor(account), size = 72.dp) + Text( + state.label, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp).semantics { heading() }, + ) + if (state.label != state.email) { + SelectionContainer { Text(state.email, style = MaterialTheme.typography.bodyMedium, color = extra.muted, textAlign = TextAlign.Center) } + } + SenderContact(state.contact) + Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcPrimaryButton( + stringResource(R.string.mail_sender_write), + onClick = { nav.write(state.email, state.accountId) }, + modifier = Modifier.weight(1f), + icon = MailIcons.Pen, + minHeight = 44.dp, + ) + GcOutlineButton( + stringResource(R.string.mail_sender_copy), + onClick = { + copy(context, state.email) + toast(context, context.getString(R.string.mail_sender_copied)) + }, + modifier = Modifier.weight(1f), + icon = GcIcons.Copy, + minHeight = 44.dp, + ) + } + } + } + item(key = "label") { + Text( + if (account != null) stringResource(R.string.mail_sender_mails_in, account.label) else stringResource(R.string.mail_sender_mails), + style = MaterialTheme.typography.labelMedium, + color = extra.muted, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp).semantics { heading() }, + ) + } + items(state.items, key = { it.id }) { item -> + MailRow(item = item, account = me?.account(item.accountId), onClick = { nav.open(item.id) }, onLongClick = { nav.open(item.id) }) + MailDivider() + } + when { + state.loading -> item(key = "busy") { + Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } + } + state.error != null && state.items.isEmpty() -> item(key = "error") { ErrorBox(mailText(context, state.error), onRetry = viewModel::load) } + state.items.isEmpty() -> item(key = "none") { ErrorBox(stringResource(R.string.mail_sender_none), onRetry = null) } + state.next != null -> item(key = "more") { TextButton(onClick = viewModel::more, modifier = Modifier.padding(16.dp)) { Text(stringResource(R.string.mail_more_results)) } } + } + } + } +} + +/** + * What the person's contacts know about the sender. For now only that they are one (the name above comes from + * there); the coming contacts plugin fills this place (phone, company, …) — no contacts are kept by Mail itself. + */ +@Composable +private fun SenderContact(contact: MContact?) { + if (contact == null) return + Text(stringResource(R.string.mail_sender_in_contacts), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.accentText) +} + +private fun copy(context: Context, text: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + runCatching { cm.setPrimaryClip(ClipData.newPlainText("Mail", text)) } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt new file mode 100644 index 00000000..19d8c025 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailSenderViewModel.kt @@ -0,0 +1,98 @@ +package com.gatecontrol.android.ui.mail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +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 javax.inject.Inject + +data class MailSenderState( + val email: String = "", + /** The name the mail gave. */ + val name: String? = null, + val accountId: Long? = null, + /** + * The sender in the person's contacts (`contacts?q=`, a `person` hit), null without one. The place for what the + * coming contacts plugin knows about them; for now only its name is shown. + */ + val contact: MContact? = null, + /** Their mails in the account, newest first. */ + val items: List = emptyList(), + val next: String? = null, + val loading: Boolean = true, + val error: String? = null, +) { + /** The name shown: from the contacts first, else from the mail. */ + val label: String get() = contact?.name?.takeIf { it.isNotBlank() } ?: name?.takeIf { it.isNotBlank() } ?: email +} + +/** A sender's page: who it is (the contacts' name when there is one) and their mails in the account (`GET search?from=`). */ +@HiltViewModel +class MailSenderViewModel @Inject constructor( + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + val me: StateFlow = repository.me + + private val _state = MutableStateFlow( + MailSenderState( + email = saved.get(Screen.MailSender.ARG_EMAIL)?.trim().orEmpty(), + name = saved.get(Screen.MailSender.ARG_NAME)?.trim()?.takeIf { it.isNotEmpty() }, + accountId = saved.get(Screen.MailSender.ARG_ACCOUNT)?.toLongOrNull()?.takeIf { it > 0 }, + ), + ) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + /** The first page of their mails and their contact. */ + fun load() { + val s = _state.value + if (!MailLogic.isEmail(s.email)) { + _state.update { it.copy(loading = false, error = "not_found") } + return + } + _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + api.contacts(s.email).valueOrNull()?.let { list -> _state.update { it.copy(contact = MailLogic.contactOf(list, s.email)) } } + } + viewModelScope.launch { + when (val r = api.search(MailLogic.senderQuery(s.email, s.accountId))) { + is MResult.Ok -> _state.update { it.copy(items = MailLogic.fromSender(r.value.items, s.email), next = r.value.next, loading = false, error = null) } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + /** The next page (the cursor of the last one). */ + fun more() { + val s = _state.value + val next = s.next ?: return + if (s.loading) return + _state.update { it.copy(loading = true) } + viewModelScope.launch { + when (val r = api.search(MailLogic.senderQuery(s.email, s.accountId, next))) { + is MResult.Ok -> _state.update { + it.copy(items = MailLogic.merge(it.items, MailLogic.fromSender(r.value.items, s.email)), next = r.value.next, loading = false) + } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } +} diff --git a/app/src/main/res/values-de/strings_mail.xml b/app/src/main/res/values-de/strings_mail.xml index 5776c1d5..ab0c63c4 100644 --- a/app/src/main/res/values-de/strings_mail.xml +++ b/app/src/main/res/values-de/strings_mail.xml @@ -238,6 +238,25 @@ Dieses Konto gibt es schon. Drive-Links sind ausgeschaltet. Das hat nicht geklappt. + + Ordner zeigen + Ordner ausblenden + Ordner aufgeklappt + Ordner zugeklappt + Alle Mails der Unterhaltung zeigen + Unterhaltung zuklappen + Aufgeklappt + Zugeklappt + Mail öffnen + Absender zeigen + Absender + Mail schreiben + Adresse kopieren + Adresse kopiert + Mails von diesem Absender + Mails von diesem Absender in %1$s + Noch keine Mails von diesem Absender. + In deinen Kontakten Unterhaltung · %d Mail Unterhaltung · %d Mails diff --git a/app/src/main/res/values/strings_mail.xml b/app/src/main/res/values/strings_mail.xml index 3f293026..1f20b20d 100644 --- a/app/src/main/res/values/strings_mail.xml +++ b/app/src/main/res/values/strings_mail.xml @@ -238,6 +238,25 @@ This account exists already. Drive links are switched off. That did not work. + + Show folders + Hide folders + Folders shown + Folders hidden + Show all mails of the conversation + Hide the conversation + Unfolded + Folded + Open mail + Show sender + Sender + Write mail + Copy address + Address copied + Mails from this sender + Mails from this sender in %1$s + No mails from this sender yet. + In your contacts Conversation · %d mail Conversation · %d mails diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt new file mode 100644 index 00000000..f4f512c0 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailReaderUxTest.kt @@ -0,0 +1,124 @@ +package com.gatecontrol.android.mail + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** The reader's rules: a wide mail fits, the menu's folded accounts, a conversation, a sender's mails. */ +class MailReaderUxTest { + + private fun s(id: Long, date: Long, from: String = "a@b.de", name: String? = "Anna") = MSummary( + id = id, accountId = 1, folderId = null, threadId = 9, threadCount = 3, from = MAddress(name, from), to = emptyList(), + subject = "s$id", snippet = "", date = date, unread = false, flagged = false, answered = false, hasAttachments = false, + tab = MSummary.TAB_RELEVANT, savedToDrive = false, aiSummary = null, + ) + + private fun m(id: Long, date: Long, from: String = "a@b.de", name: String? = "Anna") = + MMessage(s(id, date, from, name), emptyList(), emptyList(), emptyList(), "

$id

", null, false, 0, emptyList(), null) + + // ─── a wide mail fits ─────────────────────────────────────────── + + @Test + fun `the page keeps pictures, tables, links and pre inside and wraps the mail for the zoom`() { + val page = MailLogic.page("
x
") + assertTrue(page.contains("name=\"viewport\" content=\"width=device-width, initial-scale=1\""), "device wide, pinching allowed") + assertFalse(page.contains("user-scalable"), "pinching stays allowed") + assertTrue(page.contains("img{max-width:100%;height:auto}")) + assertTrue(page.contains("table{max-width:100%}")) + assertTrue(page.contains("pre{white-space:pre-wrap")) + assertTrue(page.contains("a{overflow-wrap:anywhere")) + assertTrue(page.contains("
")) + assertFalse(page.contains("zoom:"), "not zoomed until measured") + assertTrue(page.contains("script-src 'none'"), "the mail never runs a script") + } + + @Test + fun `a measured zoom goes into the page, never below the minimum`() { + assertTrue(MailLogic.page("

x

", zoom = 0.6).contains("#gc-fit{zoom:0.600}")) + assertTrue(MailLogic.page("

x

", zoom = 0.05).contains("#gc-fit{zoom:0.250}")) + assertFalse(MailLogic.page("

x

", zoom = 1.4).contains("zoom:")) + } + + @Test + fun `fitZoom - a 600 px newsletter on a 360 px phone is made smaller, a fitting mail is not`() { + // 360 wide, the mail's table 600 + the left margin + val z = MailLogic.fitZoom(360.0, 612.0)!! + assertEquals((360.0 - 24) / 600, z, 0.002) + // laid out at that zoom it fits: no further round + assertNull(MailLogic.fitZoom(360.0, 12 + 600 * z + 12)) + // fits already (1 px of rounding) + assertNull(MailLogic.fitZoom(360.0, 360.0)) + assertNull(MailLogic.fitZoom(360.0, 361.0)) + // measured again at a zoom: the next is computed from the mail at zoom 1 + val again = MailLogic.fitZoom(360.0, 12 + 600 * 0.8, current = 0.8)!! + assertEquals((360.0 - 24) / 600, again, 0.002) + // never below the minimum, never larger than now + assertEquals(MailLogic.MIN_ZOOM, MailLogic.fitZoom(360.0, 5000.0)!!, 0.0001) + assertNull(MailLogic.fitZoom(360.0, 5000.0, current = MailLogic.MIN_ZOOM)) + assertNull(MailLogic.fitZoom(0.0, 600.0)) + } + + // ─── the menu: folded accounts ────────────────────────────────── + + @Test + fun `an account's folders - open for the account shown until folded, then as the person left them`() { + val box = MailLogic.Box(3) + assertTrue(MailLogic.foldOpen(emptyMap(), 3, box), "the shown account starts open") + assertFalse(MailLogic.foldOpen(emptyMap(), 4, box)) + // the open one closes again (it could not before) + val closed = MailLogic.toggleFold(emptyMap(), 3, box) + assertFalse(MailLogic.foldOpen(closed, 3, box)) + val other = MailLogic.toggleFold(closed, 4, box) + assertTrue(MailLogic.foldOpen(other, 4, box)) + assertFalse(MailLogic.foldOpen(other, 3, box), "each account keeps its own") + // picking another box later keeps what the person chose + assertFalse(MailLogic.foldOpen(other, 3, MailLogic.Box(3, 11))) + assertTrue(MailLogic.foldOpen(MailLogic.toggleFold(other, 3, box), 3, box)) + } + + // ─── a conversation ───────────────────────────────────────────── + + @Test + fun `a conversation around the open mail - older above, newer below, each oldest first`() { + val items = listOf(m(3, 300), m(1, 100), m(2, 200), m(4, 400)) + val (older, newer) = MailLogic.conversation(items, 2) + assertEquals(listOf(1L), older.map { it.id }) + assertEquals(listOf(3L, 4L), newer.map { it.id }) + // the newest open: all others above + assertEquals(listOf(1L, 2L, 3L) to emptyList(), MailLogic.conversation(items, 4).let { (a, b) -> a.map { it.id } to b.map { it.id } }) + // not in it: all above + assertEquals(4, MailLogic.conversation(items, 99).first.size) + // the same date: by id + assertEquals(listOf(5L, 6L), MailLogic.conversation(listOf(m(6, 100), m(5, 100)), 0).first.map { it.id }) + } + + @Test + fun `a conversation's senders, each once`() { + val list = listOf(s(1, 1, "a@b.de", "Anna"), s(2, 2, "tim@b.de", "Tim"), s(3, 3, "A@B.de", "Anna B."), s(4, 4, "x@y.de", null)) + assertEquals(listOf("Anna", "Tim", "x@y.de"), MailLogic.participants(list)) + assertEquals(listOf("Anna"), MailLogic.participants(list, max = 1)) + } + + // ─── a sender ─────────────────────────────────────────────────── + + @Test + fun `a sender's mails - the search in the account, page by page, exactly that address`() { + assertEquals(mapOf("from" to "anna@b.de", "box" to "3"), MailLogic.senderQuery(" Anna@b.de ", 3)) + assertEquals(mapOf("from" to "anna@b.de", "box" to "all", "cursor" to "c-1"), MailLogic.senderQuery("anna@b.de", null, "c-1")) + val items = listOf(s(1, 1, "anna@b.de"), s(2, 2, "hanna@b.de"), s(3, 3, "ANNA@b.de")) + assertEquals(listOf(1L, 3L), MailLogic.fromSender(items, "anna@b.de").map { it.id }) + } + + @Test + fun `a sender's name from the contacts - only a person's`() { + val contacts = listOf( + MContact("Anna Recent", "anna@b.de", MContact.SOURCE_RECENT), + MContact("Anna Müller", "Anna@B.de", MContact.SOURCE_PERSON), + ) + assertEquals("Anna Müller", MailLogic.contactOf(contacts, "anna@b.de")?.name) + assertNull(MailLogic.contactOf(contacts.take(1), "anna@b.de"), "someone written with lately is no contact") + assertNull(MailLogic.contactOf(listOf(MContact(null, "anna@b.de", MContact.SOURCE_PERSON)), "anna@b.de"), "without a name") + } +} diff --git a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt index fcf50598..c34a6887 100644 --- a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt +++ b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt @@ -201,5 +201,7 @@ class MainTabsTest { assertEquals("mail/accounts/add", Screen.MailAddAccount.create()) assertEquals("mail/accounts/add?account=3", Screen.MailAddAccount.create(3)) assertEquals("mail/accounts/3", Screen.MailAccount.create(3)) + assertEquals("mail/sender?email=a%40b.de&name=Anna%20M%C3%BCller&account=3", Screen.MailSender.create("a@b.de", "Anna Müller", 3)) + assertEquals("mail/sender?email=a%40b.de", Screen.MailSender.create("a@b.de", " ", null)) } } diff --git a/app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt b/app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt new file mode 100644 index 00000000..dd3bb4ad --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/ui/mail/MailReaderViewModelsTest.kt @@ -0,0 +1,235 @@ +package com.gatecontrol.android.ui.mail + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MAddress +import com.gatecontrol.android.mail.MChanged +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MLimits +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MMessage +import com.gatecontrol.android.mail.MMicrosoft +import com.gatecontrol.android.mail.MPicture +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSearchPage +import com.gatecontrol.android.mail.MSettings +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MailApi +import com.gatecontrol.android.mail.MailCache +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** The reading screen after "Bilder laden" and with its conversation; the sender's page. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MailReaderViewModelsTest { + + private val api: MailApi = mockk(relaxed = true) + private val cache: MailCache = mockk(relaxed = true) + private val repository: MailRepository = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val me = MutableStateFlow(null) + private val viewModels = mutableListOf() + + private fun summary(id: Long, date: Long = id * 100, from: String = "news@ionos.de", threadCount: Int = 1, unread: Boolean = false) = MSummary( + id = id, accountId = 1, folderId = 5, threadId = if (threadCount > 1) 77 else null, threadCount = threadCount, + from = MAddress("IONOS", from), to = emptyList(), subject = "s$id", snippet = "snip $id", date = date, unread = unread, + flagged = false, answered = false, hasAttachments = false, tab = MSummary.TAB_RELEVANT, savedToDrive = false, aiSummary = null, + ) + + private fun message(id: Long, blocked: Boolean = false, threadCount: Int = 1) = MMessage( + summary(id, threadCount = threadCount), emptyList(), emptyList(), emptyList(), + "
", "text", blocked, 0, emptyList(), null, + ) + + private fun me(threads: Boolean = true) = MMe( + features = setOf(MMe.FEATURE_MAIL, MMe.FEATURE_IMAGES), limits = MLimits(5, 0, 0, 12), + microsoft = MMicrosoft(null, "https://login.microsoftonline.com/common", emptyList()), accounts = emptyList(), + unreadAll = 0, unreadByAccount = emptyMap(), settings = MSettings.DEFAULT.copy(threads = threads), + ) + + @BeforeEach + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + every { repository.api } returns api + every { repository.cache } returns cache + every { repository.me } returns me + every { repository.folders } returns MutableStateFlow(emptyMap()) + every { repository.changed } returns MutableSharedFlow() + coEvery { cache.message(any()) } returns null + every { context.getString(any()) } returns "text" + } + + @AfterEach + fun tearDown() { + runBlocking { viewModels.forEach { it.viewModelScope.coroutineContext[Job]!!.cancelAndJoin() } } + Dispatchers.resetMain() + } + + private fun reader(id: Long): MailReadViewModel = + MailReadViewModel(context, repository, SavedStateHandle(mapOf(Screen.MailMessage.ARG_ID to id.toString()))).also { viewModels += it } + + // ─── "Bilder laden" ───────────────────────────────────────────── + + @Test + fun `after Bilder laden the returned mail is shown and the page loads its pictures again at once`() { + me.value = me() + coEvery { api.message(1) } returns MResult.Ok(message(1, blocked = true) to JsonObject()) + coEvery { api.allowImages(1, false) } returns MResult.Ok(message(1, blocked = false) to JsonObject()) + val vm = reader(1) + val before = vm.state.value + assertTrue(before.message!!.imagesBlocked) + + vm.allowImages(sender = false) + + val after = vm.state.value + assertFalse(after.message!!.imagesBlocked, "the server's answer is the state") + assertFalse(after.imagesBusy) + // the same HTML as before (the pictures' addresses do not change): only a new round makes the WebView load again + assertEquals(before.message!!.html, after.message!!.html) + assertEquals(before.pictureRound + 1, after.pictureRound) + coVerify { cache.putMessage(any(), any()) } + } + + @Test + fun `a picture refused while blocked is not kept - it is asked for again after Bilder laden`() { + me.value = me() + coEvery { api.message(1) } returns MResult.Ok(message(1, blocked = true) to JsonObject()) + coEvery { api.picture("img/1.0.sig") } returnsMany listOf(MPicture.None, MPicture.Got(byteArrayOf(1, 2), "image/png")) + val vm = reader(1) + assertNull(runBlocking { vm.picture("img/1.0.sig") }) + assertEquals("image/png", runBlocking { vm.picture("img/1.0.sig") }!!.second) + coVerify(exactly = 2) { api.picture("img/1.0.sig") } + } + + @Test + fun `Bilder laden refused - a toast, the mail stays as it was`() { + me.value = me() + coEvery { api.message(1) } returns MResult.Ok(message(1, blocked = true) to JsonObject()) + coEvery { api.allowImages(1, true) } returns MResult.Failed("images_off", 409) + val vm = reader(1) + vm.allowImages(sender = true) + assertTrue(vm.state.value.message!!.imagesBlocked) + assertEquals(0, vm.state.value.pictureRound) + assertTrue(vm.event.value is MailReadEvent.Toast) + } + + // ─── the conversation ─────────────────────────────────────────── + + @Test + fun `the conversation loads with the mail, oldest first, and opens on a tap`() { + me.value = me(threads = true) + coEvery { api.message(2) } returns MResult.Ok(message(2, threadCount = 3) to JsonObject()) + coEvery { api.thread(77) } returns MResult.Ok(listOf(message(1, threadCount = 3), message(2, threadCount = 3), message(3, threadCount = 3))) + val vm = reader(2) + assertEquals(listOf(1L, 2L, 3L), vm.state.value.conversation.map { it.id }) + assertFalse(vm.state.value.threadOpen, "folded until tapped") + + vm.toggleThread() + assertTrue(vm.state.value.threadOpen) + vm.toggleThreadMail(1) + assertEquals(setOf(1L), vm.state.value.threadUnfolded) + vm.toggleThreadMail(1) + assertTrue(vm.state.value.threadUnfolded.isEmpty()) + vm.toggleThread() + assertFalse(vm.state.value.threadOpen) + coVerify(exactly = 1) { api.thread(77) } + } + + @Test + fun `without grouping the conversation loads only when tapped`() { + me.value = me(threads = false) + coEvery { api.message(2) } returns MResult.Ok(message(2, threadCount = 2) to JsonObject()) + coEvery { api.thread(77) } returns MResult.Ok(listOf(message(1, threadCount = 2), message(2, threadCount = 2))) + val vm = reader(2) + assertTrue(vm.state.value.conversation.isEmpty()) + coVerify(exactly = 0) { api.thread(any()) } + vm.toggleThread() + assertEquals(listOf(1L, 2L), vm.state.value.conversation.map { it.id }) + assertFalse(vm.state.value.threadLoading) + } + + @Test + fun `a single mail has no conversation to load`() { + me.value = me() + coEvery { api.message(2) } returns MResult.Ok(message(2) to JsonObject()) + val vm = reader(2) + vm.toggleThread() + coVerify(exactly = 0) { api.thread(any()) } + } + + // ─── the sender's page ────────────────────────────────────────── + + private fun sender(email: String = "news@ionos.de", name: String? = "IONOS", account: Long? = 1): MailSenderViewModel = + MailSenderViewModel( + repository, + SavedStateHandle( + mapOfNotNull( + Screen.MailSender.ARG_EMAIL to email, + Screen.MailSender.ARG_NAME to name, + Screen.MailSender.ARG_ACCOUNT to account?.toString(), + ), + ), + ).also { viewModels += it } + + private fun mapOfNotNull(vararg pairs: Pair): Map = pairs.filter { it.second != null }.toMap() + + @Test + fun `the sender's mails in the account, page by page, with the contact's name`() { + coEvery { api.contacts("news@ionos.de") } returns MResult.Ok(listOf(MContact("IONOS Kundenservice", "news@ionos.de", MContact.SOURCE_PERSON))) + coEvery { api.search(mapOf("from" to "news@ionos.de", "box" to "1")) } returns + MResult.Ok(MSearchPage(listOf(summary(9), summary(8, from = "othernews@ionos.de")), "c-8", false)) + coEvery { api.search(mapOf("from" to "news@ionos.de", "box" to "1", "cursor" to "c-8")) } returns + MResult.Ok(MSearchPage(listOf(summary(7), summary(9)), null, false)) + val vm = sender() + val s = vm.state.value + assertEquals("IONOS Kundenservice", s.label, "the contacts' name first") + assertEquals(listOf(9L), s.items.map { it.id }, "exactly this address") + assertEquals("c-8", s.next) + assertFalse(s.loading) + + vm.more() + assertEquals(listOf(9L, 7L), vm.state.value.items.map { it.id }) + assertNull(vm.state.value.next) + vm.more() + coVerify(exactly = 2) { api.search(any()) } + } + + @Test + fun `without a contact the mail's name, without that the address`() { + coEvery { api.contacts(any()) } returns MResult.Ok(listOf(MContact("Recent", "news@ionos.de", MContact.SOURCE_RECENT))) + coEvery { api.search(any()) } returns MResult.Ok(MSearchPage(emptyList(), null, false)) + assertEquals("IONOS", sender().state.value.label) + assertEquals("news@ionos.de", sender(name = null).state.value.label) + assertNull(sender().state.value.contact) + } + + @Test + fun `no address - nothing is asked`() { + val vm = sender(email = "kein-mail") + assertEquals("not_found", vm.state.value.error) + coVerify(exactly = 0) { api.search(any()) } + } +}