diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ab95c154..96aad397 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -55,6 +55,12 @@ # with the server's order (pinned pages lost, 1.67.27/28). Field names stay # stable across releases like Familie's. -keep class com.gatecontrol.android.layout.** { ; } +# Mail (plugin gatecontrol-mail): answers are read from the Gson tree +# (MailJson) and the offline copy keeps the server's raw JSON (MailCache), +# nothing reflective; the models keep their field names like the door's, in +# case they are ever (de)serialized by reflection. PluginAvailability.mail is +# covered by the familie rule above. +-keep class com.gatecontrol.android.mail.** { ; } # ── WireGuard ───────────────────────────────────────────── -keep class com.wireguard.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08238547..c3b36fc8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -120,6 +120,28 @@ + + + + + + + + + + diff --git a/app/src/main/java/com/gatecontrol/android/GateControlApp.kt b/app/src/main/java/com/gatecontrol/android/GateControlApp.kt index 53ad3258..b77b3f2e 100644 --- a/app/src/main/java/com/gatecontrol/android/GateControlApp.kt +++ b/app/src/main/java/com/gatecontrol/android/GateControlApp.kt @@ -113,6 +113,13 @@ class GateControlApp : Application() { Timber.e(e, "Failed to start GateDrop") } + // Mail: the Microsoft redirect only with the plugin; its offline copy goes with the plugin or the login. + try { + EntryPointAccessors.fromApplication(this, com.gatecontrol.android.mail.MailEntryPoint::class.java).mailStartup().start() + } catch (e: Throwable) { + Timber.w(e, "Mail: not started") + } + // Google Play build: in-app update (network watch, install in the background). if (com.gatecontrol.android.update.PlayUpdater.enabled) { try { diff --git a/app/src/main/java/com/gatecontrol/android/MailSigninActivity.kt b/app/src/main/java/com/gatecontrol/android/MailSigninActivity.kt new file mode 100644 index 00000000..3a2304d4 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/MailSigninActivity.kt @@ -0,0 +1,32 @@ +package com.gatecontrol.android + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.gatecontrol.android.mail.MailMicrosoft +import com.gatecontrol.android.mail.MailSigninRedirects + +/** + * Receives `msauth://com.gatecontrol.client/?code=…`, the browser's answer to "Mit Microsoft anmelden" of a + * mail account, and hands it to the running Mail screen ([MailSigninRedirects]), bringing up [MainActivity] again + * (which closes the Custom Tab above it). Invisible, never shown in Recents. + * + * Exported because the browser opens it, and enabled only while the server offers Mail (MailStartup). The link + * carries a one-time code that is useless without the PKCE verifier, and the Mail screen drops any answer whose + * state it did not create. + */ +class MailSigninActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val raw = intent?.dataString + if (raw != null && MailMicrosoft.isRedirect(raw)) { + MailSigninRedirects.offer(raw) + startActivity( + Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + } + finish() + } +} diff --git a/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt b/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt index 54ab03eb..45defda5 100644 --- a/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt +++ b/app/src/main/java/com/gatecontrol/android/familie/PluginDirectory.kt @@ -69,6 +69,11 @@ data class PluginAvailability( * listed for this user: the tab "Drive". */ val drive: Boolean = false, + /** + * The Mail plugin is listed with an app tab for this user (installed and the person allowed, its `appVisible`): + * the tab "Mail" (contract D). + */ + val mail: Boolean = false, ) { companion object { val NONE = PluginAvailability() @@ -88,6 +93,7 @@ data class PluginAvailability( gatedrop = plugins.any { it.id == com.gatecontrol.android.gatedrop.GATEDROP_PLUGIN }, vault = plugins.any { it.id == com.gatecontrol.android.vault.VAULT_PLUGIN }, drive = plugins.any { it.id == com.gatecontrol.android.drive.DRIVE_PLUGIN }, + mail = plugins.any { it.id == com.gatecontrol.android.mail.MAIL_PLUGIN && it.tab != null }, ) } diff --git a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt index 435706be..0bd55d50 100644 --- a/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt +++ b/app/src/main/java/com/gatecontrol/android/gatedrop/GateDropPush.kt @@ -129,13 +129,14 @@ class GateDropPushHandler @Inject constructor( } } -/** Realtime events of the push stream for the chats, GateDrop and Drive. */ +/** Realtime events of the push stream for the chats, GateDrop, Drive and Mail. */ @Singleton class AppPluginEventSink @Inject constructor( private val chats: com.gatecontrol.android.chat.ChatEventSink, private val gatedrop: dagger.Lazy, private val gatedropRepository: dagger.Lazy, private val drive: dagger.Lazy, + private val mail: dagger.Lazy, ) : PluginEventSink { override fun onPluginEvent(plugin: String, type: String, data: JsonElement?) { if (plugin == GATEDROP_PLUGIN) { @@ -146,6 +147,10 @@ class AppPluginEventSink @Inject constructor( runCatching { drive.get().onPluginEvent(type, data) } return } + if (plugin == com.gatecontrol.android.mail.MAIL_PLUGIN) { + runCatching { mail.get().onPluginEvent(type, data) } + return + } chats.onPluginEvent(plugin, type, data) } @@ -155,6 +160,8 @@ class AppPluginEventSink @Inject constructor( runCatching { gatedropRepository.get().pollPendingSoon() } // Drive's changes of that time: whatever is open loads again. runCatching { drive.get().onStreamOpen() } + // Mail: the lists load again when the tab is open + runCatching { mail.get().onStreamOpen() } } } diff --git a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt index 540beec3..7b21de5a 100644 --- a/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt +++ b/app/src/main/java/com/gatecontrol/android/layout/StartPages.kt @@ -2,7 +2,7 @@ package com.gatecontrol.android.layout /** * Pages beside the start tab (pure, unit-tested): functions that are a tab - * otherwise (Chats, Vault, Drive, Netzwerk, Familie) pinned to Start, and + * otherwise (Chats, Mail, Vault, Drive, Netzwerk, Familie) pinned to Start, and * widget pages ("Widgets 2", "Widgets 3" …) holding quick actions and * widgets like Start does. * @@ -22,13 +22,14 @@ object StartPages { const val MARKER = "page:" const val CHATS = "chats" + const val MAIL = "mail" const val VAULT = "vault" const val DRIVE = "drive" const val NETWORK = "network" const val FAMILIE = "familie" /** Functions that can be pages, in the order of the tab bar. */ - val FEATURES = listOf(CHATS, VAULT, DRIVE, NETWORK, FAMILIE) + val FEATURES = listOf(CHATS, MAIL, VAULT, DRIVE, NETWORK, FAMILIE) private val WIDGETS = Regex("^w([0-9]{1,3})$") diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt new file mode 100644 index 00000000..445dc6f7 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailApi.kt @@ -0,0 +1,326 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.data.SettingsRepository +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.ApiClient +import com.gatecontrol.android.network.ApiClientProvider +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.net.URLEncoder +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The Mail plugin's app API (contract C2) through the core's plugin bridge + * (`/api/v1/client/plugins/gatecontrol-mail/api/…`) and GateControl's media routes. The only place that knows the + * routes: one method per route, answers parsed by [MailJson]. Paths are built from fixed segments and server ids only. + * Never throws (except cancellation). + */ +@Singleton +class MailApi @Inject constructor( + private val setupRepository: SetupRepository, + private val apiClientProvider: ApiClientProvider, + private val settingsRepository: SettingsRepository, +) { + companion object { + const val CODE_INVALID = "invalid_answer" + const val CODE_OFFLINE = "offline" + /** The plugin API under the server address (the WebView's pictures and `GET providers` are read raw). */ + const val API_PREFIX = "api/v1/client/plugins/$MAIL_PLUGIN/api/" + private const val TIMEOUT_MS = 25_000L + /** Adding an account checks IMAP and SMTP first; the server's own time-outs are 30 s each. */ + private const val ACCOUNT_TIMEOUT_MS = 75_000L + 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 val _unauthorized = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** A call answered 401 (the device's token is gone): the offline copy is no longer this person's to keep. */ + val unauthorized: SharedFlow = _unauthorized.asSharedFlow() + + suspend fun lang(): String = + runCatching { settingsRepository.getLocale().first() }.getOrNull()?.takeIf { it == "de" || it == "en" } ?: "de" + + private suspend fun langQuery(): Map = mapOf("lang" to lang()) + + private suspend fun call( + timeoutMs: Long = TIMEOUT_MS, + media: Boolean = false, + request: suspend ApiClient.() -> Response, + parse: (JsonObject) -> T?, + ): MResult { + val url = setupRepository.getServerUrl() + if (url.isBlank() || !setupRepository.isConfigured()) return MResult.NotConfigured + return try { + val client = if (media) apiClientProvider.mediaClient(url) else apiClientProvider.getClient(url) + val response = withTimeoutOrNull(timeoutMs) { client.request() } ?: return MResult.Offline + if (response.isSuccessful) { + val body = response.body() ?: JsonObject() + if (MailJson.isRefusal(body)) return MResult.Failed(MailJson.failureCode(response.code(), body), response.code(), body.obj()) + parse(body)?.let { MResult.Ok(it) } ?: MResult.Failed(CODE_INVALID, response.code()) + } else { + if (response.code() == 401) _unauthorized.tryEmit(401) + val err = errorJson(response) + MResult.Failed(MailJson.failureCode(response.code(), err), response.code(), err?.obj()) + } + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + MResult.Offline + } catch (e: Exception) { + Timber.d("Mail: call failed (%s)", e.javaClass.simpleName) + MResult.Failed(CODE_INVALID, 0) + } + } + + /** The error object (`{ error: { code, … } }`), or the whole answer when it has none. */ + private fun JsonObject.obj(): JsonObject = get("error")?.takeIf { it.isJsonObject }?.asJsonObject ?: this + + private fun errorJson(response: Response<*>): JsonObject? = runCatching { + response.errorBody()?.use { b -> b.string().takeIf { it.length < 65_536 } } + ?.let { JsonParser.parseString(it).takeIf { e -> e.isJsonObject }?.asJsonObject } + }.getOrNull() + + private suspend fun get(path: String, query: Map = emptyMap(), parse: (JsonObject) -> T?) = + call(request = { pluginGet(MAIL_PLUGIN, path, query + langQuery()) }, parse = parse) + + private suspend fun post(path: String, body: JsonObject, timeoutMs: Long = TIMEOUT_MS, parse: (JsonObject) -> T?) = + call(timeoutMs = timeoutMs, request = { pluginPost(MAIL_PLUGIN, path, body, langQuery()) }, parse = parse) + + private suspend fun put(path: String, body: JsonObject, parse: (JsonObject) -> T?) = + call(request = { pluginPut(MAIL_PLUGIN, path, body, langQuery()) }, parse = parse) + + private suspend fun patch(path: String, body: JsonObject, timeoutMs: Long = TIMEOUT_MS, parse: (JsonObject) -> T?) = + call(timeoutMs = timeoutMs, request = { pluginPatch(MAIL_PLUGIN, path, body, langQuery()) }, parse = parse) + + private suspend fun del(path: String, parse: (JsonObject) -> T?) = + call(request = { pluginDelete(MAIL_PLUGIN, path, langQuery()) }, parse = parse) + + private val unit: (JsonObject) -> Unit = { } + + /** A GET whose answer is not an object (`GET providers` is an array): read raw, parsed here. */ + private suspend fun getAny(path: String, parse: (JsonElement) -> T?): MResult { + val url = setupRepository.getServerUrl() + if (url.isBlank() || !setupRepository.isConfigured()) return MResult.NotConfigured + return try { + val r = withTimeoutOrNull(TIMEOUT_MS) { + apiClientProvider.getClient(url).getRaw(API_PREFIX + path + "?lang=" + URLEncoder.encode(lang(), "UTF-8")) + } ?: return MResult.Offline + val text = r.body()?.use { b -> b.string().takeIf { it.length < 1_000_000 } } + if (!r.isSuccessful) { + if (r.code() == 401) _unauthorized.tryEmit(401) + val err = r.errorBody()?.use { b -> runCatching { JsonParser.parseString(b.string()).asJsonObject }.getOrNull() } + return MResult.Failed(MailJson.failureCode(r.code(), err), r.code(), err) + } + val el = text?.let { runCatching { JsonParser.parseString(it) }.getOrNull() } ?: return MResult.Failed(CODE_INVALID, r.code()) + parse(el)?.let { MResult.Ok(it) } ?: MResult.Failed(CODE_INVALID, r.code()) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + MResult.Offline + } catch (e: Exception) { + Timber.d("Mail: call failed (%s)", e.javaClass.simpleName) + MResult.Failed(CODE_INVALID, 0) + } + } + + // ─── account, status ──────────────────────────────────────────── + + /** `GET me` with the raw answer (kept for offline). */ + suspend fun me(): MResult> = get("me") { o -> MailJson.me(o)?.let { it to o } } + + suspend fun putSettings(threads: Boolean? = null, swipeLeft: String? = null, swipeRight: String? = null): MResult = + put("settings", MailJson.settingsBody(threads, swipeLeft, swipeRight), MailJson::settingsAnswer) + + suspend fun providers(): MResult> = getAny("providers", MailJson::providers) + + suspend fun discover(email: String): MResult = post("accounts/discover", MailJson.discoverBody(email), parse = MailJson::discover) + + suspend fun addAccount(body: JsonObject): MResult = post("accounts", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer) + + suspend fun addMicrosoft(body: JsonObject): MResult = post("accounts/microsoft", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer) + + suspend fun reloginMicrosoft(id: Long, body: JsonObject): MResult = post("accounts/$id/microsoft", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer) + + suspend fun patchAccount(id: Long, body: JsonObject): MResult = patch("accounts/$id", body, ACCOUNT_TIMEOUT_MS, MailJson::accountAnswer) + + suspend fun deleteAccount(id: Long): MResult = del("accounts/$id", unit) + + // ─── folders, lists, search ───────────────────────────────────── + + suspend fun folders(): MResult>, JsonObject>> = get("folders") { o -> MailJson.folders(o)?.let { it to o } } + + suspend fun messages(query: Map): MResult> = get("messages", query) { o -> MailJson.page(o)?.let { it to o } } + + suspend fun search(query: Map): MResult = get("search", query, MailJson::searchPage) + + suspend fun thread(threadId: Long): MResult> = get("threads/$threadId", parse = MailJson::thread) + + suspend fun message(id: Long): MResult> = get("messages/$id") { o -> MailJson.message(o)?.let { it to o } } + + suspend fun allowImages(id: Long, sender: Boolean): MResult> = + post("messages/$id/images", MailJson.imagesBody(sender)) { o -> MailJson.message(o)?.let { it to o } } + + suspend fun flags(ids: Collection, unread: Boolean? = null, flagged: Boolean? = null): MResult> = + post("messages/flags", MailJson.flagsBody(ids, unread, flagged), parse = MailJson::changedItems) + + suspend fun move(ids: Collection, folderId: Long): MResult> = + post("messages/move", MailJson.moveBody(ids, folderId), parse = MailJson::changedItems) + + suspend fun archive(ids: Collection): MResult> = post("messages/archive", MailJson.idsBody(ids), parse = MailJson::changedItems) + + suspend fun delete(ids: Collection): MResult> = post("messages/delete", MailJson.idsBody(ids), parse = MailJson::changedItems) + + suspend fun junk(ids: Collection): MResult> = post("messages/junk", MailJson.idsBody(ids), parse = MailJson::changedItems) + + suspend fun setTab(ids: Collection, tab: String): MResult> = post("messages/tab", MailJson.tabBody(ids, tab), parse = MailJson::changedItems) + + suspend fun attachmentMedia(messageId: Long, attachmentId: Long): MResult = + get("messages/$messageId/attachments/$attachmentId", parse = MailJson::attachmentMedia) + + /** A media file of the plugin as a stream (the caller closes it). */ + suspend fun media(mediaId: String): MResult { + if (!Regex("^[A-Za-z0-9_.~-]{1,200}$").matches(mediaId)) return MResult.Failed(CODE_INVALID, 0) + val url = setupRepository.getServerUrl() + if (url.isBlank() || !setupRepository.isConfigured()) return MResult.NotConfigured + return try { + val r = withTimeoutOrNull(TIMEOUT_MS) { apiClientProvider.mediaClient(url).pluginMediaGet(MAIL_PLUGIN, mediaId) } ?: return MResult.Offline + val body = r.body() + if (r.isSuccessful && body != null) MResult.Ok(body) else { + r.errorBody()?.close() + MResult.Failed("http_${r.code()}", r.code()) + } + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + MResult.Offline + } catch (e: Exception) { + MResult.Failed(CODE_INVALID, 0) + } + } + + /** + * 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. + */ + suspend fun picture(path: String): Pair? { + val url = setupRepository.getServerUrl() + if (url.isBlank() || !setupRepository.isConfigured()) return null + 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 picture = MailJson.picture(o) ?: return@withTimeoutOrNull null + val bytes = picture.data ?: picture.mediaId?.let { media -> + val m = apiClientProvider.mediaClient(url).pluginMediaGet(MAIL_PLUGIN, media) + val body = m.body() + if (!m.isSuccessful || body == null) { + m.errorBody()?.close() + body?.close() + null + } else { + body.use { b -> b.byteStream().use { input -> readAtMost(input, IMAGE_MAX_BYTES) } } + } + } ?: return@withTimeoutOrNull null + bytes to picture.mime + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + 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() + val buf = ByteArray(16 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + if (out.size() > max) return null + } + return out.toByteArray() + } + + /** A device file → its media id (`PUT …/media`, raw body). */ + suspend fun upload(file: File, mime: String): MResult { + val type = mime.toMediaTypeOrNull() ?: "application/octet-stream".toMediaTypeOrNull()!! + return call(UPLOAD_TIMEOUT_MS, media = true, request = { pluginMediaUpload(MAIL_PLUGIN, file.asRequestBody(type)) }) { o -> + o.get("mediaId")?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { Regex("^[A-Za-z0-9_.~-]{1,200}$").matches(it) } + } + } + + // ─── writing ──────────────────────────────────────────────────── + + suspend fun createDraft(body: JsonObject): MResult = post("drafts", body, parse = MailJson::draftAnswer) + + suspend fun updateDraft(id: Long, body: JsonObject): MResult = put("drafts/$id", body, MailJson::draftAnswer) + + suspend fun draft(id: Long): MResult = get("drafts/$id", parse = MailJson::draftAnswer) + + suspend fun deleteDraft(id: Long): MResult = del("drafts/$id", unit) + + suspend fun send(draftId: Long, sendAt: Long?): MResult = post("drafts/$draftId/send", MailJson.sendBody(sendAt), parse = MailJson::send) + + /** Stops a send still waiting: the draft it came from (null when the server names none). */ + suspend fun cancel(outboxId: Long): MResult = + when (val r = post("outbox/$outboxId/cancel", JsonObject()) { o -> listOfNotNull(MailJson.cancelled(o)) }) { + is MResult.Ok -> MResult.Ok(r.value.firstOrNull()) + else -> r.notOk() + } + + suspend fun outbox(): MResult> = get("outbox", parse = MailJson::outbox) + + suspend fun contacts(q: String): MResult> = get("contacts", mapOf("q" to q.take(100)), MailJson::contacts) + + // ─── AI ───────────────────────────────────────────────────────── + + suspend fun ai(messageId: Long): MResult = get("ai/$messageId", parse = MailJson::ai) + + /** "Kurz gesagt" etc.: the result that is there, or (with [again]) asked anew (at most 60 a person and hour). */ + suspend fun runAi(messageId: Long, again: Boolean = false): MResult = + post("ai/$messageId", JsonObject().apply { if (again) addProperty("again", true) }, timeoutMs = 60_000L, parse = MailJson::ai) + + suspend fun todoToFamily(messageId: Long, index: Int): MResult = + post("ai/$messageId/todo/${index.coerceAtLeast(0)}", MailJson.todoBody(), parse = MailJson::where) + + // ─── Drive ────────────────────────────────────────────────────── + + suspend fun suggest(messageId: Long, attachmentId: Long): MResult = + post("messages/$messageId/attachments/$attachmentId/drive/suggest", JsonObject(), timeoutMs = 45_000L, parse = MailJson::suggest) + + suspend fun saveToDrive(messageId: Long, attachmentId: Long, folderId: Long?, name: String?, remember: Boolean?): MResult = + post("messages/$messageId/attachments/$attachmentId/drive/save", MailJson.driveSaveBody(folderId, name, remember), timeoutMs = 60_000L, parse = MailJson::savedAnswer) + + suspend fun saveAll(messageId: Long): MResult = post("messages/$messageId/drive/save-all", JsonObject(), timeoutMs = 90_000L, parse = MailJson::saveAll) + + suspend fun filing(): MResult> = get("filing", parse = MailJson::filing) + + suspend fun confirmFiling(id: Long, folderId: Long?, name: String?): MResult = + post("filing/$id/confirm", MailJson.driveSaveBody(folderId, name, null), timeoutMs = 60_000L, parse = MailJson::savedAnswer) + + suspend fun dismissFiling(id: Long): MResult = post("filing/$id/dismiss", JsonObject(), parse = unit) +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt new file mode 100644 index 00000000..e4ccc7bb --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailCache.kt @@ -0,0 +1,196 @@ +package com.gatecontrol.android.mail + +import android.content.Context +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.familie.PluginDirectory +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The Mail tab's offline copy (contract D "Offline"): the last lists (the inbox at least 30 days back), the mails + * opened, `me` and the folders — the server's answers as they came (read with [MailJson], never by reflection). Kept + * per login (server + token) in the app's private, not backed-up files; another login, the plugin gone from the + * server or the token revoked delete it ([wipe]), and so do the files opened from mails. + */ +@Singleton +class MailCache @Inject constructor( + @ApplicationContext private val context: Context, + private val setupRepository: SetupRepository, +) { + private val mutex = Mutex() + + private fun root(): File = File(context.noBackupFilesDir, DIR) + + private fun ownerDir(): File? { + val key = PluginDirectory.ownerKey(setupRepository.getServerUrl(), setupRepository.getApiToken()).takeIf { it.isNotEmpty() } ?: return null + return File(root(), key) + } + + private fun name(key: String): String { + val d = MessageDigest.getInstance("SHA-256").digest(key.toByteArray()) + return d.take(12).joinToString("") { "%02x".format(it) } + } + + private suspend fun io(block: (File) -> T?): T? = withContext(Dispatchers.IO) { + mutex.withLock { + val dir = ownerDir() ?: return@withLock null + // only this login's copy stays + root().listFiles()?.filter { it.name != dir.name }?.forEach { runCatching { it.deleteRecursively() } } + runCatching { block(dir) }.getOrNull() + } + } + + private fun read(file: File): JsonObject? = file.takeIf { it.isFile }?.let { MailJson.parse(it.readText()) } + + private fun write(file: File, o: JsonObject) { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + tmp.writeText(o.toString()) + if (!tmp.renameTo(file)) { + file.delete() + tmp.renameTo(file) + } + } + + // ─── me, folders ──────────────────────────────────────────────── + + suspend fun putMe(raw: JsonObject) { io { write(File(it, "me.json"), raw) } } + + suspend fun me(): MMe? = io { dir -> read(File(dir, "me.json"))?.let(MailJson::me) } + + suspend fun putFolders(raw: JsonObject) { io { write(File(it, "folders.json"), raw) } } + + suspend fun folders(): Map>? = io { dir -> read(File(dir, "folders.json"))?.let(MailJson::folders) } + + // ─── lists ────────────────────────────────────────────────────── + + /** + * A list's page as it came. The first page ([first]) starts the copy afresh, later ones are added; what lies + * beyond the kept window goes ([MailCacheLogic.prune]). + */ + suspend fun putList(key: String, raw: JsonObject, first: Boolean, now: Long = System.currentTimeMillis()) { + io { dir -> + val file = File(dir, "lists/${name(key)}.json") + val items = raw.getAsJsonArray("items")?.toList()?.filter { it.isJsonObject }?.map { it.asJsonObject }.orEmpty() + val old = if (first) emptyList() else read(file)?.getAsJsonArray("items")?.mapNotNull { it.takeIf { e -> e.isJsonObject }?.asJsonObject }.orEmpty() + val kept = MailCacheLogic.prune(MailCacheLogic.mergeRaw(old, items), now) + val out = JsonObject().apply { + add("items", JsonArray().apply { kept.forEach { add(it) } }) + raw.get("counts")?.takeIf { it.isJsonObject }?.let { add("counts", it) } + ?: read(file)?.get("counts")?.let { add("counts", it) } + addProperty("savedAt", now) + } + write(file, out) + } + } + + /** The copy of a list (no `next`: offline, the list ends where the copy ends). */ + suspend fun list(key: String): MPage? = io { dir -> + read(File(dir, "lists/${name(key)}.json"))?.let(MailJson::page)?.copy(next = null) + } + + // ─── mails ────────────────────────────────────────────────────── + + suspend fun putMessage(raw: JsonObject, now: Long = System.currentTimeMillis()) { + val id = raw.get("id")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull()?.takeIf { it > 0 } ?: return + io { dir -> + write(File(dir, "msg/$id.json"), raw) + MailCacheLogic.overflow(File(dir, "msg").listFiles()?.map { it.name to it.lastModified() }.orEmpty(), now) + .forEach { File(dir, "msg/$it").delete() } + } + } + + suspend fun message(id: Long): MMessage? = io { dir -> read(File(dir, "msg/$id.json"))?.let(MailJson::message) } + + /** A mail's copy is gone (deleted, or moved and to be fetched again). */ + suspend fun forgetMessages(ids: Collection) { + io { dir -> ids.forEach { File(dir, "msg/$it.json").delete() } } + } + + /** An account was removed: its lists and mails go with the next load; the summary copies of all lists now. */ + suspend fun forgetAccount(accountId: Long) { + io { dir -> + File(dir, "lists").listFiles()?.forEach { f -> + val o = read(f) ?: return@forEach + val items = o.getAsJsonArray("items") ?: return@forEach + val left = JsonArray() + items.forEach { e -> + val acc = e.takeIf { it.isJsonObject }?.asJsonObject?.get("accountId")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull() + if (acc != accountId) left.add(e) + } + o.add("items", left) + write(f, o) + } + File(dir, "msg").listFiles()?.forEach { f -> + val acc = read(f)?.get("accountId")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull() + if (acc == accountId) f.delete() + } + } + } + + /** Everything of the Mail tab on this device: the copies of every login and the files opened from mails. */ + suspend fun wipe() = withContext(Dispatchers.IO) { + mutex.withLock { + runCatching { root().deleteRecursively() } + runCatching { File(context.cacheDir, OPEN_DIR).deleteRecursively() } + runCatching { File(context.cacheDir, UPLOAD_DIR).deleteRecursively() } + } + } + + /** Whether there is anything to wipe (no work at every start when the plugin was never there). */ + fun hasData(): Boolean = root().exists() || File(context.cacheDir, OPEN_DIR).exists() + + companion object { + private const val DIR = "mail" + /** Attachments opened or shared (FileProvider `mail_open`). */ + const val OPEN_DIR = "mail-open" + /** Device files on their way to the server (private). */ + const val UPLOAD_DIR = "mail-up" + } +} + +/** What the offline copy keeps (pure, unit-tested). */ +object MailCacheLogic { + /** At least this many of a list, however old. */ + const val MIN_ITEMS = 100 + /** At most this many of a list. */ + const val MAX_ITEMS = 3000 + /** At most this many opened mails. */ + const val MAX_MESSAGES = 500 + + private fun idOf(o: JsonObject): Long? = o.get("id")?.takeIf { it.isJsonPrimitive }?.asString?.toLongOrNull() + + private fun dateOf(o: JsonObject): Long = o.get("date")?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asLong }.getOrNull() } ?: 0L + + /** Pages one after the other: a newer copy of a mail replaces the older one in its place. */ + fun mergeRaw(old: List, more: List): List { + val byId = more.mapNotNull { o -> idOf(o)?.let { it to o } }.toMap() + val oldIds = old.mapNotNull(::idOf).toHashSet() + return old.map { o -> idOf(o)?.let { byId[it] } ?: o } + more.filter { o -> idOf(o)?.let { it !in oldIds } ?: false } + } + + /** The newest [MIN_ITEMS] and everything of the last 30 days, at most [MAX_ITEMS]; entries without id go. */ + fun prune(items: List, now: Long): List { + val cutoff = now - MailLogic.OFFLINE_KEEP_MS + return items.filter { idOf(it) != null } + .filterIndexed { i, o -> i < MIN_ITEMS || dateOf(o) >= cutoff } + .take(MAX_ITEMS) + } + + /** Copies of opened mails to delete: older than 30 days, and the oldest above [MAX_MESSAGES]. */ + fun overflow(files: List>, now: Long): List { + val cutoff = now - MailLogic.OFFLINE_KEEP_MS + val (old, fresh) = files.partition { it.second < cutoff } + val extra = fresh.sortedByDescending { it.second }.drop(MAX_MESSAGES) + return (old + extra).map { it.first } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt new file mode 100644 index 00000000..ec7fcf3e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailJson.kt @@ -0,0 +1,641 @@ +package com.gatecontrol.android.mail + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** + * The Mail plugin's answers (contract C2), read from the Gson tree (pure, unit-tested): never by reflection, so R8 + * cannot change what is read. Unknown fields are ignored, wrong ones make the entry (or the answer) null; a list keeps + * the entries it can read. Also the request bodies the app sends. + */ +object MailJson { + + private val COLOR = Regex("^#[0-9A-Fa-f]{6}$") + private val TOKEN = Regex("^[A-Za-z0-9_.~-]{1,200}$") + private val CODE = Regex("^[a-z0-9_]{1,64}$") + + // ─── helpers ──────────────────────────────────────────────────── + + private fun JsonObject.el(key: String): JsonElement? = get(key)?.takeIf { it !is JsonNull } + + internal fun JsonObject.str(key: String): String? = + el(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString }.getOrNull() } + + private fun JsonObject.long(key: String): Long? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p -> + val prim = p.asJsonPrimitive + when { + prim.isNumber -> runCatching { prim.asDouble }.getOrNull()?.takeIf { !it.isNaN() && !it.isInfinite() }?.toLong() + prim.isString -> prim.asString.trim().toLongOrNull() + else -> null + } + } + + private fun JsonObject.int(key: String): Int? = long(key)?.takeIf { it in Int.MIN_VALUE..Int.MAX_VALUE }?.toInt() + + private fun JsonObject.double(key: String): Double? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p -> + val prim = p.asJsonPrimitive + when { + prim.isNumber -> prim.asDouble + prim.isString -> prim.asString.trim().toDoubleOrNull() + else -> null + } + }?.takeIf { !it.isNaN() && !it.isInfinite() } + + private fun JsonObject.bool(key: String): Boolean? = el(key)?.takeIf { it.isJsonPrimitive }?.let { p -> + val prim = p.asJsonPrimitive + when { + prim.isBoolean -> prim.asBoolean + prim.isNumber -> prim.asInt != 0 + prim.isString -> when (prim.asString) { "true", "1" -> true; "false", "0" -> false; else -> null } + else -> null + } + } + + internal fun JsonObject.obj(key: String): JsonObject? = el(key)?.takeIf { it.isJsonObject }?.asJsonObject + + internal fun JsonObject.arr(key: String): JsonArray? = el(key)?.takeIf { it.isJsonArray }?.asJsonArray + + private fun JsonArray.objects(): List = mapNotNull { e -> e.takeIf { it.isJsonObject }?.asJsonObject } + + private fun JsonArray.strings(): List = mapNotNull { e -> e.takeIf { it.isJsonPrimitive }?.asString } + + /** A positive id (number or numeric string). */ + private fun JsonObject.id(key: String): Long? = long(key)?.takeIf { it > 0 } + + /** An opaque cursor (`next`): a string or a number, as the server sends it. */ + private fun JsonObject.cursor(key: String): String? = + el(key)?.takeIf { it.isJsonPrimitive }?.asString?.takeIf { it.isNotEmpty() && it.length <= 512 } + + fun parse(raw: String?): JsonObject? = + raw?.let { runCatching { JsonParser.parseString(it) }.getOrNull() }?.takeIf { it.isJsonObject }?.asJsonObject + + // ─── errors ───────────────────────────────────────────────────── + + /** + * The code of a refusal: the plugin's `{ error: { code } }`, the core's `{ ok: false, code }`, else `http_` + * (0: no answer at all). + */ + fun failureCode(http: Int, body: JsonObject?): String { + val code = body?.obj("error")?.str("code") ?: body?.str("error")?.takeIf { CODE.matches(it) } ?: body?.str("code") + return code?.takeIf { CODE.matches(it) } ?: "http_$http" + } + + /** A 2xx answer that still says no (`{ error: { code } }` or `{ ok: false }`). */ + fun isRefusal(body: JsonObject): Boolean = body.obj("error") != null || body.bool("ok") == false + + /** The `hint` of a refusal (`409 auth_failed`): `{ de, en }` or a plain text. */ + fun errorHint(body: JsonObject?, lang: String): String? { + val err = body?.obj("error") ?: body ?: return null + err.str("hint")?.let { return it.takeIf { h -> h.isNotBlank() } } + val h = err.obj("hint") ?: return null + return (if (lang == "en") h.str("en") ?: h.str("de") else h.str("de") ?: h.str("en"))?.takeIf { it.isNotBlank() } + } + + // ─── me, accounts, providers ──────────────────────────────────── + + fun me(o: JsonObject): MMe? { + val features = o.arr("features")?.strings()?.toSet() ?: return null + val limits = o.obj("limits") + val ms = o.obj("microsoft") + val unread = o.obj("unread") + val by = unread?.obj("byAccount")?.entrySet()?.mapNotNull { (k, v) -> + val id = k.toLongOrNull()?.takeIf { it > 0 } ?: return@mapNotNull null + val n = v.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt ?: return@mapNotNull null + id to n.coerceAtLeast(0) + }?.toMap().orEmpty() + return MMe( + features = features, + limits = MLimits( + accountsMax = limits?.int("accountsMax") ?: 5, + quotaBytes = limits?.long("quotaBytes") ?: 0L, + usedBytes = limits?.long("usedBytes") ?: 0L, + retentionMonths = limits?.int("retentionMonths") ?: 12, + ), + microsoft = MMicrosoft( + clientId = ms?.str("clientId")?.trim()?.takeIf { it.isNotEmpty() && it.length <= 200 }, + authority = ms?.str("authority")?.trimEnd('/')?.takeIf { it.startsWith("https://") } ?: MailMicrosoft.DEFAULT_AUTHORITY, + scopes = ms?.arr("scopes")?.strings()?.filter { it.isNotBlank() }?.takeIf { it.isNotEmpty() } ?: MailMicrosoft.DEFAULT_SCOPES, + ), + accounts = o.arr("accounts")?.objects()?.mapNotNull(::account).orEmpty(), + unreadAll = unread?.int("all")?.coerceAtLeast(0) ?: 0, + unreadByAccount = by, + settings = o.obj("settings")?.let(::settings) ?: MSettings.DEFAULT, + ) + } + + fun settings(o: JsonObject): MSettings = MSettings( + threads = o.bool("threads") ?: MSettings.DEFAULT.threads, + swipeLeft = o.str("swipeLeft")?.takeIf { it in MSettings.SWIPES } ?: MSettings.DEFAULT.swipeLeft, + swipeRight = o.str("swipeRight")?.takeIf { it in MSettings.SWIPES } ?: MSettings.DEFAULT.swipeRight, + ) + + /** `{ settings }` of `PUT settings`. */ + fun settingsAnswer(o: JsonObject): MSettings? = o.obj("settings")?.let(::settings) + + fun account(o: JsonObject): MAccount? { + val id = o.id("id") ?: return null + val email = o.str("email")?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val error = o.obj("error") + return MAccount( + id = id, + provider = o.str("provider") ?: MAccount.PROVIDER_IMAP, + email = email, + name = o.str("name")?.trim()?.takeIf { it.isNotEmpty() }, + color = o.str("color")?.takeIf { COLOR.matches(it) }, + kind = o.str("kind")?.takeIf { it == MAccount.KIND_BUSINESS } ?: MAccount.KIND_PERSONAL, + state = o.str("state") ?: MAccount.STATE_OK, + errorCode = error?.str("code"), + errorAt = error?.long("at"), + push = o.str("push")?.takeIf { it in MAccount.PUSH } ?: "all", + ai = o.bool("ai") ?: false, + attachments = o.str("attachments")?.takeIf { it in MAccount.ATTACHMENTS } ?: "off", + signature = o.str("signature")?.takeIf { it.isNotBlank() }, + ) + } + + /** `{ account }`. */ + fun accountAnswer(o: JsonObject): MAccount? = o.obj("account")?.let(::account) + + /** `GET providers`: an array, or `{ items }` / `{ providers }`. */ + fun providers(root: JsonElement): List? { + val arr = when { + root.isJsonArray -> root.asJsonArray + root.isJsonObject -> root.asJsonObject.let { it.arr("providers") ?: it.arr("items") } + else -> null + } ?: return null + return arr.objects().mapNotNull { o -> + val id = o.str("id")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val hint = o.obj("hint") + MProvider( + id = id, + label = o.str("label")?.takeIf { it.isNotBlank() } ?: id, + auth = o.str("auth") ?: MProvider.AUTH_PASSWORD, + hintDe = hint?.str("de")?.takeIf { it.isNotBlank() }, + hintEn = hint?.str("en")?.takeIf { it.isNotBlank() }, + help = o.str("help")?.takeIf { it.startsWith("https://") }, + ) + } + } + + private fun server(o: JsonObject?): MServer? { + o ?: return null + val host = o.str("host")?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val port = o.int("port")?.takeIf { it in 1..65535 } ?: return null + return MServer(host, port, o.str("security") ?: "tls") + } + + fun discover(o: JsonObject): MDiscover { + val hint = o.obj("hint") + return MDiscover( + provider = o.str("provider"), + auth = o.str("auth"), + imap = server(o.obj("imap")), + smtp = server(o.obj("smtp")), + hintDe = hint?.str("de") ?: o.str("hint"), + hintEn = hint?.str("en"), + ) + } + + // ─── folders, lists ───────────────────────────────────────────── + + /** `GET folders` → the folders per account, in the server's order. */ + fun folders(o: JsonObject): Map>? { + val accounts = o.arr("accounts") ?: return null + val out = LinkedHashMap>() + for (a in accounts.objects()) { + val accountId = a.id("accountId") ?: continue + out[accountId] = a.arr("folders")?.objects()?.mapNotNull { f -> + val id = f.id("id") ?: return@mapNotNull null + MFolder( + id = id, + accountId = accountId, + name = f.str("name")?.takeIf { it.isNotBlank() } ?: f.str("path") ?: return@mapNotNull null, + path = f.str("path") ?: "", + role = f.str("role")?.takeIf { it in ROLES }, + parentId = f.id("parentId"), + unread = f.int("unread")?.coerceAtLeast(0) ?: 0, + total = f.int("total")?.coerceAtLeast(0) ?: 0, + ) + }.orEmpty() + } + return out + } + + private val ROLES = setOf(MFolder.ROLE_INBOX, MFolder.ROLE_SENT, MFolder.ROLE_DRAFTS, MFolder.ROLE_ARCHIVE, MFolder.ROLE_JUNK, MFolder.ROLE_TRASH) + + fun address(o: JsonObject): MAddress? { + val email = (o.str("email") ?: o.str("address"))?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return MAddress(o.str("name")?.trim()?.takeIf { it.isNotEmpty() }, email) + } + + /** Addresses as objects `{ name, email }` or as plain strings (`Name ` or `a@b`). */ + fun addresses(arr: JsonArray?): List = arr?.mapNotNull { e -> + when { + e.isJsonObject -> address(e.asJsonObject) + e.isJsonPrimitive -> MailLogic.parseAddress(e.asString) + else -> null + } + }.orEmpty() + + fun summary(o: JsonObject): MSummary? { + val id = o.id("id") ?: return null + val accountId = o.id("accountId") ?: return null + val from = o.obj("from")?.let(::address) ?: MAddress(o.obj("from")?.str("name"), "") + return MSummary( + id = id, + accountId = accountId, + folderId = o.id("folderId"), + threadId = o.id("threadId"), + threadCount = o.int("threadCount")?.coerceAtLeast(1) ?: 1, + from = from, + to = addresses(o.arr("to")), + subject = o.str("subject") ?: "", + snippet = o.str("snippet")?.take(400) ?: "", + date = o.long("date") ?: 0L, + unread = o.bool("unread") ?: false, + flagged = o.bool("flagged") ?: false, + answered = o.bool("answered") ?: false, + hasAttachments = o.bool("hasAttachments") ?: false, + tab = o.str("tab")?.takeIf { it == MSummary.TAB_OTHER } ?: MSummary.TAB_RELEVANT, + savedToDrive = o.bool("savedToDrive") ?: false, + aiSummary = o.obj("ai")?.str("summary")?.takeIf { it.isNotBlank() }, + ) + } + + fun summaries(arr: JsonArray?): List = arr?.objects()?.mapNotNull(::summary).orEmpty() + + fun page(o: JsonObject): MPage? { + val items = o.arr("items") ?: return null + val counts = o.obj("counts")?.let { c -> MCounts(c.int("relevant")?.coerceAtLeast(0) ?: 0, c.int("other")?.coerceAtLeast(0) ?: 0) } + return MPage(summaries(items), o.cursor("next"), counts) + } + + fun searchPage(o: JsonObject): MSearchPage? { + val items = o.arr("items") ?: return null + return MSearchPage(summaries(items), o.cursor("next"), o.bool("remote") ?: false) + } + + /** `{ ok, items? }` of the bulk actions: the changed summaries (empty when the server sends none). */ + fun changedItems(o: JsonObject): List = summaries(o.arr("items")) + + /** `GET threads/` → oldest first. */ + fun thread(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull(::message) + + // ─── one message ──────────────────────────────────────────────── + + fun suggestion(o: JsonObject?): MSuggestion? { + o ?: return null + val folderId = o.id("folderId") ?: return null + return MSuggestion( + folderId = folderId, + areaId = o.str("areaId"), + path = o.str("path")?.takeIf { it.isNotBlank() }, + where = o.str("where")?.takeIf { it.isNotBlank() }, + name = o.str("name")?.takeIf { it.isNotBlank() }, + confidence = (o.double("confidence") ?: 0.0).coerceIn(0.0, 1.0), + reason = o.str("reason") ?: "ai", + ) + } + + fun savedTo(o: JsonObject?): MSavedTo? { + o ?: return null + val node = o.id("nodeId") ?: return null + return MSavedTo(node, o.str("where") ?: "") + } + + fun attachment(o: JsonObject): MAttachment? { + val id = o.id("id") ?: return null + return MAttachment( + id = id, + name = o.str("name")?.takeIf { it.isNotBlank() } ?: "attachment", + mime = o.str("mime")?.takeIf { it.isNotBlank() } ?: "application/octet-stream", + size = o.long("size")?.coerceAtLeast(0) ?: 0L, + inline = o.bool("inline") ?: false, + cid = o.str("cid")?.trim()?.removePrefix("<")?.removeSuffix(">")?.takeIf { it.isNotEmpty() }, + savedTo = savedTo(o.obj("savedTo")), + suggestion = suggestion(o.obj("suggestion")), + ) + } + + fun message(o: JsonObject): MMessage? { + val summary = summary(o) ?: return null + val unsub = o.obj("listUnsubscribe")?.let { u -> + MUnsubscribe(u.str("url")?.takeIf { it.startsWith("https://") || it.startsWith("http://") }, u.str("mail")?.takeIf { it.isNotBlank() }) + }?.takeIf { it.url != null || it.mail != null } + return MMessage( + summary = summary, + cc = addresses(o.arr("cc")), + bcc = addresses(o.arr("bcc")), + replyTo = addresses(o.arr("replyTo")), + html = o.str("html")?.takeIf { it.isNotBlank() }, + text = o.str("text"), + imagesBlocked = o.bool("imagesBlocked") ?: false, + trackers = o.int("trackers")?.coerceAtLeast(0) ?: 0, + attachments = o.arr("attachments")?.objects()?.mapNotNull(::attachment).orEmpty(), + unsubscribe = unsub, + bodyMissing = o.bool("bodyMissing") ?: false, + ) + } + + /** `{ mediaId, name, mime, size }`. */ + fun attachmentMedia(o: JsonObject): MAttachmentMedia? { + val media = o.str("mediaId")?.takeIf { TOKEN.matches(it) } ?: return null + return MAttachmentMedia(media, o.str("name") ?: "attachment", o.str("mime") ?: "application/octet-stream", o.long("size") ?: 0L) + } + + /** A picture of the HTML: inline bytes (`img/…`) or a media file (`cid/…`). */ + class Picture(val mime: String, val data: ByteArray?, val mediaId: String?) + + /** Raster pictures only: SVG (it can carry script) and anything else is refused. */ + private val PICTURE_MIME = Regex("^image/(png|jpe?g|gif|webp|bmp|x-icon|vnd\\.microsoft\\.icon|avif|heic|heif)$") + + /** + * `GET img/` → `{ mime, size, data }` (Base64) or `GET cid//` → `{ mediaId, mime, size, url }`; null + * when it is no picture the WebView may show. + */ + fun picture(o: JsonObject): Picture? { + val mime = o.str("mime")?.lowercase()?.substringBefore(';')?.trim()?.takeIf { PICTURE_MIME.matches(it) } ?: return null + val data = o.str("data")?.let { runCatching { java.util.Base64.getMimeDecoder().decode(it) }.getOrNull() } + if (data != null) return if (data.isEmpty() || data.size > 6 * 1024 * 1024) null else Picture(mime, data, null) + val media = o.str("mediaId")?.takeIf { TOKEN.matches(it) } ?: return null + return Picture(mime, null, media) + } + + // ─── AI ───────────────────────────────────────────────────────── + + fun ai(o: JsonObject): MAi? { + val status = o.str("status") ?: return null + if (status != "done") return MAi.NONE + return MAi( + done = true, + summary = o.str("summary")?.takeIf { it.isNotBlank() }, + todos = o.arr("todos")?.objects()?.mapNotNull { t -> + val text = t.str("text")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MTodo( + kind = t.str("kind") ?: MTodo.TASK, + text = text, + date = t.str("date")?.takeIf { MailLogic.isIsoDate(it) }, + time = t.str("time")?.takeIf { MailLogic.isTime(it) }, + amount = t.double("amount"), + currency = t.str("currency"), + ) + }.orEmpty(), + replies = o.arr("replies")?.objects()?.mapNotNull { r -> + val text = r.str("text")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + MReply(r.str("label")?.takeIf { it.isNotBlank() } ?: text.take(40), text) + }.orEmpty(), + ) + } + + /** `{ where }` of a to-do put into the family calendar. */ + fun where(o: JsonObject): String? = o.str("where") ?: "" + + // ─── drafts, sending ──────────────────────────────────────────── + + fun draft(o: JsonObject): MDraft? { + val id = o.id("id") ?: return null + return MDraft( + id = id, + accountId = o.id("accountId") ?: 0L, + to = addresses(o.arr("to")), + cc = addresses(o.arr("cc")), + bcc = addresses(o.arr("bcc")), + subject = o.str("subject") ?: "", + html = o.str("html") ?: "", + attachments = o.arr("attachments")?.objects()?.mapNotNull { a -> + val aid = a.id("id") ?: return@mapNotNull null + MDraftAttachment( + id = aid, + name = a.str("name") ?: "attachment", + size = a.long("size")?.coerceAtLeast(0) ?: 0L, + mime = a.str("mime") ?: "application/octet-stream", + asLink = a.str("as") == "link", + url = a.str("url")?.takeIf { it.startsWith("https://") || it.startsWith("http://") }, + ) + }.orEmpty(), + warnings = o.arr("warnings")?.objects()?.mapNotNull { w -> + val kind = w.str("kind") ?: return@mapNotNull null + MWarning(kind, w.str("text") ?: "") + }.orEmpty(), + ) + } + + /** `{ draft }`. */ + fun draftAnswer(o: JsonObject): MDraft? = o.obj("draft")?.let(::draft) ?: draft(o) + + fun send(o: JsonObject): MSend? { + val id = o.id("outboxId") ?: return null + return MSend(id, o.long("undoUntil"), o.long("sendAt")) + } + + /** `{ draftId }` of `POST outbox//cancel`. */ + fun cancelled(o: JsonObject): Long? = o.id("draftId") + + fun outbox(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { i -> + val id = i.id("id") ?: return@mapNotNull null + MOutboxItem( + id = id, + draftId = i.id("draftId"), + accountId = i.id("accountId"), + subject = i.str("subject") ?: "", + sendAt = i.long("sendAt"), + state = i.str("state") ?: "waiting", + error = i.str("error") ?: i.obj("error")?.str("code"), + ) + } + + fun contacts(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { c -> + val email = c.str("email")?.trim()?.takeIf { it.contains('@') } ?: return@mapNotNull null + MContact(c.str("name")?.trim()?.takeIf { it.isNotEmpty() }, email, c.str("source") ?: "recent") + } + + // ─── Drive ────────────────────────────────────────────────────── + + fun suggest(o: JsonObject): MSuggest = MSuggest(suggestion(o.obj("suggestion")), o.bool("duplicate") ?: false) + + /** `{ savedTo }`. */ + fun savedAnswer(o: JsonObject): MSavedTo? = savedTo(o.obj("savedTo")) + + fun saveAll(o: JsonObject): MSaveAll = MSaveAll( + saved = o.arr("saved")?.objects()?.mapNotNull { s -> s.id("attachmentId")?.let { a -> savedTo(s.obj("savedTo"))?.let { a to it } } }.orEmpty(), + failed = o.arr("failed")?.objects()?.mapNotNull { f -> f.id("attachmentId")?.let { it to (f.str("code") ?: "failed") } }.orEmpty(), + ) + + fun filing(o: JsonObject): List? = o.arr("items")?.objects()?.mapNotNull { i -> + val id = i.id("id") ?: return@mapNotNull null + MFilingItem( + id = id, + messageId = i.id("messageId") ?: return@mapNotNull null, + attachmentId = i.id("attachmentId") ?: return@mapNotNull null, + name = i.str("name") ?: "attachment", + from = i.obj("from")?.let(::address) ?: i.str("from")?.let(MailLogic::parseAddress), + date = i.long("date"), + suggestion = suggestion(i.obj("suggestion")), + ) + } + + // ─── live ─────────────────────────────────────────────────────── + + /** Realtime event `mail` (or the plugin's data under `mail`): `{ kind: 'changed', accountId, folderId }`. */ + fun changed(data: JsonElement?): MChanged? { + 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")) + } + + // ─── request bodies ───────────────────────────────────────────── + + fun idsBody(ids: Collection): JsonObject = JsonObject().apply { + add("ids", JsonArray().apply { ids.forEach { add(it) } }) + } + + fun flagsBody(ids: Collection, unread: Boolean?, flagged: Boolean?): JsonObject = idsBody(ids).apply { + unread?.let { addProperty("unread", it) } + flagged?.let { addProperty("flagged", it) } + } + + fun moveBody(ids: Collection, folderId: Long): JsonObject = idsBody(ids).apply { addProperty("folderId", folderId) } + + fun tabBody(ids: Collection, tab: String): JsonObject = idsBody(ids).apply { addProperty("tab", tab) } + + fun settingsBody(threads: Boolean? = null, swipeLeft: String? = null, swipeRight: String? = null): JsonObject = JsonObject().apply { + threads?.let { addProperty("threads", it) } + swipeLeft?.takeIf { it in MSettings.SWIPES }?.let { addProperty("swipeLeft", it) } + swipeRight?.takeIf { it in MSettings.SWIPES }?.let { addProperty("swipeRight", it) } + } + + private fun serverBody(s: MServer): JsonObject = JsonObject().apply { + addProperty("host", s.host) + addProperty("port", s.port) + addProperty("security", s.security) + } + + fun accountBody( + provider: String, + email: String, + password: String, + username: String?, + name: String?, + color: String?, + kind: String?, + imap: MServer?, + smtp: MServer?, + ): JsonObject = JsonObject().apply { + addProperty("provider", provider) + addProperty("email", email.trim()) + addProperty("password", password) + username?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("username", it) } + name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + kind?.let { addProperty("kind", it) } + imap?.let { add("imap", serverBody(it)) } + smtp?.let { add("smtp", serverBody(it)) } + } + + fun microsoftBody(code: String, verifier: String, redirectUri: String, name: String?, color: String?, kind: String?): JsonObject = JsonObject().apply { + addProperty("code", code) + addProperty("codeVerifier", verifier) + addProperty("redirectUri", redirectUri) + name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + kind?.let { addProperty("kind", it) } + } + + /** `PATCH accounts/`: only what is given. */ + fun accountPatch( + name: String? = null, + color: String? = null, + kind: String? = null, + push: String? = null, + ai: Boolean? = null, + attachments: String? = null, + signature: String? = null, + password: String? = null, + ): JsonObject = JsonObject().apply { + name?.let { addProperty("name", it.trim()) } + color?.takeIf { COLOR.matches(it) }?.let { addProperty("color", it) } + kind?.let { addProperty("kind", it) } + push?.takeIf { it in MAccount.PUSH }?.let { addProperty("push", it) } + ai?.let { addProperty("ai", it) } + attachments?.takeIf { it in MAccount.ATTACHMENTS }?.let { addProperty("attachments", it) } + signature?.let { addProperty("signature", it) } + password?.let { addProperty("password", it) } + } + + private fun addressArray(list: List): JsonArray = JsonArray().apply { + list.forEach { a -> + add(JsonObject().apply { + a.name?.let { addProperty("name", it) } + addProperty("email", a.email) + }) + } + } + + fun att(a: MAtt): JsonObject = JsonObject().apply { + when (a) { + is MAtt.Kept -> addProperty("id", a.id) + is MAtt.Upload -> { + addProperty("mediaId", a.mediaId) + addProperty("name", a.name) + } + is MAtt.Forwarded -> { + addProperty("messageId", a.messageId) + addProperty("attachmentId", a.attachmentId) + } + is MAtt.Drive -> { + addProperty("driveNodeId", a.nodeId) + addProperty("as", if (a.asLink) "link" else "copy") + if (a.asLink) { + a.expiresDays?.coerceIn(1, 90)?.let { addProperty("expiresDays", it) } + a.password?.takeIf { it.isNotEmpty() }?.let { addProperty("password", it) } + } + } + } + } + + /** + * `POST drafts` / `PUT drafts/`. With [mode] (reply, replyAll, forward) and [replyTo] the server fills + * recipients, subject and quote in when they are left out: null fields are not sent. + */ + fun draftBody( + accountId: Long?, + to: List?, + cc: List?, + bcc: List?, + subject: String?, + html: String?, + replyTo: Long? = null, + mode: String? = null, + attachments: List = emptyList(), + ): JsonObject = JsonObject().apply { + accountId?.takeIf { it > 0 }?.let { addProperty("accountId", it) } + to?.let { add("to", addressArray(it)) } + cc?.let { add("cc", addressArray(it)) } + bcc?.let { add("bcc", addressArray(it)) } + subject?.let { addProperty("subject", it) } + html?.let { addProperty("html", it) } + replyTo?.takeIf { it > 0 }?.let { addProperty("replyTo", it) } + mode?.takeIf { it in MODES }?.let { addProperty("mode", it) } + add("attachments", JsonArray().apply { attachments.forEach { add(att(it)) } }) + } + + val MODES = setOf("reply", "replyAll", "forward") + + fun sendBody(sendAt: Long?): JsonObject = JsonObject().apply { sendAt?.let { addProperty("sendAt", it) } } + + fun imagesBody(sender: Boolean): JsonObject = JsonObject().apply { addProperty("allow", if (sender) "sender" else "once") } + + fun todoBody(): JsonObject = JsonObject().apply { addProperty("action", "family_calendar") } + + fun driveSaveBody(folderId: Long?, name: String?, remember: Boolean?): JsonObject = JsonObject().apply { + folderId?.let { addProperty("folderId", it) } + name?.trim()?.takeIf { it.isNotEmpty() }?.let { addProperty("name", it) } + remember?.let { addProperty("remember", it) } + } + + fun discoverBody(email: String): JsonObject = JsonObject().apply { addProperty("email", email.trim()) } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt new file mode 100644 index 00000000..7c822070 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailLogic.kt @@ -0,0 +1,396 @@ +package com.gatecontrol.android.mail + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +/** + * The Mail tab's rules (pure, unit-tested): which list a box shows, the swipe gestures, the undo window after + * sending, the time column, addresses, the HTML shown in the WebView (embedded pictures, the requests it may make) + * and the body of a draft. + */ +object MailLogic { + + /** "Alle Posteingänge". */ + const val BOX_ALL = "all" + + /** The undo window the server keeps after `send` (contract: 10 s). */ + const val UNDO_MS = 10_000L + + /** Offline: at least this much of the inbox stays on the device. */ + const val OFFLINE_KEEP_MS = 30L * 24 * 60 * 60 * 1000 + + // ─── boxes and lists ──────────────────────────────────────────── + + /** A box: all inboxes ([accountId] null) or one account, optionally one of its folders. */ + data class Box(val accountId: Long? = null, val folderId: Long? = null) { + val all: Boolean get() = accountId == null + /** An inbox (all, or an account without a folder, or its inbox folder): has Relevant / Sonstige. */ + fun isInbox(folders: List): Boolean = + folderId == null || folders.firstOrNull { it.id == folderId }?.role == MFolder.ROLE_INBOX + + /** `box` of `GET messages`. */ + val query: String get() = accountId?.toString() ?: BOX_ALL + + /** The key of its offline copy. */ + fun key(tab: String): String = "box-${query}-${folderId ?: 0}-$tab" + } + + /** The query of `GET messages` for a box. */ + fun listQuery( + box: Box, + tab: String, + inbox: Boolean, + before: String? = null, + unread: Boolean = false, + flagged: Boolean = false, + attachments: Boolean = false, + limit: Int = 50, + ): Map = buildMap { + put("box", box.query) + if (box.accountId != null) box.folderId?.let { put("folder", it.toString()) } + if (inbox) put("tab", tab) + if (unread) put("unread", "1") + if (flagged) put("flagged", "1") + if (attachments) put("attachments", "1") + before?.let { put("before", it) } + put("limit", limit.coerceIn(1, 100).toString()) + } + + /** Pages one after the other: the newer page's items replace the same ids of an older one. */ + fun merge(old: List, more: List): List { + val byId = more.associateBy { it.id } + val oldIds = old.mapTo(HashSet()) { it.id } + return old.map { byId[it.id] ?: it } + more.filter { it.id !in oldIds }.distinctBy { it.id } + } + + /** The list after a change to some mails: [changed] replace theirs, [removed] go. */ + fun apply(list: List, changed: List, removed: Set = emptySet()): List { + val byId = changed.associateBy { it.id } + return list.filter { it.id !in removed }.map { byId[it.id] ?: it } + } + + /** 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") + + // ─── swipes ───────────────────────────────────────────────────── + + const val SWIPE_ARCHIVE = MSettings.SWIPE_ARCHIVE + const val SWIPE_DELETE = MSettings.SWIPE_DELETE + const val SWIPE_READ = MSettings.SWIPE_READ + + /** What a swipe does (null: nothing, the row does not move). `read` flips read / unread. */ + fun swipe(setting: String?): String? = setting?.takeIf { it == SWIPE_ARCHIVE || it == SWIPE_DELETE || it == SWIPE_READ } + + // ─── undo after sending ───────────────────────────────────────── + + /** + * How long "Rückgängig" stays after `send` answered at [now]: the server's [undoUntil] when it lies within the + * contract's 10 s, else (clocks apart) 10 s less what the call took ([callMs]). Never negative. + */ + fun undoWindow(undoUntil: Long?, now: Long, callMs: Long = 0L): Long { + val left = undoUntil?.let { it - now } + return when { + left != null && left in 1..UNDO_MS -> left + else -> (UNDO_MS - callMs.coerceAtLeast(0)).coerceIn(0, UNDO_MS) + } + } + + /** Whether the send was planned for later (not just the undo time). */ + fun planned(sendAt: Long?, now: Long): Boolean = sendAt != null && sendAt - now > UNDO_MS + 5_000 + + /** Suggested times for "Senden planen": tomorrow 08:00, Monday 08:00 (when tomorrow is not Monday). */ + fun planChoices(now: Long, zone: ZoneId): List { + val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + val tomorrow = today.plusDays(1) + val out = mutableListOf(tomorrow.atTime(8, 0).atZone(zone).toInstant().toEpochMilli()) + var monday = today.plusDays(1) + while (monday.dayOfWeek != java.time.DayOfWeek.MONDAY) monday = monday.plusDays(1) + if (monday != tomorrow) out += monday.atTime(8, 0).atZone(zone).toInstant().toEpochMilli() + return out + } + + // ─── the time column ──────────────────────────────────────────── + + enum class When { TIME, YESTERDAY, WEEKDAY, DATE, DATE_YEAR } + + /** How a mail's [date] is shown on [now]: the time today, "Gestern", the weekday this week, else the date. */ + fun whenOf(date: Long, now: Long, zone: ZoneId): When { + val d = Instant.ofEpochMilli(date).atZone(zone).toLocalDate() + val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + val days = ChronoUnit.DAYS.between(d, today) + return when { + days <= 0L -> When.TIME + days == 1L -> When.YESTERDAY + days < 7L -> When.WEEKDAY + d.year == today.year -> When.DATE + else -> When.DATE_YEAR + } + } + + // ─── addresses, names ─────────────────────────────────────────── + + private val ANGLE = Regex("^\\s*\"?([^\"<]*?)\"?\\s*<\\s*([^<>\\s]+@[^<>\\s]+)\\s*>\\s*$") + private val EMAIL = Regex("^[^@\\s<>,;\"]+@[^@\\s<>,;\"]+\\.[^@\\s<>,;\"]+$") + + fun isEmail(s: String): Boolean = EMAIL.matches(s.trim()) + + /** `Name ` or `a@b.de` → the address; null when there is none. */ + fun parseAddress(raw: String?): MAddress? { + val s = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + ANGLE.matchEntire(s)?.let { m -> + val email = m.groupValues[2].trim() + if (!isEmail(email)) return null + return MAddress(m.groupValues[1].trim().takeIf { it.isNotEmpty() }, email) + } + return if (isEmail(s)) MAddress(null, s) else null + } + + /** What was typed into a recipient field: addresses separated by `,` `;` or spaces; the rest is [Parsed.rest]. */ + data class Parsed(val addresses: List, val rest: String) + + fun parseRecipients(input: String): Parsed { + val parts = input.split(',', ';', '\n') + val out = ArrayList() + val rest = ArrayList() + for (p in parts) { + val t = p.trim() + if (t.isEmpty()) continue + val one = parseAddress(t) + if (one != null) { + out += one + continue + } + // "a@b.de c@d.de" without commas + val words = t.split(Regex("\\s+")) + if (words.size > 1 && words.all { isEmail(it) }) out += words.map { MAddress(null, it) } else rest += t + } + return Parsed(out.distinctBy { it.email.lowercase() }, rest.joinToString(", ")) + } + + /** Two letters for the round avatar: first letters of the first two words, else of the address. */ + fun initials(name: String?, email: String): String { + val words = name?.trim()?.split(Regex("\\s+"))?.filter { w -> w.firstOrNull()?.isLetterOrDigit() == true }.orEmpty() + val letters = when { + words.size >= 2 -> "${words[0].first()}${words[1].first()}" + words.size == 1 -> words[0].take(1) + else -> email.trim().take(1) + } + return letters.uppercase().ifEmpty { "?" } + } + + /** `#RRGGBB` → ARGB, null when it is not one. */ + fun color(hex: String?): Long? { + val h = hex?.takeIf { it.length == 7 && it.startsWith('#') } ?: return null + return h.substring(1).toLongOrNull(16)?.let { 0xFF000000L or it } + } + + /** Colors offered for a new account (as the mockups show them). */ + val COLORS = listOf("#F5B544", "#79B8FF", "#FF8080", "#B79CFF", "#46E0A0", "#FF9F6E") + + /** The next color for a new account: the first not in use, else round robin. */ + fun nextColor(used: List): String = + COLORS.firstOrNull { c -> used.none { it.equals(c, ignoreCase = true) } } ?: COLORS[used.size % COLORS.size] + + /** 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) + + /** A Drive suggestion's sureness in percent. */ + fun percent(confidence: Double): Int = (confidence.coerceIn(0.0, 1.0) * 100).toInt() + + /** Kept automatically by Drive (contract: rule, learned or ≥ 0.9). */ + fun autoFiled(s: MSuggestion): Boolean = s.reason == "rule" || s.reason == "learned" || s.confidence >= 0.9 + + // ─── dates of the AI's to-dos ─────────────────────────────────── + + private val ISO_DATE = Regex("^\\d{4}-\\d{2}-\\d{2}$") + private val TIME = Regex("^([01]\\d|2[0-3]):[0-5]\\d$") + + fun isIsoDate(s: String): Boolean = ISO_DATE.matches(s) && runCatching { LocalDate.parse(s) }.isSuccess + + fun isTime(s: String): Boolean = TIME.matches(s) + + /** Start of a to-do in the device's calendar (ms), null without a date. All day when there is no time. */ + fun todoStart(todo: MTodo, zone: ZoneId): Pair? { + val date = todo.date?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null + val time = todo.time?.let { runCatching { java.time.LocalTime.parse(it) }.getOrNull() } + return if (time != null) { + date.atTime(time).atZone(zone).toInstant().toEpochMilli() to false + } else { + // all-day events are given in UTC midnight + date.atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli() to true + } + } + + /** A to-do the calendar can take (a deadline or an event with a date). */ + fun calendarTodo(todo: MTodo): Boolean = (todo.kind == MTodo.DEADLINE || todo.kind == MTodo.EVENT) && todo.date != null + + // ─── HTML in the WebView ──────────────────────────────────────── + + /** The address the mail's HTML is loaded under; its requests are answered by the app (never the network). */ + const val BASE_HOST = "mail.gatecontrol.invalid" + const val BASE_URL = "https://$BASE_HOST/" + + private val IMG_TOKEN = Regex("^[A-Za-z0-9_.~-]{1,300}$") + private val CID_SRC = Regex("(?i)(\\b(?:src|background)\\s*=\\s*)([\"']?)cid:([^\"'\\s>]+)\\2") + + /** + * `cid:` pictures → `cid//` (the plugin's route, contract C2); a `cid:` without an + * attachment stays (it loads nothing). + */ + fun rewriteCid(html: String, messageId: Long, attachments: List): String { + val byCid = attachments.filter { it.cid != null }.associateBy { it.cid!!.lowercase() } + return CID_SRC.replace(html) { m -> + val cid = java.net.URLDecoder.decode(m.groupValues[3].replace("+", "%2B"), "UTF-8").removePrefix("<").removeSuffix(">") + val a = byCid[cid.lowercase()] ?: return@replace m.value + val q = m.groupValues[2].ifEmpty { "\"" } + "${m.groupValues[1]}${q}cid/$messageId/${a.id}$q" + } + } + + /** + * The plugin route a WebView request may load (`img/`, `cid//`), null for anything + * else: such a request is answered empty, the mail never reaches the network by itself. + */ + fun interceptPath(url: String): String? { + val uri = runCatching { java.net.URI(url) }.getOrNull() ?: return null + if (uri.scheme != "https" || uri.host != BASE_HOST || uri.rawQuery != null) return null + val parts = (uri.rawPath ?: return null).trimStart('/').split('/') + return when { + parts.size == 2 && parts[0] == "img" && IMG_TOKEN.matches(parts[1]) -> "img/${parts[1]}" + parts.size == 3 && parts[0] == "cid" && parts[1].toLongOrNull()?.let { it > 0 } == true && + parts[2].toLongOrNull()?.let { it > 0 } == true -> "cid/${parts[1]}/${parts[2]}" + else -> null + } + } + + /** Links a tap may open outside the app (the rest is ignored). */ + fun externalLink(url: String): Boolean { + val scheme = runCatching { java.net.URI(url).scheme?.lowercase() }.getOrNull() + return scheme == "https" || scheme == "http" || scheme == "mailto" || scheme == "tel" + } + + /** `mailto:a@b.de?subject=…` → the address (null for anything else). */ + fun mailtoAddress(url: String): MAddress? { + if (!url.startsWith("mailto:", ignoreCase = true)) return null + val addr = java.net.URLDecoder.decode(url.substring(7).substringBefore('?'), "UTF-8") + return parseRecipients(addr).addresses.firstOrNull() + } + + /** + * 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. + */ + fun page(html: String, dark: Boolean = false): 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 "" + return "" + + "" + + "" + + "" + + html + "" + } + + // ─── the body of a draft ──────────────────────────────────────── + + /** Marks the part of a draft's HTML the person typed in this app (the rest is the quote below it). */ + private const val TEXT_OPEN = "
" + private const val TEXT_CLOSE = "
" + + fun escape(s: String): String = buildString(s.length + 16) { + for (c in s) when (c) { + '&' -> append("&") + '<' -> append("<") + '>' -> append(">") + '"' -> append(""") + '\'' -> append("'") + else -> append(c) + } + } + + /** Plain text → HTML (lines kept). */ + fun textToHtml(text: String): String = escape(text.replace("\r\n", "\n")).replace("\n", "
") + + /** The draft's HTML: the typed [text] and, below it, the [quote] (the server's HTML, unchanged). */ + fun composeHtml(text: String, quote: String): String = TEXT_OPEN + textToHtml(text) + TEXT_CLOSE + quote + + /** A draft's HTML taken apart again: what was typed here and the quote. Another app's draft: all of it as text. */ + fun splitDraft(html: String): Pair { + val start = html.indexOf(TEXT_OPEN) + val end = html.indexOf(TEXT_CLOSE) + if (start == 0 && end > start) { + val inner = html.substring(TEXT_OPEN.length, end) + return htmlToText(inner) to html.substring(end + TEXT_CLOSE.length) + } + if (start < 0 && html.isNotBlank() && looksQuoted(html)) return "" to html + return htmlToText(html) to "" + } + + /** The server's prefilled answer or forward starts with its quote (contract: Zitat). */ + private fun looksQuoted(html: String): Boolean { + val h = html.trimStart().lowercase() + return h.startsWith("


") + } + + private val TAG = Regex("<[^>]*>") + private val BR = Regex("(?i)") + private val BLOCK_END = Regex("(?i)") + + /** Rough HTML → text (lines kept, tags dropped, the common entities decoded). */ + fun htmlToText(html: String): String = html + .replace(BR, "\n") + .replace(BLOCK_END, "\n") + .replace(TAG, "") + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") + .trimEnd('\n') + + /** A new mail's text with the account's signature below (`-- ` line as usual). */ + fun withSignature(text: String, signature: String?): String { + val sig = signature?.trim()?.takeIf { it.isNotEmpty() } ?: return text + if (text.contains(sig)) return text + return text + "\n\n-- \n" + sig + } + + /** A suggestion of the AI put above what the server prefilled. */ + fun withReply(reply: String, text: String): String = if (text.isBlank()) reply else reply + "\n\n" + text + + // ─── attachments ──────────────────────────────────────────────── + + /** Short type label of the file icon (`PDF`, `DOC`, `XLS`, `IMG` …). */ + fun typeLabel(name: String, mime: String): String { + val ext = name.substringAfterLast('.', "").lowercase() + return when { + mime == "application/pdf" || ext == "pdf" -> "PDF" + ext in setOf("doc", "docx", "odt", "rtf") -> "DOC" + ext in setOf("xls", "xlsx", "ods", "csv") -> "XLS" + ext in setOf("ppt", "pptx", "odp") -> "PPT" + mime.startsWith("image/") -> "IMG" + ext in setOf("zip", "7z", "rar", "gz", "tar") -> "ZIP" + ext == "ics" || mime == "text/calendar" -> "ICS" + ext.length in 1..4 -> ext.uppercase() + else -> "FILE" + } + } + + /** Above this a device file cannot go as a copy (the server refuses it, 413); a Drive file goes as a link. */ + const val ATTACH_MAX_BYTES = 25L * 1024 * 1024 + + /** Whether a Drive file of [size] is offered as a link first ("zu groß zum Anhängen"). */ + fun linkFirst(size: Long): Boolean = size > 20L * 1024 * 1024 + + /** A file name of a picked file, cleaned for the server. */ + fun fileName(raw: String?): String { + val n = raw?.substringAfterLast('/')?.replace(Regex("[\\u0000-\\u001f\\\\:*?\"<>|]"), "_")?.trim()?.take(180) + return n?.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "attachment" + } +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt b/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt new file mode 100644 index 00000000..1f3a28eb --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailMicrosoft.kt @@ -0,0 +1,117 @@ +package com.gatecontrol.android.mail + +import android.content.Context +import android.content.pm.PackageManager +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 + +/** + * "Mit Microsoft anmelden" for a mail account (contract D): authorization code with PKCE in the system browser + * (Custom Tab), answered to `msauth://com.gatecontrol.client/` — the hash is the URL-encoded Base64 SHA-1 of the + * app's signing certificate, as Microsoft registers Android apps. The server redeems the code (`POST + * accounts/microsoft`), the refresh token never reaches the phone. Pure except [signatureHash] of the running app. + */ +object MailMicrosoft { + + const val SCHEME = "msauth" + /** The redirect's host: the app's package as published (contract D). */ + const val HOST = "com.gatecontrol.client" + const val DEFAULT_AUTHORITY = "https://login.microsoftonline.com/common" + val DEFAULT_SCOPES = listOf("openid", "profile", "email", "offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send") + + private val random = SecureRandom() + private val VERIFIER = Regex("^[A-Za-z0-9._~-]{43,128}$") + + private fun token(bytes: Int): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(bytes).also { random.nextBytes(it) }) + + /** PKCE `code_verifier`: 43…128 characters of the unreserved set (64 random bytes → 86). */ + fun newVerifier(): String = token(64) + + fun newState(): String = token(24) + + fun isVerifier(v: String): Boolean = VERIFIER.matches(v) + + /** `code_challenge` (S256): base64url(sha256(verifier)) without padding. */ + fun challenge(verifier: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII))) + + /** Base64 (standard, with padding) of the SHA-1 of a signing certificate. */ + fun certHash(cert: ByteArray): String = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-1").digest(cert)) + + /** `msauth://com.gatecontrol.client/`. */ + fun redirectUri(hash: String): String = "$SCHEME://$HOST/" + URLEncoder.encode(hash, "UTF-8") + + /** + * The sign-in page: `/oauth2/v2.0/authorize` with the client id, the scopes (space separated), the + * redirect, state and challenge. [loginHint] fills in the address (re-login of an account). Null when the + * authority is not an https address. + */ + fun authorizeUrl( + authority: String, + clientId: String, + scopes: List, + redirectUri: String, + state: String, + challenge: String, + loginHint: String? = null, + ): String? { + val base = authority.trimEnd('/') + val uri = runCatching { URI(base) }.getOrNull() ?: return null + if (uri.scheme != "https" || uri.host.isNullOrEmpty() || uri.rawQuery != null) return null + fun enc(v: String) = URLEncoder.encode(v, "UTF-8").replace("+", "%20") + return buildString { + append(base).append("/oauth2/v2.0/authorize") + append("?client_id=").append(enc(clientId)) + append("&response_type=code") + append("&redirect_uri=").append(enc(redirectUri)) + append("&response_mode=query") + append("&scope=").append(enc(scopes.joinToString(" "))) + append("&state=").append(enc(state)) + append("&code_challenge=").append(enc(challenge)) + append("&code_challenge_method=S256") + if (loginHint != null) append("&login_hint=").append(enc(loginHint)) else append("&prompt=select_account") + } + } + + /** The browser's answer: a code, or an error with its description. */ + data class Answer(val state: String?, val code: String?, val error: String?, val description: String?) + + fun isRedirect(raw: String?): Boolean { + val uri = raw?.let { runCatching { URI(it) }.getOrNull() } ?: return false + return uri.scheme.equals(SCHEME, ignoreCase = true) && uri.host.equals(HOST, ignoreCase = true) + } + + /** `msauth://com.gatecontrol.client/?code=…&state=…` (or `error=…`); null for anything else. */ + fun parse(raw: String?): Answer? { + if (!isRedirect(raw)) return null + val uri = runCatching { URI(raw) }.getOrNull() ?: return null + val q = uri.rawQuery ?: uri.rawFragment ?: return null + val map = HashMap() + for (part in q.split('&')) { + if (part.isEmpty()) continue + val k = part.substringBefore('=') + val v = runCatching { URLDecoder.decode(part.substringAfter('=', ""), "UTF-8") }.getOrNull() ?: return null + if (k !in map) map[k] = v + } + val code = map["code"]?.takeIf { it.isNotEmpty() && it.length <= 4096 } + val error = map["error"]?.takeIf { it.isNotEmpty() } + if (code == null && error == null) return null + return Answer(map["state"], code, error, map["error_description"]?.take(300)) + } + + /** + * The running app's signature hash, null when Android does not tell: the current signing certificate (the last of + * a rotated key's history; the first signer of an app signed by several). + */ + fun signatureHash(context: Context): String? = runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES) + val signing = info.signingInfo ?: return null + val cert = if (signing.hasMultipleSigners()) signing.apkContentsSigners?.firstOrNull() else signing.signingCertificateHistory?.lastOrNull() + cert?.toByteArray()?.let(::certHash) + }.getOrNull() +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt new file mode 100644 index 00000000..69906c8b --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailModels.kt @@ -0,0 +1,337 @@ +package com.gatecontrol.android.mail + +import com.google.gson.JsonObject + +/** Plugin id of GateControl Mail (contract C/D). */ +const val MAIL_PLUGIN = "gatecontrol-mail" + +/** The answer of a Mail call: never an exception (except cancellation). */ +sealed class MResult { + data class Ok(val value: T) : MResult() + /** Refused or not understood: the plugin's `error.code` (or `http_`), the HTTP status, the error object. */ + data class Failed(val code: String, val http: Int, val error: JsonObject? = null) : MResult() + data object Offline : MResult() + data object NotConfigured : MResult() + + fun valueOrNull(): T? = (this as? Ok)?.value +} + +internal fun MResult<*>.notOk(): MResult = when (this) { + is MResult.Ok -> MResult.Failed(MailApi.CODE_INVALID, 0) + is MResult.Failed -> this + MResult.Offline -> MResult.Offline + MResult.NotConfigured -> MResult.NotConfigured +} + +/** `GET me`: what the server offers and the person's accounts. */ +data class MMe( + val features: Set, + val limits: MLimits, + val microsoft: MMicrosoft, + val accounts: List, + val unreadAll: Int, + val unreadByAccount: Map, + val settings: MSettings, +) { + fun has(feature: String): Boolean = feature in features + + fun account(id: Long?): MAccount? = id?.let { a -> accounts.firstOrNull { it.id == a } } + + companion object { + const val FEATURE_MAIL = "mail" + const val FEATURE_AI = "ai" + const val FEATURE_MICROSOFT = "microsoft" + const val FEATURE_DRIVE = "drive" + const val FEATURE_FAMILY_CALENDAR = "familyCalendar" + const val FEATURE_IMAGES = "images" + } +} + +data class MLimits(val accountsMax: Int, val quotaBytes: Long, val usedBytes: Long, val retentionMonths: Int) + +/** Microsoft sign-in (public client with PKCE): no client id → not set up yet. */ +data class MMicrosoft(val clientId: String?, val authority: String, val scopes: List) + +/** The person's list settings (`PUT settings`). */ +data class MSettings(val threads: Boolean, val swipeLeft: String, val swipeRight: String) { + companion object { + const val SWIPE_ARCHIVE = "archive" + const val SWIPE_DELETE = "delete" + const val SWIPE_READ = "read" + const val SWIPE_NONE = "none" + val SWIPES = listOf(SWIPE_ARCHIVE, SWIPE_DELETE, SWIPE_READ, SWIPE_NONE) + val DEFAULT = MSettings(threads = true, swipeLeft = SWIPE_ARCHIVE, swipeRight = SWIPE_READ) + } +} + +data class MAccount( + val id: Long, + /** gmx | webde | tonline | gmail | outlook | m365 | imap */ + val provider: String, + val email: String, + val name: String?, + /** `#RRGGBB`, null when the server sends none or a bad one. */ + val color: String?, + /** personal | business */ + val kind: String, + /** ok | syncing | auth_failed | unreachable */ + val state: String, + val errorCode: String?, + val errorAt: Long?, + /** off | relevant | all */ + val push: String, + val ai: Boolean, + /** off | suggest | auto */ + val attachments: String, + val signature: String?, +) { + val label: String get() = name?.takeIf { it.isNotBlank() } ?: email + + val microsoft: Boolean get() = provider == PROVIDER_OUTLOOK || provider == PROVIDER_M365 + + companion object { + const val PROVIDER_GMX = "gmx" + const val PROVIDER_WEBDE = "webde" + const val PROVIDER_TONLINE = "tonline" + const val PROVIDER_GMAIL = "gmail" + const val PROVIDER_OUTLOOK = "outlook" + const val PROVIDER_M365 = "m365" + const val PROVIDER_IMAP = "imap" + const val STATE_OK = "ok" + const val STATE_SYNCING = "syncing" + const val STATE_AUTH_FAILED = "auth_failed" + const val STATE_UNREACHABLE = "unreachable" + const val KIND_PERSONAL = "personal" + const val KIND_BUSINESS = "business" + val PUSH = listOf("off", "relevant", "all") + val ATTACHMENTS = listOf("off", "suggest", "auto") + } +} + +/** `GET providers`: one entry of "Konto hinzufügen". */ +data class MProvider( + val id: String, + val label: String, + /** password | app_password | microsoft | imap */ + val auth: String, + val hintDe: String?, + val hintEn: String?, + val help: String?, +) { + fun hint(lang: String): String? = if (lang == "en") hintEn ?: hintDe else hintDe ?: hintEn + + companion object { + const val AUTH_PASSWORD = "password" + const val AUTH_APP_PASSWORD = "app_password" + const val AUTH_MICROSOFT = "microsoft" + const val AUTH_IMAP = "imap" + } +} + +data class MServer(val host: String, val port: Int, val security: String) + +/** `POST accounts/discover`. */ +data class MDiscover(val provider: String?, val auth: String?, val imap: MServer?, val smtp: MServer?, val hintDe: String?, val hintEn: String?) + +data class MFolder( + val id: Long, + val accountId: Long, + val name: String, + val path: String, + /** inbox | sent | drafts | archive | junk | trash | null */ + val role: String?, + val parentId: Long?, + val unread: Int, + val total: Int, +) { + companion object { + const val ROLE_INBOX = "inbox" + const val ROLE_SENT = "sent" + const val ROLE_DRAFTS = "drafts" + const val ROLE_ARCHIVE = "archive" + const val ROLE_JUNK = "junk" + const val ROLE_TRASH = "trash" + } +} + +data class MAddress(val name: String?, val email: String) { + val label: String get() = name?.takeIf { it.isNotBlank() } ?: email +} + +/** A mail in a list (`Summary`). */ +data class MSummary( + val id: Long, + val accountId: Long, + val folderId: Long?, + val threadId: Long?, + val threadCount: Int, + val from: MAddress, + val to: List, + val subject: String, + val snippet: String, + val date: Long, + val unread: Boolean, + val flagged: Boolean, + val answered: Boolean, + val hasAttachments: Boolean, + /** relevant | other */ + val tab: String, + val savedToDrive: Boolean, + val aiSummary: String?, +) { + companion object { + const val TAB_RELEVANT = "relevant" + const val TAB_OTHER = "other" + const val TAB_ALL = "all" + } +} + +data class MCounts(val relevant: Int, val other: Int) + +/** `GET messages`: a page of a list. */ +data class MPage(val items: List, val next: String?, val counts: MCounts?) + +/** `GET search`. */ +data class MSearchPage(val items: List, val next: String?, val remote: Boolean) + +data class MSavedTo(val nodeId: Long, val where: String) + +/** Where Drive would put an attachment. */ +data class MSuggestion( + val folderId: Long, + val areaId: String?, + val path: String?, + val where: String?, + val name: String?, + val confidence: Double, + /** rule | learned | ai | kind */ + val reason: String, +) + +data class MAttachment( + val id: Long, + val name: String, + val mime: String, + val size: Long, + val inline: Boolean, + val cid: String?, + val savedTo: MSavedTo?, + val suggestion: MSuggestion?, +) + +data class MUnsubscribe(val url: String?, val mail: String?) + +/** `GET messages/`: a mail to read. */ +data class MMessage( + val summary: MSummary, + val cc: List, + val bcc: List, + val replyTo: List, + /** Cleaned by the server; external pictures as `img/`, embedded ones as `cid:`. */ + val html: String?, + val text: String?, + val imagesBlocked: Boolean, + 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. */ + val bodyMissing: Boolean = false, +) { + val id: Long get() = summary.id +} + +/** `GET/POST ai/`: "Kurz gesagt", "Zu tun", answer suggestions. */ +data class MAi(val done: Boolean, val summary: String?, val todos: List, val replies: List) { + companion object { + val NONE = MAi(done = false, summary = null, todos = emptyList(), replies = emptyList()) + } +} + +data class MTodo( + /** question | deadline | event | amount | task */ + val kind: String, + val text: String, + /** `YYYY-MM-DD` */ + val date: String?, + /** `HH:mm` */ + val time: String?, + val amount: Double?, + val currency: String?, +) { + companion object { + const val QUESTION = "question" + const val DEADLINE = "deadline" + const val EVENT = "event" + const val AMOUNT = "amount" + const val TASK = "task" + } +} + +data class MReply(val label: String, val text: String) + +/** One attachment of a draft as the server lists it. */ +data class MDraftAttachment(val id: Long, val name: String, val size: Long, val mime: String, val asLink: Boolean, val url: String?) + +data class MWarning(val kind: String, val text: String) + +data class MDraft( + val id: Long, + val accountId: Long, + val to: List, + val cc: List, + val bcc: List, + val subject: String, + val html: String, + val attachments: List, + val warnings: List, +) + +/** An attachment to send with a draft (`Att`). */ +sealed class MAtt { + /** Already on the draft (kept when the draft is saved again). */ + data class Kept(val id: Long) : MAtt() + /** Uploaded from the device (plugin media); the name rides along, the media itself has none ([mime] stays here). */ + data class Upload(val mediaId: String, val name: String, val mime: String) : MAtt() + /** An attachment of the mail being forwarded. */ + data class Forwarded(val messageId: Long, val attachmentId: Long) : MAtt() + /** A Drive file as a copy or as a link. */ + data class Drive(val nodeId: Long, val asLink: Boolean, val expiresDays: Int? = null, val password: String? = null) : MAtt() +} + +/** `POST drafts//send`. */ +data class MSend(val outboxId: Long, val undoUntil: Long?, val sendAt: Long?) + +data class MOutboxItem( + val id: Long, + val draftId: Long?, + val accountId: Long?, + val subject: String, + val sendAt: Long?, + /** waiting | sending | failed */ + val state: String, + val error: String?, +) + +data class MContact(val name: String?, val email: String, val source: String) + +/** "Zum Ablegen": an attachment the AI was not sure about. */ +data class MFilingItem( + val id: Long, + val messageId: Long, + val attachmentId: Long, + val name: String, + val from: MAddress?, + val date: Long?, + val suggestion: MSuggestion?, +) + +/** `GET messages//attachments/`: where its file lies. */ +data class MAttachmentMedia(val mediaId: String, val name: String, val mime: String, val size: Long) + +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?) diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt new file mode 100644 index 00000000..ec754f5b --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailPush.kt @@ -0,0 +1,42 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.notify.model.PushMessage +import com.google.gson.JsonObject +import com.google.gson.JsonParser + +/** + * Where a tap on a Mail push leads (pure, unit-tested). Topic `mail` (title = sender, text = subject) carries + * `data.mail = { messageId, accountId }` and opens the mail; `mail_failed` carries `{ outboxId }` and opens the + * outbox. Their channel is called "Mail" whatever the server names it. + */ +object MailPush { + private const val TOPIC_PREFIX = "plugin:$MAIL_PLUGIN:" + const val TOPIC_MAIL = TOPIC_PREFIX + "mail" + const val TOPIC_FAILED = TOPIC_PREFIX + "mail_failed" + + fun isMailMessage(message: PushMessage): Boolean = message.topic.startsWith(TOPIC_PREFIX) + + /** Whether a notification channel (by its topic) is Mail's own: shown as "Mail". */ + fun isMailTopic(topic: String): Boolean = topic == TOPIC_MAIL + + /** `message:` | `outbox:` | `outbox`; null for other messages. */ + fun destination(message: PushMessage): String? { + if (!isMailMessage(message)) return null + val mail = message.dataJson?.let { raw -> runCatching { JsonParser.parseString(raw).asJsonObject.get("mail") }.getOrNull() } + ?.takeIf { it.isJsonObject }?.asJsonObject + return when (message.topic) { + TOPIC_MAIL -> mail?.id("messageId")?.let { "message:$it" } + TOPIC_FAILED -> mail?.id("outboxId")?.let { "outbox:$it" } ?: "outbox" + else -> null + } + } + + private fun JsonObject.id(key: String): Long? = + get(key)?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asString.trim().toLong() }.getOrNull() }?.takeIf { it > 0 } + + /** A destination of [destination] → the mail's id, or null. */ + fun messageOf(dest: String): Long? = dest.takeIf { it.startsWith("message:") }?.removePrefix("message:")?.toLongOrNull()?.takeIf { it > 0 } + + /** Whether a destination opens the outbox. */ + fun isOutbox(dest: String): Boolean = dest == "outbox" || dest.startsWith("outbox:") +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt new file mode 100644 index 00000000..384d9b35 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailRepository.kt @@ -0,0 +1,213 @@ +package com.gatecontrol.android.mail + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.os.SystemClock +import com.gatecontrol.android.MailSigninActivity +import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.familie.PluginDirectory +import com.gatecontrol.android.support.CrashReports +import com.google.gson.JsonElement +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** "Gesendet · Rückgängig" after `send`: until [untilElapsed] (elapsed realtime) the send can be stopped. */ +data class MUndo(val outboxId: Long, val draftId: Long?, val untilElapsed: Long, val sendAt: Long?, val planned: Boolean) + +/** + * What the Mail screens share: the last `me` (accounts, features, unread) and the folders, the plugin's realtime + * events (`mail { kind: 'changed' }` → the lists load again) and the send waiting to be undone. + */ +@Singleton +class MailRepository @Inject constructor( + val api: MailApi, + val cache: MailCache, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) + + private val _me = MutableStateFlow(null) + val me: StateFlow = _me.asStateFlow() + + private val _folders = MutableStateFlow>>(emptyMap()) + val folders: StateFlow>> = _folders.asStateFlow() + + /** `me` could not be had: its code (offline, not_allowed, …); null once it came. */ + private val _meError = MutableStateFlow(null) + val meError: StateFlow = _meError.asStateFlow() + + private val _changed = MutableSharedFlow(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val changed: SharedFlow = _changed.asSharedFlow() + + private val _undo = MutableStateFlow(null) + val undo: StateFlow = _undo.asStateFlow() + + @Volatile private var meAt = 0L + private var refreshJob: Job? = null + + init { + scope.launch { + if (_me.value == null) cache.me()?.let { if (_me.value == null) _me.value = it } + if (_folders.value.isEmpty()) cache.folders()?.let { if (_folders.value.isEmpty()) _folders.value = it } + } + } + + /** `GET me` (and the folders) now. */ + suspend fun refresh(): MResult { + val r = api.me() + when (r) { + is MResult.Ok -> { + _me.value = r.value.first + _meError.value = null + meAt = SystemClock.elapsedRealtime() + cache.putMe(r.value.second) + api.folders().valueOrNull()?.let { (f, raw) -> + _folders.value = f + cache.putFolders(raw) + } + } + is MResult.Failed -> _meError.value = r.code + MResult.Offline -> _meError.value = MailApi.CODE_OFFLINE + MResult.NotConfigured -> _meError.value = "not_configured" + } + return when (r) { + is MResult.Ok -> MResult.Ok(r.value.first) + else -> r.notOk() + } + } + + /** On showing the tab: `me` again unless it came a moment ago. */ + fun refreshSoon(maxAgeMs: Long = 20_000L) { + if (_me.value != null && SystemClock.elapsedRealtime() - meAt < maxAgeMs) return + if (refreshJob?.isActive == true) return + refreshJob = scope.launch { refresh() } + } + + fun setMe(me: MMe) { + _me.value = me + } + + /** `event: plugin` of Mail (`mail { kind: 'changed', accountId, folderId }`): the lists and the counts load again. */ + fun onPluginEvent(type: String, data: JsonElement?) { + val changed = MailJson.changed(data) ?: if (type == "mail" || type == "changed") MChanged(null, null) else return + _changed.tryEmit(changed) + if (refreshJob?.isActive != true) { + refreshJob = scope.launch { + delay(500) + refresh() + } + } + } + + /** (Re)connected: what changed meanwhile. */ + fun onStreamOpen() { + if (_me.value == null) return + _changed.tryEmit(MChanged(null, null)) + } + + // ─── undo after sending ───────────────────────────────────────── + + fun startUndo(send: MSend, draftId: Long?, callMs: Long, now: Long = System.currentTimeMillis()) { + val planned = MailLogic.planned(send.sendAt, now) + val window = if (planned) PLANNED_UNDO_MS else MailLogic.undoWindow(send.undoUntil, now, callMs) + _undo.value = MUndo(send.outboxId, draftId, SystemClock.elapsedRealtime() + window, send.sendAt, planned) + } + + fun clearUndo(outboxId: Long? = null) { + if (outboxId == null || _undo.value?.outboxId == outboxId) _undo.value = null + } + + /** "Rückgängig": the send stops, the draft it came from (to open again). */ + suspend fun undo(u: MUndo): MResult { + val r = api.cancel(u.outboxId) + clearUndo(u.outboxId) + return r + } + + /** Everything this device keeps of Mail goes (another login, the plugin gone, the token revoked). */ + suspend fun forget() { + _me.value = null + _folders.value = emptyMap() + _undo.value = null + cache.wipe() + } + + private companion object { + /** A planned send: its bar stays a little longer (it can be stopped any time before). */ + const val PLANNED_UNDO_MS = 8_000L + } +} + +/** + * Mail's duties outside its screens, started with the app: the Microsoft redirect (`msauth://…`) is only handled + * while the server offers Mail, and the offline copy goes when Mail is gone, the login changes or the token is + * revoked (contract D "beim Fernlöschen mit löschen"). Without the plugin nothing else happens. + */ +@Singleton +class MailStartup @Inject constructor( + @ApplicationContext private val context: Context, + private val pluginDirectory: PluginDirectory, + private val setupRepository: SetupRepository, + private val cache: MailCache, + private val api: MailApi, + private val repository: dagger.Lazy, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CrashReports.coroutineHandler) + private var started = false + + fun start() { + synchronized(this) { + if (started) return + started = true + } + scope.launch { + pluginDirectory.state.map { it.mail }.distinctUntilChanged().collect { on -> + setSigninEnabled(context, on) + if (!on && cache.hasData()) repository.get().forget() + } + } + scope.launch { + setupRepository.revision.drop(1).collect { if (cache.hasData()) repository.get().forget() } + } + scope.launch { + api.unauthorized.collect { if (cache.hasData()) repository.get().forget() } + } + } + + private fun setSigninEnabled(context: Context, enabled: Boolean) { + runCatching { + val component = ComponentName(context, MailSigninActivity::class.java) + val want = if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + if (context.packageManager.getComponentEnabledSetting(component) != want) { + context.packageManager.setComponentEnabledSetting(component, want, PackageManager.DONT_KILL_APP) + } + }.onFailure { Timber.w("Mail: sign-in redirect not switched (%s)", it.javaClass.simpleName) } + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface MailEntryPoint { + fun mailStartup(): MailStartup +} diff --git a/app/src/main/java/com/gatecontrol/android/mail/MailSigninRedirects.kt b/app/src/main/java/com/gatecontrol/android/mail/MailSigninRedirects.kt new file mode 100644 index 00000000..6b21102e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/mail/MailSigninRedirects.kt @@ -0,0 +1,20 @@ +package com.gatecontrol.android.mail + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** The browser's answer to a Microsoft sign-in of a mail account, until the screen that asked takes it. */ +object MailSigninRedirects { + private val _pending = MutableStateFlow(null) + val pending: StateFlow = _pending.asStateFlow() + + fun offer(raw: String) { + _pending.value = raw + } + + fun consume(raw: String) { + _pending.update { if (it == raw) null else it } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt index 98fb43da..a280ff6d 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/AppNavigation.kt @@ -300,6 +300,7 @@ fun AppNavigation( Screen.Chats.route -> TabItem(route, Screen.Chats.create(), R.string.nav_chats, Icons.AutoMirrored.Outlined.Chat, chatBadge) Screen.Vault.route -> TabItem(route, Screen.Vault.route, R.string.vault_tab, GcIcons.Vault) Screen.Drive.route -> TabItem(route, Screen.Drive.route, R.string.drive_tab, GcIcons.Cloud) + Screen.Mail.route -> TabItem(route, Screen.Mail.route, R.string.mail_tab, GcIcons.Mail) else -> TabItem(route, Screen.Vpn.route, R.string.nav_start, GcIcons.Shield) } } @@ -422,6 +423,7 @@ fun AppNavigation( ) StartPages.VAULT -> VaultHome(navController) StartPages.DRIVE -> DriveHome(navController, onShown = {}) + StartPages.MAIL -> MailHome(navController) } } @@ -1083,7 +1085,7 @@ fun AppNavigation( onBack = { selected = null }, onNavigate = { target -> if (target is PushNavTarget.Detail) selected = target.id - else if (target != PushNavTarget.Inbox) openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) + else if (target != PushNavTarget.Inbox) openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive, plugins.mail) }, viewModel = detailViewModel, ) @@ -1110,7 +1112,7 @@ fun AppNavigation( onBack = { if (!navController.popBackStack()) navController.navigate(Screen.Inbox.create()) }, - onNavigate = { target -> openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) }, + onNavigate = { target -> openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive, plugins.mail) }, ) } } @@ -1227,6 +1229,9 @@ fun AppNavigation( vaultRoutes(navController, navigateToTab) { if (StartPages.VAULT in pinnedNow) RedirectToPage(StartPages.VAULT, Screen.Vault.route) else VaultHome(navController) } + mailRoutes(navController, navigateToTab) { + if (StartPages.MAIL in pinnedNow) RedirectToPage(StartPages.MAIL, Screen.Mail.route) else MailHome(navController) + } driveRoutes(navController, navigateToTab) { entry -> if (StartPages.DRIVE in pinnedNow) { RedirectToPage(StartPages.DRIVE, Screen.Drive.route) @@ -1242,7 +1247,7 @@ fun AppNavigation( val target = pushTarget ?: return@LaunchedEffect if (!isSetupComplete) return@LaunchedEffect PushNavigation.consume(target) - openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive) + openPushTarget(navController, target, navigateToTab, openTab, layout, plugins.familieTab, plugins.door, plugins.messengerTab, plugins.vault, plugins.drive, plugins.mail) } // After NavHost so the graph is set before navigating. @@ -1274,8 +1279,25 @@ private fun openPushTarget( vault: Boolean = false, /** The Drive plugin is listed: a deadline opens its file, a vault request the vault. */ drive: Boolean = false, + /** The Mail plugin is listed with its tab: a mail push opens the mail, a failed send the outbox. */ + mail: Boolean = false, ) { when (target) { + is PushNavTarget.Mail -> { + val message = com.gatecontrol.android.mail.MailPush.messageOf(target.dest) + when { + mail && message != null -> { + navigateToTab(Screen.Mail.route) + navController.navigate(Screen.MailMessage.create(message)) { launchSingleTop = true } + } + mail && com.gatecontrol.android.mail.MailPush.isOutbox(target.dest) -> { + navigateToTab(Screen.Mail.route) + navController.navigate(Screen.MailOutbox.route) { launchSingleTop = true } + } + target.messageId != null -> navController.navigate(Screen.NotificationDetail.create(target.messageId)) + else -> navigateToTab(Screen.Vpn.route) + } + } is PushNavTarget.Drive -> { val file = com.gatecontrol.android.drive.DrivePush.fileOf(target.dest) val vaultDest = com.gatecontrol.android.drive.DrivePush.vaultOf(target.dest) @@ -1479,7 +1501,11 @@ private fun GcTabBar( .padding(start = 4.dp, end = 4.dp, bottom = 10.dp), ) { // Six tabs (all plugins) share a phone's width: a narrower pill. - val pill = if (tabs.size > 5) 52.dp else 60.dp + val pill = when { + tabs.size > 6 -> 44.dp + tabs.size > 5 -> 52.dp + else -> 60.dp + } tabs.forEach { tab -> val selected = isSelected(tab) Box(Modifier.weight(1f)) { @@ -1602,7 +1628,7 @@ private fun GcNavRail( /** Single-column pages on wide windows: centred, at most [maxWidth] wide. */ @Composable -private fun GcContentWidth(maxWidth: Dp = 720.dp, content: @Composable () -> Unit) { +internal fun GcContentWidth(maxWidth: Dp = 720.dp, content: @Composable () -> Unit) { Box( Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.TopCenter, diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt new file mode 100644 index 00000000..7b28104b --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/navigation/MailNavigation.kt @@ -0,0 +1,117 @@ +package com.gatecontrol.android.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.gatecontrol.android.ui.mail.MailAccountScreen +import com.gatecontrol.android.ui.mail.MailAddAccountScreen +import com.gatecontrol.android.ui.mail.MailComposeScreen +import com.gatecontrol.android.ui.mail.MailFilingScreen +import com.gatecontrol.android.ui.mail.MailHomeScreen +import com.gatecontrol.android.ui.mail.MailNav +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.MailSettingsScreen + +/** Mail's ways between its screens (the tab, a page beside Start and the routes on top share them). */ +private class MailNavigation(private val navController: NavHostController) { + val go: (String) -> Unit = { route -> navController.navigate(route) { launchSingleTop = true } } + + val nav = MailNav( + open = { go(Screen.MailMessage.create(it)) }, + compose = { account -> go(Screen.MailCompose.create(account = account)) }, + draft = { go(Screen.MailCompose.create(draft = it)) }, + search = { go(Screen.MailSearch.route) }, + filing = { go(Screen.MailFiling.route) }, + addAccount = { go(Screen.MailAddAccount.create()) }, + settings = { go(Screen.MailSettings.route) }, + account = { go(Screen.MailAccount.create(it)) }, + outbox = { go(Screen.MailOutbox.route) }, + ) +} + +/** The Mail tab's screen (as the tab or as a page beside Start). */ +@Composable +internal fun MailHome(navController: NavHostController) { + val nav = androidx.compose.runtime.remember(navController) { MailNavigation(navController) } + GcContentWidth { MailHomeScreen(nav.nav) } +} + +/** + * Mail (plugin gatecontrol-mail, contract D): the tab, a mail, writing, the search, "Zum Ablegen", the outbox, + * adding an account and the settings, each a route of its own so the tab bar shows on the tab only. + */ +internal fun NavGraphBuilder.mailRoutes( + navController: NavHostController, + navigateToTab: (String) -> Unit, + home: @Composable () -> Unit, +) { + val back: () -> Unit = { if (!navController.popBackStack()) navigateToTab(Screen.Mail.route) } + val nav = MailNavigation(navController) + val go = nav.go + val optional: (String) -> androidx.navigation.NamedNavArgument = { name -> + navArgument(name) { + type = NavType.StringType + nullable = true + defaultValue = null + } + } + composable(Screen.Mail.route) { home() } + composable(Screen.MailMessage.route, arguments = listOf(navArgument(Screen.MailMessage.ARG_ID) { type = NavType.StringType })) { entry -> + val id = entry.arguments?.getString(Screen.MailMessage.ARG_ID)?.toLongOrNull() + GcContentWidth { + MailReadScreen( + MailReadNav( + back = back, + 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)) }, + ), + ) + } + } + composable( + Screen.MailCompose.route, + arguments = listOf( + optional(Screen.MailCompose.ARG_DRAFT), + optional(Screen.MailCompose.ARG_REF), + optional(Screen.MailCompose.ARG_MODE), + optional(Screen.MailCompose.ARG_ACCOUNT), + optional(Screen.MailCompose.ARG_TO), + optional(Screen.MailCompose.ARG_REPLY), + ), + ) { + GcContentWidth { MailComposeScreen(onClose = back) } + } + composable(Screen.MailSearch.route) { + GcContentWidth { MailSearchScreen(onBack = back, onOpen = nav.nav.open) } + } + composable(Screen.MailFiling.route) { + GcContentWidth { MailFilingScreen(onBack = back) } + } + composable(Screen.MailOutbox.route) { + GcContentWidth { + MailOutboxScreen(onBack = back, onDraft = { draft -> navController.navigate(Screen.MailCompose.create(draft = draft)) }) + } + } + composable(Screen.MailAddAccount.route, arguments = listOf(optional(Screen.MailAddAccount.ARG_ACCOUNT))) { + GcContentWidth { MailAddAccountScreen(onBack = back, onDone = { back() }) } + } + composable(Screen.MailSettings.route) { + GcContentWidth { + MailSettingsScreen( + onBack = back, + onAccount = { go(Screen.MailAccount.create(it)) }, + onAdd = { go(Screen.MailAddAccount.create()) }, + ) + } + } + composable(Screen.MailAccount.route, arguments = listOf(navArgument(Screen.MailAccount.ARG_ID) { type = NavType.StringType })) { + GcContentWidth { MailAccountScreen(onBack = back, onRelogin = { go(Screen.MailAddAccount.create(it)) }) } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt b/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt index 2cb28eab..b3efa187 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/MainTabs.kt @@ -6,7 +6,7 @@ import com.gatecontrol.android.ui.network.NetworkTabs /** * Entries of the bottom bar / navigation rail (pure, unit-tested): Start, - * Chats (messenger plugin), Vault (vault plugin), Drive (drive plugin), Netzwerk (Dienste, Pi-hole and Remote as its + * Chats (messenger plugin), Mail (mail plugin), Vault (vault plugin), Drive (drive plugin), Netzwerk (Dienste, Pi-hole and Remote as its * segments) and Familie. The door plugin gets no tab (its screen opens from * pushes and its quick actions, docs/DOOR.md). The settings are no tab * since 1.35: they open from the profile (gear top right). @@ -19,14 +19,16 @@ internal object MainTabs { Screen.Network.route, Screen.Familie.route, Screen.Chats.route, + Screen.Mail.route, Screen.Vault.route, Screen.Drive.route, ) - /** Routes of the tabs in their order: Start, Chats, Vault, Drive, Netzwerk, Familie. */ + /** Routes of the tabs in their order: Start, Chats, Mail, Vault, Drive, Netzwerk, Familie. */ fun routes(network: Boolean, plugins: PluginAvailability): List = buildList { add(Screen.Vpn.route) if (plugins.messengerTab) add(Screen.Chats.route) + if (plugins.mail) add(Screen.Mail.route) if (plugins.vault) add(Screen.Vault.route) if (plugins.drive) add(Screen.Drive.route) if (network) add(Screen.Network.route) @@ -51,6 +53,7 @@ internal object MainTabs { /** The page id ([StartPages]) of a tab's route (the route pattern); null for Start. */ fun pageOf(route: String): String? = when (route) { Screen.Chats.route -> StartPages.CHATS + Screen.Mail.route -> StartPages.MAIL Screen.Vault.route -> StartPages.VAULT Screen.Drive.route -> StartPages.DRIVE Screen.Network.route -> StartPages.NETWORK @@ -61,6 +64,7 @@ internal object MainTabs { /** The tab's route pattern of a function page (the reverse of [pageOf]). */ fun routeOf(page: String): String? = when (page) { StartPages.CHATS -> Screen.Chats.route + StartPages.MAIL -> Screen.Mail.route StartPages.VAULT -> Screen.Vault.route StartPages.DRIVE -> Screen.Drive.route StartPages.NETWORK -> Screen.Network.route @@ -75,6 +79,7 @@ internal object MainTabs { */ fun pageFor(route: String): String? = when (route.substringBefore('?')) { Screen.Chats.BASE -> StartPages.CHATS + Screen.Mail.route -> StartPages.MAIL Screen.Vault.route -> StartPages.VAULT Screen.Drive.route -> StartPages.DRIVE Screen.Network.BASE -> StartPages.NETWORK 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 76928683..17ffac74 100644 --- a/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt +++ b/app/src/main/java/com/gatecontrol/android/navigation/Screen.kt @@ -128,6 +128,53 @@ sealed class Screen(val route: String) { } data object VaultImport : Screen("vault/import") data object VaultSettings : Screen("vault/settings") + /** + * Tab "Mail" (plugin gatecontrol-mail, only when the server lists it with its tab): the inboxes. A mail, writing, + * the search, "Zum Ablegen", adding an account and the settings are routes of their own (no tab bar). + */ + data object Mail : Screen("mail") + data object MailMessage : Screen("mail/m/{id}") { + const val ARG_ID = "id" + fun create(id: Long) = "mail/m/$id" + } + /** Writing: a new mail (`account`, `to`), an answer or forward of `ref` (`mode`), or the draft `draft`. */ + data object MailCompose : Screen("mail/compose?draft={draft}&ref={ref}&mode={mode}&account={account}&to={to}&reply={reply}") { + const val ARG_DRAFT = "draft" + const val ARG_REF = "ref" + const val ARG_MODE = "mode" + const val ARG_ACCOUNT = "account" + const val ARG_TO = "to" + const val ARG_REPLY = "reply" + fun create(draft: Long? = null, ref: Long? = null, mode: String? = null, account: Long? = null, to: String? = null, reply: String? = null): String { + // %20, not "+": the navigation decodes arguments like a URI path + fun enc(v: String) = java.net.URLEncoder.encode(v, "UTF-8").replace("+", "%20") + val args = listOfNotNull( + draft?.let { "draft=$it" }, + ref?.let { "ref=$it" }, + mode?.let { "mode=" + enc(it) }, + account?.let { "account=$it" }, + to?.let { "to=" + enc(it) }, + reply?.let { "reply=" + enc(it.take(2000)) }, + ) + return "mail/compose" + if (args.isEmpty()) "" else "?" + args.joinToString("&") + } + } + data object MailSearch : Screen("mail/search") + /** "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. */ + data object MailAddAccount : Screen("mail/accounts/add?account={account}") { + const val ARG_ACCOUNT = "account" + fun create(account: Long? = null) = "mail/accounts/add" + (account?.let { "?account=$it" } ?: "") + } + /** "Mail-Einstellungen": the list settings and the accounts. */ + data object MailSettings : Screen("mail/settings") + data object MailAccount : Screen("mail/accounts/{id}") { + const val ARG_ID = "id" + fun create(id: Long) = "mail/accounts/$id" + } + /** "Postausgang": sends planned or failed. */ + data object MailOutbox : Screen("mail/outbox") /** * Tab "Drive" (plugin gatecontrol-drive, only when the server lists it): * the areas and the newest files. A folder, the trash and the search are diff --git a/app/src/main/java/com/gatecontrol/android/push/PushNavigation.kt b/app/src/main/java/com/gatecontrol/android/push/PushNavigation.kt index b90d2ee4..3546f26e 100644 --- a/app/src/main/java/com/gatecontrol/android/push/PushNavigation.kt +++ b/app/src/main/java/com/gatecontrol/android/push/PushNavigation.kt @@ -68,6 +68,12 @@ sealed interface PushNavTarget { */ data class Drive(val dest: String, val messageId: Long? = null) : PushNavTarget + /** + * Mail: a mail (`message:`, a new mail's push) or the outbox (`outbox[:]`, a send failed). Without the + * plugin (gone meanwhile) the message [messageId] opens. + */ + data class Mail(val dest: String, val messageId: Long? = null) : PushNavTarget + companion object { /** App route of the Familie plugin (`plg-gatecontrol-familie`). */ const val FAMILIE_ROUTE = "plg-gatecontrol-familie" diff --git a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt index bc43061d..9f287a59 100644 --- a/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt +++ b/app/src/main/java/com/gatecontrol/android/push/PushNotifier.kt @@ -96,6 +96,8 @@ class PushNotifier @Inject constructor( private fun channelName(ctx: Context, spec: ChannelSpec): String = spec.key?.let { PushText.channelName(it) }?.let { ctx.getString(it) } + // Mail's new mails: the channel "Mail" (contract D), whatever the server calls the topic + ?: ctx.getString(R.string.mail_channel).takeIf { spec.id == PushChannels.topicChannelId(com.gatecontrol.android.mail.MailPush.TOPIC_MAIL) } ?: spec.label ?: PushText.fallbackLabel(spec.id.removePrefix(PushChannels.topicChannelId(""))) @@ -317,7 +319,9 @@ class PushNotifier @Inject constructor( val vaultDest = com.gatecontrol.android.vault.VaultPush.destination(message) // Drive: a deadline opens its file, a vault request the vault's requests val driveDest = com.gatecontrol.android.drive.DrivePush.destination(message) - val intent = PushOpenActivity.intent(context, message.id, action, familieDest, doorDest, vaultDest, driveDest).setData(uri(message, slot)) + // Mail: a new mail opens it, a failed send the outbox + val mailDest = com.gatecontrol.android.mail.MailPush.destination(message) + val intent = PushOpenActivity.intent(context, message.id, action, familieDest, doorDest, vaultDest, driveDest, mailDest).setData(uri(message, slot)) return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } diff --git a/app/src/main/java/com/gatecontrol/android/push/PushOpenActivity.kt b/app/src/main/java/com/gatecontrol/android/push/PushOpenActivity.kt index 4099d68d..4d262347 100644 --- a/app/src/main/java/com/gatecontrol/android/push/PushOpenActivity.kt +++ b/app/src/main/java/com/gatecontrol/android/push/PushOpenActivity.kt @@ -109,12 +109,14 @@ class PushOpenActivity : ComponentActivity() { val doorDest = intent.getStringExtra(EXTRA_DOOR_DEST) val vaultDest = intent.getStringExtra(EXTRA_VAULT_DEST) val driveDest = intent.getStringExtra(EXTRA_DRIVE_DEST) + val mailDest = intent.getStringExtra(EXTRA_MAIL_DEST) val navTarget = when { actionType == PushActionType.OPEN_APP_ROUTE -> PushNavTarget.forRoute(target, id, doorDest) familieDest != null && actionType == null -> PushNavTarget.Familie(familieDest, id) doorDest != null && actionType == null -> PushNavTarget.Door(doorDest, id) vaultDest != null && actionType == null -> PushNavTarget.Vault(vaultDest, id) driveDest != null && actionType == null -> PushNavTarget.Drive(driveDest, id) + mailDest != null && actionType == null -> PushNavTarget.Mail(mailDest, id) else -> PushNavTarget.Detail(id) } PushNavigation.request(navTarget) @@ -134,6 +136,7 @@ class PushOpenActivity : ComponentActivity() { private const val EXTRA_FAMILIE_DEST = "com.gatecontrol.android.push.extra.FAMILIE_DEST" private const val EXTRA_VAULT_DEST = "com.gatecontrol.android.push.extra.VAULT_DEST" private const val EXTRA_DRIVE_DEST = "com.gatecontrol.android.push.extra.DRIVE_DEST" + private const val EXTRA_MAIL_DEST = "com.gatecontrol.android.push.extra.MAIL_DEST" private const val EXTRA_DOOR_DEST = "com.gatecontrol.android.push.extra.DOOR_DEST" private const val EXTRA_CHAT = "com.gatecontrol.android.push.extra.CHAT" private const val EXTRA_CHAT_MESSAGE = "com.gatecontrol.android.push.extra.CHAT_MESSAGE" @@ -175,10 +178,12 @@ class PushOpenActivity : ComponentActivity() { doorDest: String? = null, vaultDest: String? = null, driveDest: String? = null, + mailDest: String? = null, ): Intent = Intent(context, PushOpenActivity::class.java) .putExtra(EXTRA_ID, id) .apply { if (driveDest != null) putExtra(EXTRA_DRIVE_DEST, driveDest) } + .apply { if (mailDest != null) putExtra(EXTRA_MAIL_DEST, mailDest) } .apply { if (familieDest != null) putExtra(EXTRA_FAMILIE_DEST, familieDest) } .apply { if (doorDest != null) putExtra(EXTRA_DOOR_DEST, doorDest) } .apply { if (vaultDest != null) putExtra(EXTRA_VAULT_DEST, vaultDest) } diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailAddAccountScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailAddAccountScreen.kt new file mode 100644 index 00000000..aa553a97 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailAddAccountScreen.kt @@ -0,0 +1,316 @@ +package com.gatecontrol.android.ui.mail + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MProvider +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSegmented +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.setup.openSigninBrowser +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** + * "Konto hinzufügen" (mockup "Konto hinzufügen"): the providers (Microsoft with a note while the server has no + * client id), then the form with the provider's hint, "Verbinden"; or the Microsoft sign-in in the browser. + */ +@Composable +fun MailAddAccountScreen(onBack: () -> Unit, onDone: (Long) -> Unit, viewModel: MailAddAccountViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val event by viewModel.event.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + LaunchedEffect(event) { + when (val e = event) { + null -> Unit + is AddAccountEvent.Browser -> if (!openSigninBrowser(context, e.url)) toast(context, context.getString(R.string.mail_no_app)) + is AddAccountEvent.Done -> { + toast(context, context.getString(R.string.mail_account_added, e.account.label)) + onDone(e.account.id) + } + } + if (event != null) viewModel.consumed() + } + // back from the browser without an answer: the buttons work again + LifecycleResumeEffect(viewModel) { + viewModel.browserClosed() + onPauseOrDispose { } + } + + val limitReached = me?.let { it.accounts.size >= it.limits.accountsMax } == true && viewModel.relogin == null + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar( + title = stringResource(if (viewModel.relogin != null) R.string.mail_relogin else R.string.mail_add_account), + onBack = { if (state.provider != null && viewModel.relogin == null) viewModel.pick(null) else onBack() }, + backDescription = stringResource(R.string.mail_back), + ) + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(top = 4.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (limitReached) { + GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert) { + Text((me?.limits?.accountsMax ?: 0).let { n -> pluralStringResource(R.plurals.mail_limit_reached, n, n) }, style = MaterialTheme.typography.bodySmall) + } + } + if (state.loading) { + Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } + if (state.provider == null && !state.loading && viewModel.relogin == null) { + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface)) { + state.providers.forEachIndexed { i, p -> + if (i > 0) MailDivider() + ProviderRow(p, microsoftReady = viewModel.microsoftReady(), enabled = !limitReached) { viewModel.pick(p) } + } + } + if (state.providers.isEmpty()) state.error?.let { ErrorBox(mailText(context, it), onRetry = null) } + } + val p = state.provider + if (p != null && p.auth == MProvider.AUTH_MICROSOFT) { + MicrosoftBox(state, ready = viewModel.microsoftReady(), onStart = viewModel::startMicrosoft) + } else if (p != null) { + AccountFormCard(p, state, viewModel, enabled = !limitReached) + } + } + } +} + +/** The provider's mark (mockup: colored square with letters). */ +@Composable +private fun ProviderLogo(id: String) { + val (bg, fg, text) = when (id) { + MAccount.PROVIDER_M365, MAccount.PROVIDER_OUTLOOK -> Triple(Color(0xFF1F4FA0), Color.White, "MS") + MAccount.PROVIDER_GMX -> Triple(Color(0xFF1C449B), Color.White, "GMX") + MAccount.PROVIDER_WEBDE -> Triple(Color(0xFFFFD800), Color(0xFF1A1D22), "WEB") + MAccount.PROVIDER_TONLINE -> Triple(Color(0xFFE20074), Color.White, "T") + MAccount.PROVIDER_GMAIL -> Triple(Color.White, Color(0xFFD93025), "G") + else -> Triple(GateControlTheme.extraColors.panel2, MaterialTheme.colorScheme.onSurface, "@") + } + Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(bg), contentAlignment = Alignment.Center) { + Text(text, color = fg, fontWeight = FontWeight.Bold, fontSize = 11.sp) + } +} + +@Composable +private fun ProviderRow(p: MProvider, microsoftReady: Boolean, enabled: Boolean, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + val ms = p.auth == MProvider.AUTH_MICROSOFT + Row( + Modifier.fillMaxWidth().heightIn(min = 60.dp).clickable(enabled = enabled, role = Role.Button, onClick = onClick).padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ProviderLogo(p.id) + Column(Modifier.weight(1f)) { + Text(p.label, style = MaterialTheme.typography.bodyLarge) + if (ms && !microsoftReady) Text(stringResource(R.string.mail_ms_not_set_up), style = MaterialTheme.typography.bodySmall, color = extra.warn) + } + Text( + stringResource( + when (p.auth) { + MProvider.AUTH_MICROSOFT -> R.string.mail_auth_microsoft + MProvider.AUTH_APP_PASSWORD -> R.string.mail_auth_app_password + MProvider.AUTH_IMAP -> R.string.mail_auth_imap + else -> R.string.mail_auth_password + }, + ), + style = MaterialTheme.typography.labelSmall, + color = extra.muted, + modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(extra.panel2).padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun MicrosoftBox(state: AddAccountState, ready: Boolean, onStart: () -> Unit) { + val context = mailContext() + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ProviderLogo(MAccount.PROVIDER_M365) + Text(stringResource(R.string.mail_provider_microsoft), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } + if (!ready) { + GcBanner(tone = GcTone.Warn, icon = GcIcons.Info) { Text(stringResource(R.string.mail_ms_not_set_up_text), style = MaterialTheme.typography.bodySmall) } + return@Column + } + Text(stringResource(R.string.mail_ms_text), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + state.error?.let { e -> + GcBanner(tone = GcTone.Error, icon = GcIcons.Alert) { + Text( + listOfNotNull( + when (e) { + "cancelled" -> stringResource(R.string.mail_ms_cancelled) + "ms_failed" -> stringResource(R.string.mail_ms_failed) + MailAddAccountViewModel.MS_NOT_SET_UP -> stringResource(R.string.mail_ms_not_set_up_text) + else -> mailText(context, e) + }, + state.errorHint, + ).joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + ) + } + } + GcPrimaryButton( + stringResource(if (state.waitingBrowser) R.string.mail_ms_waiting else R.string.mail_ms_start), + onClick = onStart, + loading = state.busy, + enabled = !state.busy, + ) + } +} + +/** " einrichten": address, password (app password), name and color, the provider's hint, "Verbinden". */ +@Composable +private fun AccountFormCard(p: MProvider, state: AddAccountState, viewModel: MailAddAccountViewModel, enabled: Boolean) { + val f = state.form + val context = mailContext() + val extra = GateControlTheme.extraColors + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), RoundedCornerShape(18.dp)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.mail_setup_provider, p.label), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + GcTextField( + value = f.email, + onValueChange = { v -> viewModel.edit { it.copy(email = v.trim()) } }, + label = stringResource(R.string.mail_email), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.onFocusChanged { if (!it.isFocused) viewModel.discover() }, + ) + GcTextField( + value = f.password, + onValueChange = { v -> viewModel.edit { it.copy(password = v) } }, + label = stringResource(if (p.auth == MProvider.AUTH_APP_PASSWORD) R.string.mail_app_password else R.string.mail_password), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + GcTextField(value = f.name, onValueChange = { v -> viewModel.edit { it.copy(name = v) } }, label = stringResource(R.string.mail_name_color)) + ColorChoice(f.color) { c -> viewModel.edit { it.copy(color = c) } } + GcSegmented( + options = listOf(MAccount.KIND_PERSONAL to stringResource(R.string.mail_kind_personal), MAccount.KIND_BUSINESS to stringResource(R.string.mail_kind_business)), + selected = f.kind, + onSelect = { k -> viewModel.edit { it.copy(kind = k) } }, + ) + if (f.manual) ServerFields(f, viewModel) + state.hint?.let { hint -> + Row( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(extra.warnBg).padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(GcIcons.Info, contentDescription = null, tint = extra.warn, modifier = Modifier.size(18.dp)) + Text(hint, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + } + } + p.help?.let { url -> TextButton(onClick = { openLink(context, url) }) { Text(stringResource(R.string.mail_provider_help)) } } + state.error?.let { e -> + GcBanner(tone = GcTone.Error, icon = GcIcons.Alert) { + Text(listOfNotNull(mailText(context, e), state.errorHint).joinToString("\n"), style = MaterialTheme.typography.bodySmall) + } + } + Text(stringResource(R.string.mail_password_note), style = MaterialTheme.typography.bodySmall, color = extra.muted) + GcPrimaryButton(stringResource(R.string.mail_connect), onClick = viewModel::connect, loading = state.busy, enabled = enabled && !state.busy) + } +} + +/** IMAP and SMTP by hand ("Andere / eigene Domain"); only encrypted connections are offered. */ +@Composable +private fun ServerFields(f: AccountForm, viewModel: MailAddAccountViewModel) { + val sec = listOf("tls" to "SSL/TLS", "starttls" to "STARTTLS") + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + GcTextField(value = f.username, onValueChange = { v -> viewModel.edit { it.copy(username = v) } }, label = stringResource(R.string.mail_username), placeholder = f.email) + Text(stringResource(R.string.mail_imap), style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcTextField(value = f.imapHost, onValueChange = { v -> viewModel.edit { it.copy(imapHost = v.trim()) } }, label = stringResource(R.string.mail_server), modifier = Modifier.weight(1f)) + GcTextField(value = f.imapPort, onValueChange = { v -> viewModel.edit { it.copy(imapPort = v.filter(Char::isDigit).take(5)) } }, label = stringResource(R.string.mail_port), modifier = Modifier.width(96.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + GcSegmented(options = sec, selected = f.imapSecurity, onSelect = { v -> viewModel.edit { it.copy(imapSecurity = v, imapPort = if (v == "tls") "993" else "143") } }) + Text(stringResource(R.string.mail_smtp), style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcTextField(value = f.smtpHost, onValueChange = { v -> viewModel.edit { it.copy(smtpHost = v.trim()) } }, label = stringResource(R.string.mail_server), modifier = Modifier.weight(1f)) + GcTextField(value = f.smtpPort, onValueChange = { v -> viewModel.edit { it.copy(smtpPort = v.filter(Char::isDigit).take(5)) } }, label = stringResource(R.string.mail_port), modifier = Modifier.width(96.dp), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + } + GcSegmented(options = sec, selected = f.smtpSecurity, onSelect = { v -> viewModel.edit { it.copy(smtpSecurity = v, smtpPort = if (v == "tls") "465" else "587") } }) + } +} + +/** The account's color: the dots of the mockups. */ +@Composable +internal fun ColorChoice(selected: String?, onPick: (String) -> Unit) { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + MailLogic.COLORS.forEach { c -> + val on = c.equals(selected, ignoreCase = true) + val color = accountColor(c, Color.Gray) + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .then(if (on) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier) + .selectable(selected = on, role = Role.RadioButton) { onPick(c) } + .semantics { contentDescription = c }, + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(24.dp).clip(CircleShape).background(color)) + } + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailAddAccountViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailAddAccountViewModel.kt new file mode 100644 index 00000000..4d17fd3e --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailAddAccountViewModel.kt @@ -0,0 +1,272 @@ +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.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MProvider +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MServer +import com.gatecontrol.android.mail.MailJson +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailMicrosoft +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.mail.MailSigninRedirects +import com.gatecontrol.android.navigation.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The form of " einrichten". */ +data class AccountForm( + val email: String = "", + val password: String = "", + val name: String = "", + val color: String = MailLogic.COLORS.first(), + val kind: String = MAccount.KIND_PERSONAL, + val username: String = "", + val imapHost: String = "", + val imapPort: String = "993", + val imapSecurity: String = "tls", + val smtpHost: String = "", + val smtpPort: String = "465", + val smtpSecurity: String = "tls", + /** "Andere / eigene Domain": the servers by hand (filled in by the discovery when it finds them). */ + val manual: Boolean = false, +) + +data class AddAccountState( + val providers: List = emptyList(), + val loading: Boolean = true, + val provider: MProvider? = null, + val form: AccountForm = AccountForm(), + val hint: String? = null, + val busy: Boolean = false, + val error: String? = null, + val errorHint: String? = null, + /** Waiting for the browser's answer of the Microsoft sign-in. */ + val waitingBrowser: Boolean = false, +) + +sealed class AddAccountEvent { + /** Open the Microsoft sign-in page in a Custom Tab. */ + data class Browser(val url: String) : AddAccountEvent() + data class Done(val account: MAccount) : AddAccountEvent() +} + +/** + * "Konto hinzufügen" (mockup "Konto hinzufügen"): the providers, the form with the provider's hint and "Verbinden" + * (the server checks IMAP and SMTP first), or "Mit Microsoft anmelden" (PKCE in the browser, the server redeems the + * code). With `account` given: sign in to that Microsoft account again. + */ +@HiltViewModel +class MailAddAccountViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, + private val saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + val me: StateFlow = repository.me + /** Sign in to this account again (Microsoft). */ + val relogin: Long? = saved.get(Screen.MailAddAccount.ARG_ACCOUNT)?.toLongOrNull() + + private val _state = MutableStateFlow(AddAccountState()) + val state: StateFlow = _state.asStateFlow() + + private val _event = MutableStateFlow(null) + val event: StateFlow = _event.asStateFlow() + + init { + viewModelScope.launch { + val me = repository.me.value ?: repository.refresh().valueOrNull() + val used = me?.accounts?.map { it.color }.orEmpty() + _state.update { it.copy(form = it.form.copy(color = MailLogic.nextColor(used))) } + val list = api.providers() + _state.update { + it.copy( + loading = false, + providers = withMicrosoft(list.valueOrNull().orEmpty()), + error = if (list is MResult.Ok) null else MailHomeViewModel.code(list), + ) + } + // "Neu anmelden" of a Microsoft account: straight to the browser (not again after a restart while it is open) + if (relogin != null && saved.get(KEY_STATE) == null && me?.account(relogin)?.microsoft == true) { + _state.update { s -> s.copy(provider = s.providers.firstOrNull { it.auth == MProvider.AUTH_MICROSOFT }) } + startMicrosoft() + } + } + // the browser's answer (MailSigninActivity): only one this screen asked for + viewModelScope.launch { + MailSigninRedirects.pending.filterNotNull().collect { raw -> + MailSigninRedirects.consume(raw) + onRedirect(raw) + } + } + } + + /** The server's list, with a Microsoft entry when it has none (contract: always listed, with a hint when off). */ + private fun withMicrosoft(list: List): List { + if (list.any { it.auth == MProvider.AUTH_MICROSOFT }) return list + return listOf(MProvider(MAccount.PROVIDER_M365, context.getString(R.string.mail_provider_microsoft), MProvider.AUTH_MICROSOFT, null, null, null)) + list + } + + fun microsoftReady(): Boolean = me.value?.let { it.has(MMe.FEATURE_MICROSOFT) && it.microsoft.clientId != null } == true + + fun lang(): String = context.resources.configuration.locales[0].language.takeIf { it == "en" } ?: "de" + + fun pick(p: MProvider?) { + _state.update { s -> + s.copy( + provider = p, + hint = p?.hint(lang()), + error = null, + errorHint = null, + form = s.form.copy( + manual = p?.auth == MProvider.AUTH_IMAP, + name = s.form.name.ifEmpty { p?.label.orEmpty() }, + ), + ) + } + if (p?.auth == MProvider.AUTH_MICROSOFT) startMicrosoft() + } + + fun edit(change: (AccountForm) -> AccountForm) = _state.update { it.copy(form = change(it.form), error = null, errorHint = null) } + + /** "Andere / eigene Domain": the servers of the address, when the server finds them. */ + fun discover() { + val email = _state.value.form.email.trim() + if (!MailLogic.isEmail(email) || _state.value.provider?.auth != MProvider.AUTH_IMAP) return + viewModelScope.launch { + val d = api.discover(email).valueOrNull() ?: return@launch + _state.update { s -> + s.copy( + hint = (if (lang() == "en") d.hintEn ?: d.hintDe else d.hintDe ?: d.hintEn) ?: s.hint, + form = s.form.copy( + imapHost = s.form.imapHost.ifEmpty { d.imap?.host.orEmpty() }, + imapPort = d.imap?.port?.toString() ?: s.form.imapPort, + imapSecurity = d.imap?.security ?: s.form.imapSecurity, + smtpHost = s.form.smtpHost.ifEmpty { d.smtp?.host.orEmpty() }, + smtpPort = d.smtp?.port?.toString() ?: s.form.smtpPort, + smtpSecurity = d.smtp?.security ?: s.form.smtpSecurity, + ), + ) + } + } + } + + /** "Verbinden". */ + fun connect() { + val s = _state.value + val p = s.provider ?: return + val f = s.form + if (!MailLogic.isEmail(f.email) || f.password.isEmpty()) { + _state.update { it.copy(error = "invalid") } + return + } + val imap = if (f.manual && f.imapHost.isNotBlank()) MServer(f.imapHost.trim(), f.imapPort.toIntOrNull() ?: 993, f.imapSecurity) else null + val smtp = if (f.manual && f.smtpHost.isNotBlank()) MServer(f.smtpHost.trim(), f.smtpPort.toIntOrNull() ?: 465, f.smtpSecurity) else null + _state.update { it.copy(busy = true, error = null, errorHint = null) } + viewModelScope.launch { + val r = api.addAccount(MailJson.accountBody(p.id, f.email, f.password, f.username.takeIf { f.manual }, f.name, f.color, f.kind, imap, smtp)) + done(r) + } + } + + private suspend fun done(r: MResult) { + when (r) { + is MResult.Ok -> { + repository.refresh() + _state.update { it.copy(busy = false, waitingBrowser = false) } + _event.value = AddAccountEvent.Done(r.value) + } + else -> _state.update { + it.copy( + busy = false, + waitingBrowser = false, + error = MailHomeViewModel.code(r), + errorHint = MailJson.errorHint((r as? MResult.Failed)?.error, lang()), + ) + } + } + } + + // ─── Microsoft ────────────────────────────────────────────────── + + /** "Mit Microsoft anmelden": the sign-in page in the browser; verifier and state stay here (and survive a restart). */ + fun startMicrosoft() { + val me = me.value + val clientId = me?.microsoft?.clientId + if (me == null || !me.has(MMe.FEATURE_MICROSOFT) || clientId == null) { + _state.update { it.copy(error = MS_NOT_SET_UP) } + return + } + val hash = MailMicrosoft.signatureHash(context) + if (hash == null) { + _state.update { it.copy(error = "failed") } + return + } + val redirect = MailMicrosoft.redirectUri(hash) + val verifier = MailMicrosoft.newVerifier() + val st = MailMicrosoft.newState() + val hint = relogin?.let { me.account(it)?.email } + val url = MailMicrosoft.authorizeUrl(me.microsoft.authority, clientId, me.microsoft.scopes, redirect, st, MailMicrosoft.challenge(verifier), hint) + if (url == null) { + _state.update { it.copy(error = "failed") } + return + } + saved[KEY_VERIFIER] = verifier + saved[KEY_STATE] = st + saved[KEY_REDIRECT] = redirect + _state.update { it.copy(waitingBrowser = true, error = null) } + _event.value = AddAccountEvent.Browser(url) + } + + private fun onRedirect(raw: String) { + val answer = MailMicrosoft.parse(raw) ?: return + val verifier = saved.get(KEY_VERIFIER) + val expected = saved.get(KEY_STATE) + val redirect = saved.get(KEY_REDIRECT) + // not ours (another sign-in, or a replay): ignored + if (verifier == null || expected == null || redirect == null || answer.state != expected) return + saved.remove(KEY_VERIFIER) + saved.remove(KEY_STATE) + val code = answer.code + if (code == null) { + _state.update { it.copy(waitingBrowser = false, error = if (answer.error == "access_denied") "cancelled" else "ms_failed", errorHint = answer.description) } + return + } + _state.update { it.copy(busy = true, waitingBrowser = true) } + viewModelScope.launch { + val f = _state.value.form + val r = relogin?.let { api.reloginMicrosoft(it, MailJson.microsoftBody(code, verifier, redirect, null, null, null)) } + ?: api.addMicrosoft(MailJson.microsoftBody(code, verifier, redirect, f.name.takeIf { it.isNotBlank() && it != _state.value.provider?.label }, f.color, null)) + done(r) + } + } + + /** The browser came back without an answer (closed): the form may be used again. */ + fun browserClosed() { + if (_state.value.waitingBrowser && !_state.value.busy) _state.update { it.copy(waitingBrowser = false) } + } + + fun consumed() { + _event.value = null + } + + companion object { + const val MS_NOT_SET_UP = "ms_not_set_up" + private const val KEY_VERIFIER = "ms_verifier" + private const val KEY_STATE = "ms_state" + private const val KEY_REDIRECT = "ms_redirect" + } +} 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 new file mode 100644 index 00000000..a187c68d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComponents.kt @@ -0,0 +1,393 @@ +package com.gatecontrol.android.ui.mail + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.provider.CalendarContract +import android.widget.Toast +import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MTodo +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.theme.GateControlTheme +import kotlinx.coroutines.launch +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.math.abs +import kotlin.math.roundToInt + +/** Icons of the Mail screens the common set lacks (24×24 stroke paths, as in the mockups). */ +internal object MailIcons { + val Send: ImageVector by lazy { GcIcons.fromPath("MailSend", "M3 11 21 3l-8 18-2-8z") } + val Inbox: ImageVector by lazy { GcIcons.fromPath("MailInbox", "M3 13h5l2 3h4l2-3h5M5 5h14l2 8v6H3v-6z") } + val Reply: ImageVector by lazy { GcIcons.fromPath("MailReply", "M9 14 4 9l5-5M4 9h10a6 6 0 0 1 6 6v4") } + val Forward: ImageVector by lazy { GcIcons.fromPath("MailForward", "m15 14 5-5-5-5M20 9H10a6 6 0 0 0-6 6v4") } + val Sparkle: ImageVector by lazy { + GcIcons.fromPath("MailSparkle", "M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1") + } + val Pen: ImageVector by lazy { GcIcons.fromPath("MailPen", "M4 20h4l10-10-4-4L4 16z") } + val Outbox: ImageVector by lazy { GcIcons.fromPath("MailOutbox", "M12 15V3m0 0-4 4m4-4 4 4M3 13h5l2 3h4l2-3h5v6H3z") } + val Unread: ImageVector by lazy { GcIcons.fromPath("MailUnread", "M3 5h18v14H3zM3 7l9 6 9-6") } +} + +/** The account's color (`#RRGGBB`), else a neutral one. */ +@Composable +internal fun accountColor(account: MAccount?): Color = + MailLogic.color(account?.color)?.let { Color(it) } ?: GateControlTheme.extraColors.muted + +internal fun accountColor(hex: String?, fallback: Color): Color = MailLogic.color(hex)?.let { Color(it) } ?: fallback + +/** Round dot in the account's color. */ +@Composable +internal fun MailDot(color: Color, size: Int = 8) { + Box(Modifier.size(size.dp).clip(CircleShape).background(color)) +} + +/** The time column of a mail: the time today, "Gestern", the weekday, else the date. */ +@Composable +internal fun mailTime(date: Long): String { + if (date <= 0) return "" + val zone = ZoneId.systemDefault() + val locale = com.gatecontrol.android.ui.update.currentLocale() + val at = Instant.ofEpochMilli(date).atZone(zone) + return when (MailLogic.whenOf(date, System.currentTimeMillis(), zone)) { + MailLogic.When.TIME -> at.format(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)) + MailLogic.When.YESTERDAY -> stringResource(R.string.mail_yesterday) + MailLogic.When.WEEKDAY -> at.format(DateTimeFormatter.ofPattern("EEE", locale)) + MailLogic.When.DATE -> at.format(DateTimeFormatter.ofPattern(if (locale.language == "de") "d. MMM" else "MMM d", locale)) + MailLogic.When.DATE_YEAR -> at.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(locale)) + } +} + +/** Date and time in full (the reading screen, the outbox). */ +internal fun mailDateTime(date: Long): String = + Instant.ofEpochMilli(date).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT).withLocale(Locale.getDefault())) + +/** A size in bytes as KB / MB. */ +internal fun mailSize(bytes: Long): String = when { + bytes >= 1024L * 1024 -> String.format(Locale.getDefault(), "%.1f MB", bytes / 1024.0 / 1024.0) + bytes >= 1024 -> "${(bytes / 1024.0).roundToInt()} KB" + else -> "$bytes B" +} + +/** The square with a file's type (`PDF`, `DOC` …) in its color. */ +@Composable +internal fun FileBadge(name: String, mime: String, size: Int = 40) { + val label = MailLogic.typeLabel(name, mime) + val extra = GateControlTheme.extraColors + val (bg, fg) = when (label) { + "PDF" -> extra.errorBg to MaterialTheme.colorScheme.error + "DOC" -> extra.blueBg to extra.blue + "XLS" -> extra.accentBg to extra.accentText + "IMG" -> extra.proBg to extra.pro + else -> extra.panel2 to extra.muted + } + Box(Modifier.size(size.dp).clip(RoundedCornerShape(10.dp)).background(bg), contentAlignment = Alignment.Center) { + Text(label, color = fg, fontSize = 10.sp, fontWeight = FontWeight.Bold, maxLines = 1) + } +} + +/** 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) + } +} + +/** What a swipe does on a mail row (its color, icon and label). */ +internal data class SwipeSpec(val action: String, val label: String, val icon: ImageVector, val color: Color) + +@Composable +internal fun swipeSpec(action: String?, unread: Boolean): SwipeSpec? { + val extra = GateControlTheme.extraColors + return when (MailLogic.swipe(action)) { + MailLogic.SWIPE_ARCHIVE -> SwipeSpec(MailLogic.SWIPE_ARCHIVE, stringResource(R.string.mail_archive), GcIcons.Archive, extra.accentText) + MailLogic.SWIPE_DELETE -> SwipeSpec(MailLogic.SWIPE_DELETE, stringResource(R.string.mail_delete), GcIcons.Trash, MaterialTheme.colorScheme.error) + MailLogic.SWIPE_READ -> SwipeSpec( + MailLogic.SWIPE_READ, + stringResource(if (unread) R.string.mail_mark_read else R.string.mail_mark_unread), + MailIcons.Unread, + extra.blue, + ) + else -> null + } +} + +/** + * A row that can be swiped: to the left does [left], to the right [right] (null: that way does nothing). Past a + * third of the width the action fires. The actions are also offered to accessibility services. + */ +@Composable +internal fun SwipeRow( + left: SwipeSpec?, + right: SwipeSpec?, + onSwipe: (SwipeSpec) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val offset = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + val width = remember { floatArrayOf(1f) } + val actions = listOfNotNull(left, right).distinctBy { it.action }.map { s -> + CustomAccessibilityAction(s.label) { onSwipe(s); true } + } + Box( + modifier + .fillMaxWidth() + .onSizeChanged { width[0] = it.width.toFloat().coerceAtLeast(1f) } + .semantics { if (actions.isNotEmpty()) customActions = actions }, + ) { + val x = offset.value + val spec = if (x < 0) left else if (x > 0) right else null + if (spec != null) { + Row( + Modifier.matchParentSize().background(spec.color.copy(alpha = 0.18f)).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (x < 0) Arrangement.End else Arrangement.Start, + ) { + Icon(spec.icon, contentDescription = null, tint = spec.color, modifier = Modifier.size(20.dp)) + Text(spec.label, color = spec.color, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(start = 8.dp)) + } + } + Box( + Modifier + .offset { IntOffset(offset.value.roundToInt(), 0) } + .background(MaterialTheme.colorScheme.background) + .pointerInput(left, right) { + if (left == null && right == null) return@pointerInput + detectHorizontalDragGestures( + onDragEnd = { + val w = width[0] + val v = offset.value + val fired = if (v < 0) left else right + scope.launch { + if (fired != null && abs(v) > w / 3f) { + offset.animateTo(if (v < 0) -w else w) + onSwipe(fired) + offset.snapTo(0f) + } else { + offset.animateTo(0f) + } + } + }, + onDragCancel = { scope.launch { offset.animateTo(0f) } }, + ) { change, drag -> + val next = offset.value + drag + val allowed = (next < 0 && left != null) || (next > 0 && right != null) || next == 0f + if (allowed) { + change.consume() + scope.launch { offset.snapTo(next.coerceIn(-width[0], width[0])) } + } + } + }, + ) { content() } + } +} + +/** One mail in a list (mockup "Posteingang"): avatar, dot, sender, time, subject, snippet. */ +@Composable +internal fun MailRow( + item: MSummary, + account: MAccount?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false, +) { + val extra = GateControlTheme.extraColors + val color = accountColor(account) + val weight = if (item.unread) FontWeight.Bold else FontWeight.Medium + val longLabel = stringResource(R.string.mail_actions) + Row( + modifier + .fillMaxWidth() + .background(if (selected) extra.accentBg else Color.Transparent) + .combinedClickable(onClick = onClick, onLongClick = onLongClick, onLongClickLabel = longLabel) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SenderAvatar(item.from.name, item.from.email, color) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + MailDot(color) + Text( + item.from.label.ifEmpty { stringResource(R.string.mail_unknown_sender) }, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = weight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.threadCount > 1) Text(item.threadCount.toString(), style = MaterialTheme.typography.labelSmall, color = extra.muted) + if (item.flagged) Icon(GcIcons.Flag, contentDescription = stringResource(R.string.mail_flagged), tint = extra.warn, modifier = Modifier.size(14.dp)) + Text(mailTime(item.date), style = MaterialTheme.typography.bodySmall, color = if (item.unread) extra.accentText else extra.muted) + } + Text( + item.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = weight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val preview = item.snippet.ifBlank { item.aiSummary.orEmpty() } + if (preview.isNotBlank()) { + Text(preview, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (item.hasAttachments || item.savedToDrive) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + if (item.hasAttachments) Icon(GcIcons.Paperclip, contentDescription = stringResource(R.string.mail_has_attachments), tint = extra.muted, modifier = Modifier.size(14.dp)) + if (item.savedToDrive) MailTag(stringResource(R.string.mail_saved_to_drive), extra.accentBg, extra.accentText) + } + } + } + } +} + +@Composable +internal fun MailTag(text: String, bg: Color, fg: Color) { + Box(Modifier.clip(RoundedCornerShape(999.dp)).background(bg).padding(horizontal = 7.dp, vertical = 2.dp)) { + Text(text, color = fg, fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1) + } +} + +/** Thin line under a row (the mockup's `border-bottom`). */ +@Composable +internal fun MailDivider() { + Box(Modifier.fillMaxWidth().height(1.dp).background(GateControlTheme.extraColors.panel2)) +} + +/** A refusal or failure of the Mail plugin as a sentence. */ +internal fun mailText(context: Context, code: String?): String = context.getString( + when (code) { + "offline", "http_0" -> R.string.mail_err_offline + "not_configured" -> R.string.mail_err_not_configured + "not_allowed", "http_403" -> R.string.mail_err_not_allowed + "limit" -> R.string.mail_err_limit + "auth_failed" -> R.string.mail_err_auth_failed + "imap_disabled" -> R.string.mail_err_imap_disabled + "unreachable" -> R.string.mail_err_unreachable + "tls" -> R.string.mail_err_tls + "invalid", "http_400" -> R.string.mail_err_invalid + "too_large", "http_413" -> R.string.mail_err_too_large + "ai_off" -> R.string.mail_err_ai_off + "ai_unavailable" -> R.string.mail_err_ai_unavailable + "rate_limited", "http_429" -> R.string.mail_err_rate_limited + "familie_unavailable" -> R.string.mail_err_familie_unavailable + "not_found", "http_404" -> R.string.mail_err_not_found + "conflict", "http_409" -> R.string.mail_err_conflict + "encrypted" -> R.string.mail_err_encrypted + "forbidden", "login_required" -> R.string.mail_err_not_allowed + "microsoft_off" -> R.string.mail_err_microsoft_off + "attachments_unsupported" -> R.string.mail_err_attachments_unsupported + "images_blocked" -> R.string.mail_err_images_blocked + "images_off" -> R.string.mail_err_images_off + "drive_unavailable" -> R.string.mail_err_drive_unavailable + "no_folder" -> R.string.mail_err_no_folder + "not_waiting" -> R.string.mail_err_not_waiting + "exists" -> R.string.mail_err_exists + "links_disabled" -> R.string.mail_err_links_disabled + else -> R.string.mail_err_failed + }, +) + +internal fun toast(context: Context, text: String) = Toast.makeText(context, text, Toast.LENGTH_LONG).show() + +/** An address in a Custom Tab (the in-app browser), else the default browser. */ +internal fun openLink(context: Context, url: String) { + val uri = url.toUri() + val ok = runCatching { CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(context, uri) }.isSuccess || + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }.isSuccess + if (!ok) toast(context, context.getString(R.string.mail_no_app)) +} + +/** Another app shows a link (`tel:`, a web page from a mail): never inside the app. */ +internal fun openExternal(context: Context, url: String) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: ActivityNotFoundException) { + toast(context, context.getString(R.string.mail_no_app)) + } catch (_: SecurityException) { + toast(context, context.getString(R.string.mail_no_app)) + } +} + +/** A downloaded attachment opened in another app (FileProvider `mail_open`). */ +internal fun openFile(context: Context, file: File, mime: String) { + val uri = FileProvider.getUriForFile(context, context.packageName + ".provider", file) + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, mime).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(Intent.createChooser(intent, file.name).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: ActivityNotFoundException) { + toast(context, context.getString(R.string.mail_no_app)) + } +} + +/** A to-do of the AI into the device's calendar (the calendar app asks; no permission needed). */ +internal fun addToCalendar(context: Context, todo: MTodo, title: String) { + val (start, allDay) = MailLogic.todoStart(todo, ZoneId.systemDefault()) ?: return + val intent = Intent(Intent.ACTION_INSERT) + .setData(CalendarContract.Events.CONTENT_URI) + .putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, start) + .putExtra(CalendarContract.EXTRA_EVENT_END_TIME, start + if (allDay) 24 * 3600_000L else 3600_000L) + .putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, allDay) + .putExtra(CalendarContract.Events.TITLE, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + toast(context, context.getString(R.string.mail_no_calendar)) + } +} + +/** The context of a composable, for the helpers above. */ +@Composable +internal fun mailContext(): Context = LocalContext.current 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 new file mode 100644 index 00000000..17fa212a --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeScreen.kt @@ -0,0 +1,446 @@ +package com.gatecontrol.android.ui.mail + +import android.app.DatePickerDialog +import android.app.TimePickerDialog +import android.text.format.DateFormat +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +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.MAccount +import com.gatecontrol.android.mail.MAtt +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** + * Writing a mail (mockup "Schreiben"): the account it goes from, recipients with suggestions, subject, text, the + * quote of an answer, attachments from the device or from Drive (copy or link), the server's warnings, and send — + * with the undo bar on the list afterwards — or "Senden planen". + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MailComposeScreen(onClose: () -> Unit, viewModel: MailComposeViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val event by viewModel.event.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + var attach by remember { mutableStateOf(false) } + var plan by remember { mutableStateOf(false) } + var more by remember { mutableStateOf(false) } + var askDiscard by remember { mutableStateOf(false) } + var noSubject by remember { mutableStateOf(null) } + var showQuote by remember { mutableStateOf(false) } + val pickFiles = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> + if (uris.isNotEmpty()) viewModel.addFiles(uris) + } + + LaunchedEffect(event) { + when (val e = event) { + null -> Unit + ComposeEvent.Sent, ComposeEvent.Discarded -> onClose() + ComposeEvent.Saved -> { + toast(context, context.getString(R.string.mail_draft_saved)) + onClose() + } + is ComposeEvent.Toast -> toast(context, e.text) + } + if (event != null) viewModel.consumed() + } + BackHandler { viewModel.close() } + + val send: (Long?) -> Unit = { at -> if (state.subject.isBlank()) noSubject = at ?: 0L else viewModel.send(at) } + val account = me?.account(state.accountId) + + Column(Modifier.fillMaxSize().imePadding()) { + Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.mail_close), onClick = viewModel::close, tint = MaterialTheme.colorScheme.onBackground) + Text( + stringResource( + when (viewModel.mode) { + "reply", "replyAll" -> R.string.mail_reply + "forward" -> R.string.mail_forward + else -> R.string.mail_new + }, + ), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f).padding(start = 4.dp), + maxLines = 1, + ) + if (state.saving) CircularProgressIndicator(Modifier.size(16.dp).padding(end = 4.dp), strokeWidth = 2.dp) + Box(Modifier.size(44.dp).clip(RoundedCornerShape(12.dp)).background(extra.panel2)) { + GcIconButton(GcIcons.Paperclip, contentDescription = stringResource(R.string.mail_attach), onClick = { attach = true }, size = 44.dp, tint = MaterialTheme.colorScheme.onBackground) + } + Box { + val sendLabel = stringResource(R.string.mail_send) + val planLabel = stringResource(R.string.mail_plan) + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .combinedClickable( + enabled = !state.sending && !state.loading, + role = Role.Button, + onClick = { send(null) }, + onLongClick = { plan = true }, + onLongClickLabel = planLabel, + ) + .semantics { contentDescription = sendLabel }, + contentAlignment = Alignment.Center, + ) { + if (state.sending) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + else Icon(MailIcons.Send, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(22.dp)) + } + } + Box { + GcIconButton(GcIcons.MoreVertical, contentDescription = stringResource(R.string.mail_more), onClick = { more = true }, tint = MaterialTheme.colorScheme.onBackground) + DropdownMenu(expanded = more, onDismissRequest = { more = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.mail_plan)) }, onClick = { more = false; plan = true }) + DropdownMenuItem(text = { Text(stringResource(R.string.mail_discard)) }, onClick = { more = false; askDiscard = true }) + } + } + } + if (state.loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Column + } + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + FromRow(me, account, onPick = viewModel::setAccount) + RecipientRow(RecipientField.TO, stringResource(R.string.mail_to), state, viewModel, trailing = { + if (!state.showCc) TextButton(onClick = viewModel::showCc) { Text(stringResource(R.string.mail_cc_bcc)) } + }) + if (state.showCc) { + RecipientRow(RecipientField.CC, stringResource(R.string.mail_cc), state, viewModel) + RecipientRow(RecipientField.BCC, stringResource(R.string.mail_bcc), state, viewModel) + } + FieldRow(stringResource(R.string.mail_subject)) { + BasicTextField( + value = state.subject, + onValueChange = viewModel::setSubject, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth().semantics { contentDescription = context.getString(R.string.mail_subject) }, + ) + } + if (state.warnings.isNotEmpty() || state.error != null) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + state.warnings.forEach { w -> + GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert) { + Text(w.text.ifBlank { warningText(w.kind) }, style = MaterialTheme.typography.bodySmall) + } + } + state.error?.let { GcBanner(tone = GcTone.Error, icon = GcIcons.Alert) { Text(mailText(context, it), style = MaterialTheme.typography.bodySmall) } } + } + } + state.attachments.forEach { a -> AttachmentRow(a, onRemove = { viewModel.removeAttachment(a) }) } + BasicTextField( + value = state.text, + onValueChange = viewModel::setText, + textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.9f), fontSize = MaterialTheme.typography.bodyLarge.fontSize, lineHeight = MaterialTheme.typography.bodyLarge.lineHeight), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp) + .padding(horizontal = 16.dp, vertical = 14.dp) + .semantics { contentDescription = context.getString(R.string.mail_text) }, + ) + if (state.quote.isNotBlank()) { + TextButton(onClick = { showQuote = !showQuote }, modifier = Modifier.padding(horizontal = 8.dp)) { + Text(stringResource(if (showQuote) R.string.mail_quote_hide else R.string.mail_quote_show)) + } + if (showQuote) { + Text( + MailLogic.htmlToText(state.quote).trim(), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp), + ) + } + } + } + } + + if (attach) { + MailAttachSheet( + drive = me?.has(MMe.FEATURE_DRIVE) == true, + onDevice = { + attach = false + pickFiles.launch(arrayOf("*/*")) + }, + onDrive = { picks -> + attach = false + viewModel.addDrive(picks) + }, + onDismiss = { attach = false }, + ) + } + if (plan) PlanDialog(onPick = { at -> plan = false; send(at) }, onDismiss = { plan = false }) + if (askDiscard) { + AlertDialog( + onDismissRequest = { askDiscard = false }, + title = { Text(stringResource(R.string.mail_discard_title)) }, + text = { Text(stringResource(R.string.mail_discard_text)) }, + confirmButton = { TextButton(onClick = { askDiscard = false; viewModel.discard() }) { Text(stringResource(R.string.mail_discard)) } }, + dismissButton = { TextButton(onClick = { askDiscard = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } + noSubject?.let { at -> + AlertDialog( + onDismissRequest = { noSubject = null }, + title = { Text(stringResource(R.string.mail_no_subject_title)) }, + text = { Text(stringResource(R.string.mail_no_subject_text)) }, + confirmButton = { TextButton(onClick = { noSubject = null; viewModel.send(at.takeIf { it > 0 }) }) { Text(stringResource(R.string.mail_send)) } }, + dismissButton = { TextButton(onClick = { noSubject = null }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } +} + +@Composable +private fun warningText(kind: String): String = when (kind) { + "too_large" -> stringResource(R.string.mail_warn_too_large) + "password" -> stringResource(R.string.mail_warn_password) + "no_subject" -> stringResource(R.string.mail_no_subject_text) + else -> stringResource(R.string.mail_warn_other) +} + +/** A row of the head (mockup `.f`): its label left. */ +@Composable +private fun FieldRow(label: String, trailing: @Composable () -> Unit = {}, content: @Composable () -> Unit) { + Column { + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.faint, modifier = Modifier.width(52.dp)) + Box(Modifier.weight(1f)) { content() } + trailing() + } + MailDivider() + } +} + +/** "Von": the account it goes from (tap: another one). */ +@Composable +private fun FromRow(me: MMe?, account: MAccount?, onPick: (Long) -> Unit) { + var open by remember { mutableStateOf(false) } + val accounts = me?.accounts.orEmpty() + FieldRow(stringResource(R.string.mail_from)) { + Box { + Row( + Modifier.fillMaxWidth().clickable(enabled = accounts.size > 1, role = Role.DropdownList) { open = true }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MailDot(accountColor(account)) + 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)) + } + 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) } }, + onClick = { open = false; onPick(a.id) }, + ) + } + } + } + } +} + +/** "An" / "Cc" / "Bcc": the recipients as chips, what is typed, the suggestions below. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RecipientRow(f: RecipientField, label: String, state: ComposeState, viewModel: MailComposeViewModel, trailing: @Composable () -> Unit = {}) { + val extra = GateControlTheme.extraColors + val context = mailContext() + FieldRow(label, trailing = trailing) { + FlowRow(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + state.recipients(f).forEach { a -> + Row( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(extra.panel2) + .clickable(role = Role.Button, onClickLabel = context.getString(R.string.mail_remove)) { viewModel.remove(f, a) } + .padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(a.label, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + Icon(GcIcons.Close, contentDescription = null, modifier = Modifier.size(14.dp), tint = extra.muted) + } + } + BasicTextField( + value = state.inputs[f].orEmpty(), + onValueChange = { viewModel.setInput(f, it) }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { viewModel.commit(f) }), + modifier = Modifier + .widthIn(min = 120.dp) + .padding(vertical = 4.dp) + .onFocusChanged { if (!it.isFocused) viewModel.commit(f) } + .semantics { contentDescription = label }, + ) + } + } + if (state.suggestFor == f && state.suggestions.isNotEmpty()) { + Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface)) { + state.suggestions.take(6).forEach { c -> + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { viewModel.pick(f, c) }.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(if (c.source == "person") GcIcons.User else GcIcons.History, contentDescription = null, tint = extra.muted, modifier = Modifier.size(18.dp)) + Column(Modifier.weight(1f)) { + Text(c.name ?: c.email, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + if (c.name != null) Text(c.email, style = MaterialTheme.typography.bodySmall, color = extra.muted, maxLines = 1) + } + } + } + } + } +} + +/** An attachment of the mail: type, name, size, "Link", remove. */ +@Composable +private fun AttachmentRow(a: ComposeAttachment, onRemove: () -> Unit) { + val extra = GateControlTheme.extraColors + val (mime, link) = when (a) { + is ComposeAttachment.Saved -> a.a.mime to a.a.asLink + is ComposeAttachment.Pending -> a.mime to ((a.att as? MAtt.Drive)?.asLink == true) + is ComposeAttachment.Uploading -> "application/octet-stream" to false + } + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FileBadge(a.name, mime, size = 36) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(a.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull( + a.size.takeIf { it >= 0 }?.let { mailSize(it) }, + stringResource(R.string.mail_attach_link).takeIf { link }, + stringResource(R.string.mail_uploading).takeIf { a is ComposeAttachment.Uploading }, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + if (a is ComposeAttachment.Uploading) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + GcIconButton(GcIcons.Close, contentDescription = stringResource(R.string.mail_remove_attachment, a.name), onClick = onRemove, size = 40.dp, iconSize = 18.dp) + } + } +} + +/** "Senden planen": tomorrow morning, Monday morning, or a day and time of one's own. */ +@Composable +private fun PlanDialog(onPick: (Long) -> Unit, onDismiss: () -> Unit) { + val context = mailContext() + val zone = ZoneId.systemDefault() + val now = System.currentTimeMillis() + val choices = remember { MailLogic.planChoices(now, zone) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.mail_plan)) }, + text = { + Column { + choices.forEach { at -> + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { onPick(at) }, verticalAlignment = Alignment.CenterVertically) { + Icon(GcIcons.Clock, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(mailDateTime(at), modifier = Modifier.padding(start = 12.dp)) + } + } + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { + val today = LocalDate.now(zone) + DatePickerDialog(context, { _, y, mo, d -> + TimePickerDialog(context, { _, h, mi -> + val at = LocalDate.of(y, mo + 1, d).atTime(LocalTime.of(h, mi)).atZone(zone).toInstant().toEpochMilli() + if (at > System.currentTimeMillis() + 60_000) onPick(at) else toast(context, context.getString(R.string.mail_plan_past)) + }, 8, 0, DateFormat.is24HourFormat(context)).show() + }, today.year, today.monthValue - 1, today.dayOfMonth).apply { datePicker.minDate = now }.show() + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(GcIcons.Calendar, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.mail_plan_pick), modifier = Modifier.padding(start = 12.dp)) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt new file mode 100644 index 00000000..e9f5426d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailComposeViewModel.kt @@ -0,0 +1,510 @@ +package com.gatecontrol.android.ui.mail + +import android.content.Context +import android.net.Uri +import android.os.SystemClock +import android.provider.OpenableColumns +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAddress +import com.gatecontrol.android.mail.MAtt +import com.gatecontrol.android.mail.MContact +import com.gatecontrol.android.mail.MDraft +import com.gatecontrol.android.mail.MDraftAttachment +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MWarning +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.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 +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject + +/** An attachment of the mail being written, as the screen shows it. */ +sealed class ComposeAttachment { + abstract val key: String + abstract val name: String + abstract val size: Long + + /** On the draft already (the server knows it). */ + data class Saved(val a: MDraftAttachment) : ComposeAttachment() { + override val key get() = "s${a.id}" + override val name get() = a.name + override val size get() = a.size + } + + /** Added here, goes with the next save. */ + data class Pending(override val key: String, val att: MAtt, override val name: String, override val size: Long, val mime: String) : ComposeAttachment() + + /** A device file on its way to the server. */ + data class Uploading(override val key: String, override val name: String, override val size: Long) : ComposeAttachment() +} + +/** The recipient fields. */ +enum class RecipientField { TO, CC, BCC } + +data class ComposeState( + val loading: Boolean = true, + val accountId: Long? = null, + val to: List = emptyList(), + val cc: List = emptyList(), + val bcc: List = emptyList(), + val inputs: Map = emptyMap(), + val showCc: Boolean = false, + val subject: String = "", + val text: String = "", + /** The quote below (answer / forward): the server's HTML, sent unchanged. */ + val quote: String = "", + val attachments: List = emptyList(), + val warnings: List = emptyList(), + val draftId: Long? = null, + val saving: Boolean = false, + val sending: Boolean = false, + val suggestions: List = emptyList(), + val suggestFor: RecipientField? = null, + val error: String? = null, +) { + fun recipients(f: RecipientField): List = when (f) { + RecipientField.TO -> to + RecipientField.CC -> cc + RecipientField.BCC -> bcc + } + + val empty: Boolean + get() = to.isEmpty() && cc.isEmpty() && bcc.isEmpty() && subject.isBlank() && attachments.isEmpty() && + inputs.values.all { it.isBlank() } && text.isBlank() +} + +sealed class ComposeEvent { + data object Sent : ComposeEvent() + data object Saved : ComposeEvent() + data object Discarded : ComposeEvent() + data class Toast(val text: String) : ComposeEvent() +} + +/** + * Writing (mockup "Schreiben"): a new mail, an answer or a forward (the server fills recipients, subject and quote + * in), or a draft again. Saved as a draft on the server while writing; sent with the undo window, or planned. + */ +@HiltViewModel +class MailComposeViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + val me: StateFlow = repository.me + + private val argDraft = saved.get(Screen.MailCompose.ARG_DRAFT)?.toLongOrNull() + private val argRef = saved.get(Screen.MailCompose.ARG_REF)?.toLongOrNull() + private val argMode = saved.get(Screen.MailCompose.ARG_MODE)?.takeIf { it in MailJson.MODES } + + /** reply | replyAll | forward | null (a new mail or a draft). */ + val mode: String? get() = argMode + private val argAccount = saved.get(Screen.MailCompose.ARG_ACCOUNT)?.toLongOrNull() + private val argTo = saved.get(Screen.MailCompose.ARG_TO) + private val argReply = saved.get(Screen.MailCompose.ARG_REPLY) + + private val _state = MutableStateFlow(ComposeState()) + val state: StateFlow = _state.asStateFlow() + + private val _event = MutableStateFlow(null) + val event: StateFlow = _event.asStateFlow() + + private val saveMutex = Mutex() + private var autosave: Job? = null + private var suggestJob: Job? = null + /** Something changed since the last save. */ + @Volatile private var dirty = false + /** The person changed something here (else closing leaves nothing behind). */ + @Volatile private var touched = argReply != null + + init { + viewModelScope.launch { start() } + } + + private suspend fun start() { + val me = repository.me.value ?: repository.refresh().valueOrNull() + when { + argDraft != null -> when (val r = api.draft(argDraft)) { + is MResult.Ok -> fromDraft(r.value) + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + argRef != null && argMode != null -> { + // the server fills recipients, subject and quote in (always from the account the mail came to) + when (val r = api.createDraft(MailJson.draftBody(null, null, null, null, null, null, replyTo = argRef, mode = argMode))) { + is MResult.Ok -> { + fromDraft(r.value) + val account = me?.account(r.value.accountId) + _state.update { s -> s.copy(text = MailLogic.withSignature(MailLogic.withReply(argReply.orEmpty(), s.text), account?.signature).trimStart('\n')) } + if (argMode == "forward" && r.value.attachments.isEmpty()) forwardAttachments(argRef) + } + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + else -> { + val accountId = argAccount?.takeIf { a -> me?.account(a) != null } ?: me?.accounts?.firstOrNull()?.id + val to = argTo?.let { MailLogic.parseRecipients(it).addresses }.orEmpty() + _state.update { + it.copy( + loading = false, + accountId = accountId, + to = to, + text = MailLogic.withSignature("", me?.account(accountId)?.signature), + ) + } + } + } + } + + private fun fromDraft(d: MDraft) { + val (text, quote) = MailLogic.splitDraft(d.html) + _state.update { + it.copy( + loading = false, + draftId = d.id, + accountId = d.accountId.takeIf { a -> a > 0 } ?: it.accountId, + to = d.to, + cc = d.cc, + bcc = d.bcc, + showCc = d.cc.isNotEmpty() || d.bcc.isNotEmpty(), + subject = d.subject, + text = text, + quote = quote, + attachments = d.attachments.map { a -> ComposeAttachment.Saved(a) }, + warnings = d.warnings, + ) + } + } + + /** Forwarding: the mail's own attachments go along (contract: `{ messageId, attachmentId }`). */ + private suspend fun forwardAttachments(ref: Long) { + val m = api.message(ref).valueOrNull()?.first ?: repository.cache.message(ref) ?: return + val atts = m.attachments.filter { !it.inline } + if (atts.isEmpty()) return + _state.update { s -> + s.copy(attachments = s.attachments + atts.map { a -> ComposeAttachment.Pending("f${a.id}", MAtt.Forwarded(ref, a.id), a.name, a.size, a.mime) }) + } + save() + } + + // ─── editing ──────────────────────────────────────────────────── + + private fun changed() { + dirty = true + touched = true + autosave?.cancel() + autosave = viewModelScope.launch { + delay(AUTOSAVE_MS) + if (!_state.value.empty) save() + } + } + + fun setAccount(id: Long) { + _state.update { s -> + val old = me.value?.account(s.accountId)?.signature + val new = me.value?.account(id)?.signature + // the signature follows the account while it is still the untouched one + val text = if (old != null && s.text.contains(old)) s.text.replace(old, new.orEmpty()) else s.text + s.copy(accountId = id, text = text) + } + changed() + } + + fun setSubject(v: String) { + _state.update { it.copy(subject = v) } + changed() + } + + fun setText(v: String) { + _state.update { it.copy(text = v) } + changed() + } + + fun showCc() = _state.update { it.copy(showCc = true) } + + /** Typing in a recipient field: complete addresses become chips, the rest asks for suggestions. */ + fun setInput(f: RecipientField, v: String) { + val ends = v.endsWith(",") || v.endsWith(";") || v.endsWith("\n") + if (ends) { + commit(f, v) + return + } + _state.update { it.copy(inputs = it.inputs + (f to v)) } + suggestJob?.cancel() + val q = v.trim() + if (q.length < 2) { + _state.update { it.copy(suggestions = emptyList(), suggestFor = null) } + return + } + suggestJob = viewModelScope.launch { + delay(250) + val found = api.contacts(q).valueOrNull().orEmpty() + val taken = _state.value.recipients(f).map { it.email.lowercase() }.toSet() + _state.update { it.copy(suggestions = found.filter { c -> c.email.lowercase() !in taken }, suggestFor = f) } + } + } + + /** What was typed into [f] becomes recipients (when it is addresses). */ + fun commit(f: RecipientField, raw: String? = null) { + val input = raw ?: _state.value.inputs[f].orEmpty() + val parsed = MailLogic.parseRecipients(input) + _state.update { s -> + val list = (s.recipients(f) + parsed.addresses).distinctBy { it.email.lowercase() } + val withList = when (f) { + RecipientField.TO -> s.copy(to = list) + RecipientField.CC -> s.copy(cc = list) + RecipientField.BCC -> s.copy(bcc = list) + } + withList.copy(inputs = s.inputs + (f to parsed.rest), suggestions = if (parsed.addresses.isNotEmpty()) emptyList() else s.suggestions) + } + if (parsed.addresses.isNotEmpty()) changed() + } + + fun pick(f: RecipientField, c: MContact) { + _state.update { s -> + val a = MAddress(c.name, c.email) + val list = (s.recipients(f) + a).distinctBy { it.email.lowercase() } + val next = when (f) { + RecipientField.TO -> s.copy(to = list) + RecipientField.CC -> s.copy(cc = list) + RecipientField.BCC -> s.copy(bcc = list) + } + next.copy(inputs = s.inputs + (f to ""), suggestions = emptyList(), suggestFor = null) + } + changed() + } + + fun remove(f: RecipientField, a: MAddress) { + _state.update { s -> + when (f) { + RecipientField.TO -> s.copy(to = s.to - a) + RecipientField.CC -> s.copy(cc = s.cc - a) + RecipientField.BCC -> s.copy(bcc = s.bcc - a) + } + } + changed() + } + + fun removeAttachment(a: ComposeAttachment) { + _state.update { s -> s.copy(attachments = s.attachments.filter { it.key != a.key }) } + changed() + } + + // ─── attachments ──────────────────────────────────────────────── + + /** Files of the device: copied, uploaded to the plugin, then on the draft. Too large ones are refused here. */ + fun addFiles(uris: List) { + uris.forEach { uri -> + val key = "u" + UUID.randomUUID().toString() + viewModelScope.launch { + val meta = withContext(Dispatchers.IO) { meta(uri) } + if (meta.second > MailLogic.ATTACH_MAX_BYTES) { + _event.value = ComposeEvent.Toast(context.getString(R.string.mail_attach_too_large, meta.first)) + return@launch + } + touched = true + _state.update { it.copy(attachments = it.attachments + ComposeAttachment.Uploading(key, meta.first, meta.second)) } + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + val file = withContext(Dispatchers.IO) { copy(uri) } + val r = if (file == null) MResult.Failed("failed", 0) else api.upload(file, mime) + file?.delete() + when (r) { + is MResult.Ok -> _state.update { s -> + s.copy(attachments = s.attachments.map { if (it.key == key) ComposeAttachment.Pending(key, MAtt.Upload(r.value, meta.first, mime), meta.first, meta.second, mime) else it }) + } + else -> { + _state.update { s -> s.copy(attachments = s.attachments.filter { it.key != key }) } + _event.value = ComposeEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + } + if (r is MResult.Ok) save() + } + } + } + + /** Drive files picked in "Aus Drive" (a copy or a link each). */ + fun addDrive(picks: List) { + touched = true + _state.update { s -> + s.copy( + attachments = s.attachments + picks.map { p -> + ComposeAttachment.Pending("d${p.nodeId}-${UUID.randomUUID()}", MAtt.Drive(p.nodeId, p.asLink, if (p.asLink) MailDrivePick.LINK_DAYS else null), p.name, p.size, p.mime) + }, + ) + } + viewModelScope.launch { save() } + } + + private fun meta(uri: Uri): Pair { + var name: String? = null + var size = -1L + runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + name = c.getString(0) + if (!c.isNull(1)) size = c.getLong(1) + } + } + } + return MailLogic.fileName(name ?: uri.lastPathSegment) to size + } + + private fun copy(uri: Uri): File? = runCatching { + val dir = File(context.cacheDir, MailCache.UPLOAD_DIR).apply { mkdirs() } + val f = File(dir, UUID.randomUUID().toString()) + context.contentResolver.openInputStream(uri)?.use { input -> f.outputStream().use { input.copyTo(it) } } ?: return null + if (f.length() > MailLogic.ATTACH_MAX_BYTES) { + f.delete() + return null + } + f + }.getOrNull() + + // ─── saving and sending ───────────────────────────────────────── + + private fun body(s: ComposeState): com.google.gson.JsonObject = MailJson.draftBody( + accountId = s.accountId, + to = s.to, + cc = s.cc, + bcc = s.bcc, + subject = s.subject, + html = MailLogic.composeHtml(s.text, s.quote), + replyTo = argRef, + mode = argMode, + attachments = s.attachments.mapNotNull { a -> + when (a) { + is ComposeAttachment.Saved -> MAtt.Kept(a.a.id) + is ComposeAttachment.Pending -> a.att + is ComposeAttachment.Uploading -> null + } + }, + ) + + /** The draft on the server now (made the first time). */ + suspend fun save(): MResult = saveMutex.withLock { + autosave?.cancel() + // what is still typed in a recipient field counts + RecipientField.entries.forEach { f -> if (MailLogic.parseRecipients(_state.value.inputs[f].orEmpty()).addresses.isNotEmpty()) commit(f) } + val s = _state.value + _state.update { it.copy(saving = true) } + dirty = false + val r = s.draftId?.let { api.updateDraft(it, body(s)) } ?: api.createDraft(body(s)) + when (r) { + is MResult.Ok -> _state.update { now -> + // attachments added meanwhile stay pending + val sentKeys = s.attachments.map { it.key }.toSet() + val added = now.attachments.filter { it.key !in sentKeys } + now.copy( + saving = false, + draftId = r.value.id, + attachments = r.value.attachments.map { ComposeAttachment.Saved(it) } + added, + warnings = r.value.warnings, + error = null, + ) + } + else -> { + dirty = true + _state.update { it.copy(saving = false, error = MailHomeViewModel.code(r)) } + } + } + r + } + + /** Sends now (with the undo window) or at [sendAt]. */ + fun send(sendAt: Long? = null) { + if (_state.value.sending) return + RecipientField.entries.forEach { commit(it) } + val s = _state.value + if (s.to.isEmpty() && s.cc.isEmpty() && s.bcc.isEmpty()) { + _event.value = ComposeEvent.Toast(context.getString(R.string.mail_no_recipient)) + return + } + if (s.attachments.any { it is ComposeAttachment.Uploading }) { + _event.value = ComposeEvent.Toast(context.getString(R.string.mail_wait_upload)) + return + } + _state.update { it.copy(sending = true) } + viewModelScope.launch { + val saved = save() + if (saved !is MResult.Ok) { + _state.update { it.copy(sending = false) } + _event.value = ComposeEvent.Toast(mailText(context, MailHomeViewModel.code(saved))) + return@launch + } + val started = SystemClock.elapsedRealtime() + when (val r = api.send(saved.value.id, sendAt)) { + is MResult.Ok -> { + repository.startUndo(r.value, saved.value.id, SystemClock.elapsedRealtime() - started) + _event.value = ComposeEvent.Sent + } + else -> { + _state.update { it.copy(sending = false) } + _event.value = ComposeEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + } + } + } + + /** + * Closing: what was written stays as a draft. Nothing written here leaves nothing behind: a draft the server made + * for an answer goes again, a draft that was opened stays as it was. + */ + fun close() { + viewModelScope.launch { + val s = _state.value + if (!touched) { + autosave?.cancel() + if (argDraft == null) s.draftId?.let { api.deleteDraft(it) } + _event.value = ComposeEvent.Discarded + return@launch + } + if (dirty || s.draftId == null) save() + _event.value = ComposeEvent.Saved + } + } + + /** "Verwerfen": the draft goes. */ + fun discard() { + autosave?.cancel() + viewModelScope.launch { + _state.value.draftId?.let { api.deleteDraft(it) } + _event.value = ComposeEvent.Discarded + } + } + + fun consumed() { + _event.value = null + } + + private companion object { + const val AUTOSAVE_MS = 3_000L + } +} + +/** A Drive file picked in "Aus Drive": as a copy or as a link. */ +data class MailDrivePick(val nodeId: Long, val name: String, val size: Long, val mime: String, val asLink: Boolean) { + companion object { + /** A link is valid this long (Drive's default). */ + const val LINK_DAYS = 14 + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailDrivePicker.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailDrivePicker.kt new file mode 100644 index 00000000..62ee463d --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailDrivePicker.kt @@ -0,0 +1,185 @@ +package com.gatecontrol.android.ui.mail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.R +import com.gatecontrol.android.drive.DNode +import com.gatecontrol.android.drive.DResult +import com.gatecontrol.android.drive.DriveApi +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSegmented +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.theme.GateControlTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The search of "Aus Drive" (Drive's own API, as in the Drive tab). */ +@HiltViewModel +class MailDrivePickerViewModel @Inject constructor(private val drive: DriveApi) : ViewModel() { + + data class State(val query: String = "", val busy: Boolean = false, val hits: List = emptyList(), val error: String? = null) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + fun search(q: String) { + _state.value = _state.value.copy(query = q) + job?.cancel() + if (q.trim().length < 2) { + _state.value = _state.value.copy(hits = emptyList(), busy = false, error = null) + return + } + job = viewModelScope.launch { + delay(300) + _state.value = _state.value.copy(busy = true) + _state.value = when (val r = drive.search(q.trim())) { + // files only, never a vault's (they are end-to-end encrypted) + is DResult.Ok -> _state.value.copy(busy = false, hits = r.value.filter { !it.isDir && it.vaultId == null }.take(50), error = null) + is DResult.Failed -> _state.value.copy(busy = false, error = r.code) + DResult.Offline -> _state.value.copy(busy = false, error = "offline") + DResult.NotConfigured -> _state.value.copy(busy = false, error = "not_configured") + } + } + } +} + +/** + * "Anhang hinzufügen" (mockup "Schreiben: Anhang aus Drive"): "Vom Gerät" or "Aus Drive" — Drive's search, each hit + * as a copy or a link (large files as a link first), what a link means, and "N Dateien hinzufügen". + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MailAttachSheet( + drive: Boolean, + onDevice: () -> Unit, + onDrive: (List) -> Unit, + onDismiss: () -> Unit, + viewModel: MailDrivePickerViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val picked = remember { mutableStateMapOf() } + val extra = GateControlTheme.extraColors + val context = mailContext() + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(bottom = 20.dp)) { + Row(Modifier.padding(horizontal = 16.dp).padding(bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + com.gatecontrol.android.ui.components.GcOutlineButton(stringResource(R.string.mail_attach_device), onClick = onDevice, modifier = Modifier.weight(1f), minHeight = 44.dp) + if (drive) { + Box( + Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(12.dp)).background(extra.accentBg), + contentAlignment = Alignment.Center, + ) { Text(stringResource(R.string.mail_attach_drive), color = extra.accentText, fontWeight = FontWeight.Bold) } + } + } + if (!drive) return@Column + GcTextField( + value = state.query, + onValueChange = viewModel::search, + label = stringResource(R.string.mail_attach_drive_search), + modifier = Modifier.padding(horizontal = 16.dp), + trailingIcon = { if (state.busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) }, + ) + if (state.hits.isNotEmpty()) { + Text(stringResource(R.string.mail_attach_hits), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) + } + state.error?.let { Text(mailText(context, it), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(16.dp)) } + LazyColumn(Modifier.heightIn(max = 360.dp)) { + items(state.hits, key = { it.id }) { n -> + val big = MailLogic.linkFirst(n.size) + val pick = picked[n.id] + Row( + Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FileBadge(n.name, n.mime ?: "application/octet-stream", size = 36) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(n.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull(n.path?.replace("/", " › "), mailSize(n.size), stringResource(R.string.mail_attach_too_big).takeIf { big }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 2, + ) + } + Box(Modifier.width(132.dp)) { + GcSegmented( + options = listOf("none" to "–", "copy" to stringResource(R.string.mail_attach_copy), "link" to stringResource(R.string.mail_attach_link)), + selected = when { + pick == null -> "none" + pick.asLink -> "link" + else -> "copy" + }, + onSelect = { v -> + when (v) { + "none" -> picked.remove(n.id) + else -> picked[n.id] = MailDrivePick(n.id, n.name, n.size, n.mime ?: "application/octet-stream", asLink = v == "link" || (big && v != "copy")) + } + }, + ) + } + } + } + } + picked.values.filter { it.asLink }.forEach { p -> + Column( + Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.background).padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(stringResource(R.string.mail_attach_link_for, p.name), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold) + Text(pluralStringResource(R.plurals.mail_attach_link_terms, MailDrivePick.LINK_DAYS, MailDrivePick.LINK_DAYS), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + } + if (picked.isNotEmpty()) { + GcPrimaryButton( + pluralStringResource(R.plurals.mail_attach_add_n, picked.size, picked.size), + onClick = { onDrive(picked.values.toList()) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + icon = GcIcons.Plus, + ) + } + } + } +} 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 new file mode 100644 index 00000000..34529858 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeScreen.kt @@ -0,0 +1,708 @@ +package com.gatecontrol.android.ui.mail + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.gatecontrol.android.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MFolder +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MUndo +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** Where the Mail tab leads. */ +data class MailNav( + val open: (Long) -> Unit, + /** "Neue Mail" (from this account, null: the box's or the first). */ + val compose: (Long?) -> Unit, + /** A draft to write on (undo after sending, the outbox). */ + val draft: (Long) -> Unit, + val search: () -> Unit, + val filing: () -> Unit, + val addAccount: () -> Unit, + val settings: () -> Unit, + val account: (Long) -> Unit, + val outbox: () -> Unit, +) + +/** + * The Mail tab (mockups "Posteingang" and "Seitenmenü"): the menu top left with "Alle Posteingänge", "Zum Ablegen" + * and the accounts with their folders, the chips to switch between all inboxes and one account, Relevant / + * Sonstige, the mails (swipes as the person set them), "Neue Mail" and the bar to undo a send. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MailHomeScreen(nav: MailNav, viewModel: MailHomeViewModel = hiltViewModel()) { + val me by viewModel.me.collectAsStateWithLifecycle() + val meError by viewModel.meError.collectAsStateWithLifecycle() + val folders by viewModel.folders.collectAsStateWithLifecycle() + val box by viewModel.box.collectAsStateWithLifecycle() + val tab by viewModel.tab.collectAsStateWithLifecycle() + val list by viewModel.list.collectAsStateWithLifecycle() + val filing by viewModel.filing.collectAsStateWithLifecycle() + val outbox by viewModel.outbox.collectAsStateWithLifecycle() + val undo by viewModel.undo.collectAsStateWithLifecycle() + val message by viewModel.message.collectAsStateWithLifecycle() + val reopen by viewModel.reopen.collectAsStateWithLifecycle() + val context = mailContext() + val scope = rememberCoroutineScope() + val drawer = rememberDrawerState(DrawerValue.Closed) + val extra = GateControlTheme.extraColors + var actionsFor by remember { mutableStateOf(null) } + var moveFor by remember { mutableStateOf(null) } + + LifecycleResumeEffect(viewModel) { + viewModel.onShown() + onPauseOrDispose { } + } + LaunchedEffect(message) { + val m = message ?: return@LaunchedEffect + toast( + context, + when (m) { + MailHomeViewModel.UNDONE -> context.getString(R.string.mail_undone) + MailHomeViewModel.TOO_LATE -> context.getString(R.string.mail_undo_too_late) + else -> mailText(context, m) + }, + ) + viewModel.clearMessage() + } + LaunchedEffect(reopen) { + reopen?.let { + viewModel.reopened() + nav.draft(it) + } + } + BackHandler(enabled = drawer.isOpen) { scope.launch { drawer.close() } } + // a folder of an account: back goes to the account's inbox first + BackHandler(enabled = !drawer.isOpen && box.folderId != null) { viewModel.selectBox(MailLogic.Box(box.accountId)) } + + val accounts = me?.accounts.orEmpty() + val account = me?.account(box.accountId) + val inbox = box.isInbox(folders[box.accountId].orEmpty()) + val folder = folders[box.accountId]?.firstOrNull { it.id == box.folderId } + + ModalNavigationDrawer( + drawerState = drawer, + gesturesEnabled = drawer.isOpen, + drawerContent = { + ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surface) { + val close: () -> Unit = { scope.launch { drawer.close() } } + MailDrawer( + me = me, + folders = folders, + box = box, + filing = filing, + outbox = outbox, + onBox = { close(); viewModel.selectBox(it) }, + onFiling = { close(); nav.filing() }, + onOutbox = { close(); nav.outbox() }, + onAdd = { close(); nav.addAccount() }, + onSettings = { close(); nav.settings() }, + ) + } + }, + ) { + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + // the bar: menu, title with the unread count, search, "⋯" when pinned to Start + val unread = when { + folder != null -> folder.unread + box.accountId != null -> me?.unreadByAccount?.get(box.accountId) ?: 0 + else -> me?.unreadAll ?: 0 + } + val title = when { + folder != null && !inbox -> folder.name + box.all -> stringResource(R.string.mail_all_inboxes) + else -> stringResource(R.string.mail_inbox) + } + val where = if (box.all) pluralStringResource(R.plurals.mail_accounts_n, accounts.size, accounts.size) else account?.label.orEmpty() + Row( + Modifier.fillMaxWidth().padding(start = 4.dp, end = 8.dp, top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + GcIconButton( + GcIcons.Menu, + contentDescription = stringResource(R.string.mail_menu), + onClick = { scope.launch { drawer.open() } }, + tint = MaterialTheme.colorScheme.onBackground, + ) + Column(Modifier.weight(1f).padding(start = 4.dp)) { + Text(title, style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (me != null) { + Text( + listOf(where, pluralStringResource(R.plurals.mail_unread_n, unread, unread)).filter { it.isNotEmpty() }.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + GcIconButton(GcIcons.Search, contentDescription = stringResource(R.string.mail_search), onClick = nav.search, tint = MaterialTheme.colorScheme.onBackground) + com.gatecontrol.android.ui.start.StartPageMenuButton() + } + if (accounts.size > 1) { + BoxChips(accounts = accounts, box = box, onBox = viewModel::selectBox) + } + if (inbox) { + TabSwitch( + tab = tab, + other = list.counts?.other, + onTab = viewModel::selectTab, + ) + } + MailBanners(me = me, meError = meError, offline = list.offline, onAccount = nav.account) + when { + me != null && accounts.isEmpty() -> NoAccounts(onAdd = nav.addAccount) + me == null && meError != null && list.items.isEmpty() -> ErrorBox(mailText(context, meError), onRetry = { viewModel.load(refresh = true) }) + else -> { + val listState = rememberLazyListState() + LaunchedEffect(listState, list.next) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }.collect { last -> + if (list.next != null && last >= list.items.size - 8) viewModel.loadMore() + } + } + PullToRefreshBox( + isRefreshing = list.refreshing, + onRefresh = { viewModel.load(refresh = true) }, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) { + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = 96.dp)) { + if (list.items.isEmpty()) { + item(key = "empty") { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + if (list.loading) { + CircularProgressIndicator(Modifier.size(28.dp)) + } else { + Text( + list.error?.let { mailText(context, it) } ?: stringResource(if (inbox && tab == MSummary.TAB_OTHER) R.string.mail_empty_other else R.string.mail_empty), + color = extra.muted, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + items(list.items, key = { it.id }) { item -> + val settings = me?.settings + SwipeRow( + left = swipeSpec(settings?.swipeLeft, item.unread), + right = swipeSpec(settings?.swipeRight, item.unread), + onSwipe = { viewModel.act(item, it.action) }, + ) { + Column { + MailRow( + item = item, + account = me?.account(item.accountId), + onClick = { nav.open(item.id) }, + onLongClick = { actionsFor = item }, + ) + MailDivider() + } + } + } + if (list.loadingMore) { + item(key = "more") { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + } + } + } + } + } + } + if (accounts.isNotEmpty()) { + NewMailButton( + onClick = { nav.compose(box.accountId) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = if (undo != null) 88.dp else 16.dp), + ) + } + undo?.let { u -> + UndoBar(u, onUndo = { viewModel.undoSend(u) }, onExpired = { viewModel.undoExpired(u) }, modifier = Modifier.align(Alignment.BottomCenter)) + } + } + } + + actionsFor?.let { item -> + RowActions( + item = item, + inbox = inbox, + hasFolders = folders[item.accountId].orEmpty().isNotEmpty(), + onAction = { action -> + actionsFor = null + if (action == "move") moveFor = item else viewModel.act(item, action) + }, + onDismiss = { actionsFor = null }, + ) + } + moveFor?.let { item -> + FolderPicker( + folders = folders[item.accountId].orEmpty().filter { it.id != item.folderId }, + onPick = { f -> + moveFor = null + viewModel.act(item, "move", f.id) + }, + onDismiss = { moveFor = null }, + ) + } +} + +/** "Alle" and one chip per account (mockup: colored dot, the active one in the accent). */ +@Composable +private fun BoxChips(accounts: List, box: MailLogic.Box, onBox: (MailLogic.Box) -> Unit) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + BoxChip(stringResource(R.string.mail_box_all), MaterialTheme.colorScheme.onBackground, box.all) { onBox(MailLogic.Box()) } + accounts.forEach { a -> + BoxChip(a.label, accountColor(a), box.accountId == a.id && box.folderId == null) { onBox(MailLogic.Box(a.id)) } + } + } +} + +@Composable +private fun BoxChip(label: String, dot: Color, on: Boolean, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Row( + Modifier + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(999.dp)) + .then(if (on) Modifier.background(extra.accentBg) else Modifier.border(1.dp, extra.border2, RoundedCornerShape(999.dp))) + .selectable(selected = on, role = Role.Tab, onClick = onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + MailDot(dot) + Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (on) extra.accentText else MaterialTheme.colorScheme.onBackground, maxLines = 1) + } +} + +/** Relevant | Sonstige · n (mockup: tabs with the accent line). */ +@Composable +private fun TabSwitch(tab: String, other: Int?, onTab: (String) -> Unit) { + val extra = GateControlTheme.extraColors + Column { + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + listOf( + MSummary.TAB_RELEVANT to stringResource(R.string.mail_tab_relevant), + MSummary.TAB_OTHER to (stringResource(R.string.mail_tab_other) + (other?.let { " · $it" } ?: "")), + ).forEach { (id, label) -> + val on = tab == id + Column( + Modifier.weight(1f).heightIn(min = 44.dp).selectable(selected = on, role = Role.Tab) { onTab(id) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom, + ) { + Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (on) extra.accentText else extra.muted, modifier = Modifier.padding(vertical = 12.dp)) + Box(Modifier.fillMaxWidth().padding(horizontal = 8.dp).heightIn(min = 2.dp, max = 2.dp).background(if (on) MaterialTheme.colorScheme.primary else Color.Transparent)) + } + } + } + MailDivider() + } +} + +/** Accounts that need the person (sign-in refused, server unreachable), and "offline". */ +@Composable +private fun MailBanners(me: MMe?, meError: String?, offline: Boolean, onAccount: (Long) -> Unit) { + val failing = me?.accounts.orEmpty().filter { it.state == MAccount.STATE_AUTH_FAILED || it.state == MAccount.STATE_UNREACHABLE } + if (!offline && failing.isEmpty() && !(me != null && meError == "offline")) return + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (offline || (me != null && meError == "offline")) { + GcBanner(tone = GcTone.Neutral, icon = GcIcons.Wifi) { + Text(stringResource(R.string.mail_offline_copy), style = MaterialTheme.typography.bodySmall) + } + } + failing.take(3).forEach { a -> + GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert, modifier = Modifier.clickable { onAccount(a.id) }) { + Text( + stringResource(if (a.state == MAccount.STATE_AUTH_FAILED) R.string.mail_account_auth_failed else R.string.mail_account_unreachable, a.label), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + GcIcons.ChevronRight.let { Icon(it, contentDescription = null, modifier = Modifier.size(16.dp)) } + } + } + } +} + +@Composable +private fun NoAccounts(onAdd: () -> Unit) { + Column( + Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(GcIcons.Mail, contentDescription = null, tint = GateControlTheme.extraColors.muted, modifier = Modifier.size(40.dp)) + Text(stringResource(R.string.mail_no_accounts_title), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.mail_no_accounts_text), style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted) + GcPrimaryButton(stringResource(R.string.mail_add_account), onClick = onAdd, icon = GcIcons.Plus) + } +} + +@Composable +internal fun ErrorBox(text: String, onRetry: (() -> Unit)?) { + Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(text, style = MaterialTheme.typography.bodyMedium, color = GateControlTheme.extraColors.muted) + if (onRetry != null) TextButton(onClick = onRetry) { Text(stringResource(R.string.mail_retry)) } + } +} + +/** "Neue Mail" (mockup: accent pill with the pen). */ +@Composable +private fun NewMailButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + Row( + modifier + .heightIn(min = 56.dp) + .clip(RoundedCornerShape(18.dp)) + .background(scheme.primary) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(MailIcons.Pen, contentDescription = null, tint = scheme.onPrimary, modifier = Modifier.size(20.dp)) + Text(stringResource(R.string.mail_new), color = scheme.onPrimary, fontWeight = FontWeight.Bold) + } +} + +/** "Wird gesendet · Rückgängig" for the undo window (a planned send: "Geplant für …"). */ +@Composable +private fun UndoBar(u: MUndo, onUndo: () -> Unit, onExpired: () -> Unit, modifier: Modifier = Modifier) { + var left by remember(u.outboxId) { mutableLongStateOf((u.untilElapsed - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(0)) } + val total = remember(u.outboxId) { left.coerceAtLeast(1) } + LaunchedEffect(u.outboxId) { + while (true) { + left = (u.untilElapsed - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(0) + if (left <= 0) break + delay(200) + } + onExpired() + } + val extra = GateControlTheme.extraColors + Column( + modifier + .padding(12.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, extra.border, RoundedCornerShape(16.dp)), + ) { + Row(Modifier.padding(start = 16.dp, end = 8.dp, top = 6.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(MailIcons.Send, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Text( + if (u.planned && u.sendAt != null) stringResource(R.string.mail_planned_for, mailDateTime(u.sendAt)) else stringResource(R.string.mail_sending, ((left + 999) / 1000).toInt()), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onUndo) { Text(stringResource(R.string.mail_undo), fontWeight = FontWeight.Bold) } + } + LinearProgressIndicator(progress = { left.toFloat() / total }, modifier = Modifier.fillMaxWidth()) + } +} + +/** The menu of a mail (long press): read, flag, Relevant / Sonstige, archive, delete, spam, move. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RowActions(item: MSummary, inbox: Boolean, hasFolders: Boolean, onAction: (String) -> Unit, onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(bottom = 24.dp)) { + Text( + item.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp).semantics { heading() }, + ) + SheetRow(MailIcons.Unread, stringResource(if (item.unread) R.string.mail_mark_read else R.string.mail_mark_unread)) { onAction(MailLogic.SWIPE_READ) } + SheetRow(GcIcons.Flag, stringResource(if (item.flagged) R.string.mail_unflag else R.string.mail_flag)) { onAction("flag") } + if (inbox) { + SheetRow(MailIcons.Inbox, stringResource(if (item.tab == MSummary.TAB_OTHER) R.string.mail_to_relevant else R.string.mail_to_other)) { onAction("tab") } + } + SheetRow(GcIcons.Archive, stringResource(R.string.mail_archive)) { onAction(MailLogic.SWIPE_ARCHIVE) } + if (hasFolders) SheetRow(GcIcons.FolderMove, stringResource(R.string.mail_move)) { onAction("move") } + SheetRow(GcIcons.ShieldX, stringResource(R.string.mail_junk)) { onAction("junk") } + SheetRow(GcIcons.Trash, stringResource(R.string.mail_delete), MaterialTheme.colorScheme.error) { onAction(MailLogic.SWIPE_DELETE) } + } + } +} + +@Composable +internal fun SheetRow(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, tint: Color = MaterialTheme.colorScheme.onSurface, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().heightIn(min = 52.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, color = tint) + } +} + +/** "Verschieben": the account's folders. */ +@Composable +internal fun FolderPicker(folders: List, onPick: (MFolder) -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.mail_move_title)) }, + text = { + LazyColumn(Modifier.heightIn(max = 420.dp)) { + items(MailFolders.ordered(folders), key = { it.id }) { f -> + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { onPick(f) }.padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(MailFolders.icon(f.role), contentDescription = null, modifier = Modifier.size(20.dp), tint = GateControlTheme.extraColors.muted) + Text(MailFolders.label(f), style = MaterialTheme.typography.bodyLarge) + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.mail_cancel)) } }, + ) +} + +/** The folders of an account in the menu's order: inbox, sent, drafts, archive, the own ones, junk, trash. */ +internal object MailFolders { + private val ORDER = listOf(MFolder.ROLE_INBOX, MFolder.ROLE_SENT, MFolder.ROLE_DRAFTS, MFolder.ROLE_ARCHIVE, null, MFolder.ROLE_JUNK, MFolder.ROLE_TRASH) + + fun ordered(folders: List): List = folders.sortedWith(compareBy({ ORDER.indexOf(it.role) }, { it.path.lowercase() })) + + fun icon(role: String?) = when (role) { + MFolder.ROLE_INBOX -> MailIcons.Inbox + MFolder.ROLE_SENT -> MailIcons.Send + MFolder.ROLE_DRAFTS -> MailIcons.Pen + MFolder.ROLE_ARCHIVE -> GcIcons.Archive + MFolder.ROLE_JUNK -> GcIcons.ShieldX + MFolder.ROLE_TRASH -> GcIcons.Trash + else -> GcIcons.Folder + } + + /** The well-known folders by their role in the app's language, the own ones by name. */ + @Composable + fun label(f: MFolder): String = when (f.role) { + MFolder.ROLE_INBOX -> stringResource(R.string.mail_inbox) + MFolder.ROLE_SENT -> stringResource(R.string.mail_folder_sent) + MFolder.ROLE_DRAFTS -> stringResource(R.string.mail_folder_drafts) + MFolder.ROLE_ARCHIVE -> stringResource(R.string.mail_folder_archive) + MFolder.ROLE_JUNK -> stringResource(R.string.mail_folder_junk) + MFolder.ROLE_TRASH -> stringResource(R.string.mail_folder_trash) + else -> f.name + } +} + +/** + * 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). + */ +@Composable +private fun MailDrawer( + me: MMe?, + folders: Map>, + box: MailLogic.Box, + filing: Int, + outbox: Int, + onBox: (MailLogic.Box) -> Unit, + onFiling: () -> Unit, + onOutbox: () -> Unit, + onAdd: () -> Unit, + onSettings: () -> Unit, +) { + val extra = GateControlTheme.extraColors + Column(Modifier.fillMaxHeight().padding(horizontal = 12.dp, vertical = 16.dp)) { + Text( + stringResource(R.string.mail_tab), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(start = 12.dp, top = 4.dp, bottom = 12.dp).semantics { heading() }, + ) + 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()) } + } + if (me?.has(MMe.FEATURE_DRIVE) == true) { + item(key = "filing") { DrawerItem(GcIcons.Folder, null, 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() } } + } + val accounts = me?.accounts.orEmpty() + if (accounts.isNotEmpty()) { + item(key = "accounts") { DrawerLabel(stringResource(R.string.mail_accounts)) } + } + 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) { + onBox(MailLogic.Box(a.id)) + } + } + if (box.accountId == a.id) { + val list = MailFolders.ordered(folders[a.id].orEmpty()) + 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 = 40.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) + } + } + } + } + } + me?.limits?.let { l -> + MailLogic.quotaPercent(l.usedBytes, l.quotaBytes)?.let { p -> + item(key = "quota") { + Column(Modifier.padding(horizontal = 12.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.mail_quota, mailSize(l.usedBytes), mailSize(l.quotaBytes)), style = MaterialTheme.typography.bodySmall, color = extra.muted) + LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth()) + } + } + } + } + } + Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DrawerIcon(GcIcons.Plus, stringResource(R.string.mail_add_account), onAdd) + DrawerIcon(GcIcons.Settings, stringResource(R.string.mail_settings), onSettings) + } + } +} + +@Composable +private fun DrawerLabel(text: String) { + Text( + text.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = GateControlTheme.extraColors.faint, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp, top = 14.dp, bottom = 6.dp).semantics { heading() }, + ) +} + +@Composable +private fun DrawerItem(icon: androidx.compose.ui.graphics.vector.ImageVector?, dot: Color?, 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( + Modifier + .fillMaxWidth() + .heightIn(min = 44.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), + verticalAlignment = Alignment.CenterVertically, + 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) + } + } +} + +@Composable +private fun DrawerIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit) { + val extra = GateControlTheme.extraColors + Box( + Modifier + .size(44.dp) + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, extra.border, RoundedCornerShape(12.dp)) + .clickable(role = Role.Button, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = extra.muted, modifier = Modifier.size(20.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 new file mode 100644 index 00000000..504fef92 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHomeViewModel.kt @@ -0,0 +1,269 @@ +package com.gatecontrol.android.ui.mail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MCounts +import com.gatecontrol.android.mail.MFolder +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MUndo +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.mail.MailRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** A list of mails as the screen shows it. */ +data class MailListState( + val items: List = emptyList(), + val next: String? = null, + val counts: MCounts? = null, + /** The first load is running (nothing to show yet). */ + val loading: Boolean = true, + val refreshing: Boolean = false, + val loadingMore: Boolean = false, + /** Shown from the device's copy: the server could not be reached. */ + val offline: Boolean = false, + val error: String? = null, +) + +/** + * The inboxes (mockup "Posteingang"): "Alle Posteingänge" or one account (a folder of it from the menu), + * Relevant / Sonstige, the swipes, the counts of the menu and the bar to undo a send. + */ +@HiltViewModel +class MailHomeViewModel @Inject constructor( + private val repository: MailRepository, + private val saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + private val cache = repository.cache + + val me: StateFlow = repository.me + val meError: StateFlow = repository.meError + val folders: StateFlow>> = repository.folders + val undo: StateFlow = repository.undo + + 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() + + private val _tab = MutableStateFlow(saved.get(KEY_TAB) ?: MSummary.TAB_RELEVANT) + val tab: StateFlow = _tab.asStateFlow() + + private val _list = MutableStateFlow(MailListState()) + val list: StateFlow = _list.asStateFlow() + + /** "Zum Ablegen" (Drive): how many wait. */ + private val _filing = MutableStateFlow(0) + val filing: StateFlow = _filing.asStateFlow() + + /** "Postausgang": planned or failed sends. */ + private val _outbox = MutableStateFlow(0) + val outbox: StateFlow = _outbox.asStateFlow() + + private val _message = MutableStateFlow(null) + /** A short result to show once (an error code, `undone`). */ + val message: StateFlow = _message.asStateFlow() + + private val _reopen = MutableStateFlow(null) + /** A draft to open again (undo after sending). */ + val reopen: StateFlow = _reopen.asStateFlow() + + private var loadJob: Job? = null + + init { + viewModelScope.launch { + repository.changed.collect { c -> + val b = _box.value + if (c.accountId == null || b.accountId == null || c.accountId == b.accountId) { + delay(300) + load(refresh = true, quiet = true) + } + } + } + load(refresh = false) + } + + fun onShown() { + repository.refreshSoon() + loadCounts() + } + + private fun loadCounts() { + viewModelScope.launch { + val me = repository.me.value ?: repository.refresh().valueOrNull() + if (me?.has(MMe.FEATURE_DRIVE) == true) api.filing().valueOrNull()?.let { _filing.value = it.size } + api.outbox().valueOrNull()?.let { _outbox.value = it.size } + } + } + + fun selectBox(box: MailLogic.Box) { + if (box == _box.value) return + _box.value = box + saved[KEY_ACCOUNT] = box.accountId ?: 0L + saved[KEY_FOLDER] = box.folderId ?: 0L + _list.value = MailListState() + load(refresh = false) + } + + fun selectTab(tab: String) { + if (tab == _tab.value) return + _tab.value = tab + saved[KEY_TAB] = tab + _list.value = MailListState() + load(refresh = false) + } + + private fun inbox(): Boolean = _box.value.isInbox(folders.value[_box.value.accountId].orEmpty()) + + private fun cacheKey(): String = _box.value.key(if (inbox()) _tab.value else MSummary.TAB_ALL) + + /** The list from the start; [refresh] = pulled down (the list stays while it loads). */ + fun load(refresh: Boolean, quiet: Boolean = false) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + val box = _box.value + val inbox = inbox() + val key = cacheKey() + if (!refresh && _list.value.items.isEmpty()) { + cache.list(key)?.let { c -> _list.value = MailListState(items = c.items, counts = c.counts, loading = true) } + } + if (refresh && !quiet) _list.update { it.copy(refreshing = true) } + val r = api.messages(MailLogic.listQuery(box, _tab.value, inbox)) + when (r) { + is MResult.Ok -> { + val (page, raw) = r.value + _list.value = MailListState(items = page.items, next = page.next, counts = page.counts ?: _list.value.counts, loading = false) + cache.putList(key, raw, first = true) + } + else -> _list.update { + it.copy( + loading = false, + refreshing = false, + offline = r is MResult.Offline || it.items.isNotEmpty(), + error = if (it.items.isEmpty() || !quiet) code(r) else it.error, + ) + } + } + if (refresh) loadCounts() + } + } + + fun loadMore() { + val s = _list.value + val next = s.next ?: return + if (s.loadingMore || s.loading) return + _list.update { it.copy(loadingMore = true) } + viewModelScope.launch { + val key = cacheKey() + val r = api.messages(MailLogic.listQuery(_box.value, _tab.value, inbox(), before = next)) + if (r is MResult.Ok) { + val (page, raw) = r.value + _list.update { it.copy(items = MailLogic.merge(it.items, page.items), next = page.next, loadingMore = false) } + cache.putList(key, raw, first = false) + } else { + _list.update { it.copy(loadingMore = false) } + } + } + } + + /** A swipe or an action of the row's menu. */ + fun act(item: MSummary, action: String, folderId: Long? = null) { + val ids = listOf(item.id) + val leaves = when (action) { + MailLogic.SWIPE_READ, "unread", "flag" -> false + "tab" -> inbox() && _tab.value != MSummary.TAB_ALL + else -> true + } + // at once on the screen, put right when the server says no + val before = _list.value.items + _list.update { s -> + if (leaves) s.copy(items = s.items.filter { it.id != item.id }) else s.copy( + items = s.items.map { + when { + it.id != item.id -> it + action == MailLogic.SWIPE_READ -> it.copy(unread = !it.unread) + action == "unread" -> it.copy(unread = true) + action == "flag" -> it.copy(flagged = !it.flagged) + else -> it + } + }, + ) + } + viewModelScope.launch { + val r = when (action) { + MailLogic.SWIPE_ARCHIVE -> api.archive(ids) + MailLogic.SWIPE_DELETE -> api.delete(ids) + MailLogic.SWIPE_READ -> api.flags(ids, unread = !item.unread) + "unread" -> api.flags(ids, unread = true) + "flag" -> api.flags(ids, flagged = !item.flagged) + "junk" -> api.junk(ids) + "tab" -> api.setTab(ids, if (item.tab == MSummary.TAB_OTHER) MSummary.TAB_RELEVANT else MSummary.TAB_OTHER) + "move" -> api.move(ids, folderId ?: return@launch) + else -> return@launch + } + when (r) { + is MResult.Ok -> { + if (!leaves && r.value.isNotEmpty()) _list.update { s -> s.copy(items = MailLogic.apply(s.items, r.value)) } + if (leaves) cache.forgetMessages(ids) + repository.refreshSoon(0) + } + else -> { + _list.update { it.copy(items = before) } + _message.value = code(r) + } + } + } + } + + /** "Rückgängig" of the bar after sending: the send stops, its draft opens again. */ + fun undoSend(u: MUndo) { + viewModelScope.launch { + when (val r = repository.undo(u)) { + is MResult.Ok -> { + _message.value = UNDONE + (r.value ?: u.draftId)?.let { _reopen.value = it } + } + else -> _message.value = (r as? MResult.Failed)?.code?.takeIf { it != "http_409" } ?: TOO_LATE + } + } + } + + fun undoExpired(u: MUndo) = repository.clearUndo(u.outboxId) + + fun clearMessage() { + _message.value = null + } + + fun reopened() { + _reopen.value = null + } + + /** Accounts in the order of `me`. */ + fun accounts(): List = me.value?.accounts.orEmpty() + + companion object { + private const val KEY_ACCOUNT = "account" + private const val KEY_FOLDER = "folder" + private const val KEY_TAB = "tab" + const val UNDONE = "undone" + const val TOO_LATE = "too_late" + + internal fun code(r: MResult<*>): String = when (r) { + is MResult.Failed -> r.code + MResult.Offline -> "offline" + MResult.NotConfigured -> "not_configured" + is MResult.Ok -> "ok" + } + } +} 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 new file mode 100644 index 00000000..bb3d3df9 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailHtmlView.kt @@ -0,0 +1,101 @@ +package com.gatecontrol.android.ui.mail + +import android.annotation.SuppressLint +import android.graphics.Color +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +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.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.gatecontrol.android.mail.MailLogic +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayInputStream + +/** + * A mail's HTML (cleaned by the server) in a WebView that can do nothing by itself (contract D): JavaScript off, no + * 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. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +internal fun MailHtmlView( + html: String, + loader: suspend (String) -> Pair?, + onLink: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val load = rememberUpdatedState(loader) + val link = rememberUpdatedState(onLink) + val page = remember(html) { MailLogic.page(html) } + Box(modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(androidx.compose.ui.graphics.Color.White)) { + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) + setBackgroundColor(Color.WHITE) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + settings.apply { + javaScriptEnabled = false + javaScriptCanOpenWindowsAutomatically = false + allowFileAccess = false + allowContentAccess = false + domStorageEnabled = false + databaseEnabled = false + setSupportMultipleWindows(false) + setGeolocationEnabled(false) + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + cacheMode = WebSettings.LOAD_NO_CACHE + loadsImagesAutomatically = true + builtInZoomControls = true + displayZoomControls = false + loadWithOverviewMode = true + useWideViewPort = true + textZoom = 100 + safeBrowsingEnabled = true + } + webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse { + val url = request.url.toString() + // the page itself (loadDataWithBaseURL) never comes here; anything else must be one of ours + 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)) + } + return WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + val url = request.url.toString() + if (request.hasGesture() && MailLogic.externalLink(url)) link.value(url) + return true + } + } + } + }, + onRelease = { web -> web.destroy() }, + update = { web -> + if (web.tag != page) { + web.tag = page + 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 new file mode 100644 index 00000000..e4511bbd --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreScreens.kt @@ -0,0 +1,434 @@ +package com.gatecontrol.android.ui.mail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow +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.MAccount +import com.gatecontrol.android.mail.MFilingItem +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MSettings +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcCard +import com.gatecontrol.android.ui.components.GcChevron +import com.gatecontrol.android.ui.components.GcFilterChip +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcListRow +import com.gatecontrol.android.ui.components.GcOutlineButton +import com.gatecontrol.android.ui.components.GcPrimaryButton +import com.gatecontrol.android.ui.components.GcSectionLabel +import com.gatecontrol.android.ui.components.GcSegmented +import com.gatecontrol.android.ui.components.GcSubpageBar +import com.gatecontrol.android.ui.components.GcSwitchRow +import com.gatecontrol.android.ui.components.GcTextField +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** "Zum Ablegen" (mockup): each attachment with Drive's suggestion and how sure it is; "Alle ablegen". */ +@Composable +fun MailFilingScreen(onBack: () -> Unit, viewModel: MailFilingViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val toastText by viewModel.toast.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + var other by remember { mutableStateOf(null) } + LaunchedEffect(toastText) { + toastText?.let { + toast(context, it) + viewModel.toastShown() + } + } + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.mail_filing), onBack = onBack, backDescription = stringResource(R.string.mail_back)) { + if (state.items.any { it.suggestion != null }) TextButton(onClick = viewModel::confirmAll) { Text(stringResource(R.string.mail_filing_all), fontWeight = FontWeight.SemiBold) } + } + LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item(key = "intro") { + Text(stringResource(R.string.mail_filing_intro), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 4.dp)) + } + when { + state.loading -> item(key = "load") { Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } + state.error != null && state.items.isEmpty() -> item(key = "err") { ErrorBox(mailText(context, state.error), onRetry = viewModel::load) } + state.items.isEmpty() -> item(key = "empty") { ErrorBox(stringResource(R.string.mail_filing_empty), onRetry = null) } + } + items(state.items, key = { it.id }) { item -> + FilingCard( + item = item, + account = null, + busy = item.id in state.busy, + onConfirm = { viewModel.confirm(item) }, + onOther = { other = item }, + onDismiss = { viewModel.dismiss(item) }, + ) + } + me?.accounts?.takeIf { it.isNotEmpty() }?.let { accounts -> + item(key = "auto") { + Text( + stringResource( + R.string.mail_filing_auto, + accounts.joinToString(", ") { a -> + a.label + ": " + context.getString( + when (a.attachments) { + "auto" -> R.string.mail_attachments_auto + "suggest" -> R.string.mail_attachments_suggest + else -> R.string.mail_attachments_off + }, + ) + }, + ), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp), + ) + } + } + } + } + other?.let { item -> + com.gatecontrol.android.ui.drive.DriveFolderChooser( + onPicked = { folderId -> + other = null + viewModel.confirm(item, folderId) + }, + onDismiss = { other = null }, + ) + } +} + +@Composable +private fun FilingCard(item: MFilingItem, account: MAccount?, busy: Boolean, onConfirm: () -> Unit, onOther: () -> Unit, onDismiss: () -> Unit) { + val extra = GateControlTheme.extraColors + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FileBadge(item.name, "") + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(item.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (account != null) MailDot(accountColor(account)) + Text( + listOfNotNull(item.from?.label, item.date?.let { mailTime(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + maxLines = 1, + ) + } + } + if (busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + item.suggestion?.let { s -> + Text( + stringResource(R.string.mail_filing_suggestion, s.path ?: s.where ?: "", reasonText(s.reason, s.confidence)), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (item.suggestion != null) GcPrimaryButton(stringResource(R.string.mail_filing_confirm), onClick = onConfirm, enabled = !busy, fillWidth = false, minHeight = 40.dp) + GcOutlineButton(stringResource(R.string.mail_drive_other), onClick = onOther, enabled = !busy, fillWidth = false, minHeight = 40.dp) + GcOutlineButton(stringResource(R.string.mail_filing_dismiss), onClick = onDismiss, enabled = !busy, fillWidth = false, minHeight = 40.dp) + } + } +} + +/** "Postausgang": planned and failed sends; "Bearbeiten" stops one and opens its draft. */ +@Composable +fun MailOutboxScreen(onBack: () -> Unit, onDraft: (Long) -> Unit, viewModel: MailOutboxViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val open by viewModel.open.collectAsStateWithLifecycle() + val toastText by viewModel.toast.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + LaunchedEffect(open) { + open?.let { + viewModel.opened() + onDraft(it) + } + } + LaunchedEffect(toastText) { + toastText?.let { + toast(context, it) + viewModel.toastShown() + } + } + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.mail_outbox), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + LazyColumn(Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + when { + state.loading -> item { Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } + state.error != null && state.items.isEmpty() -> item { ErrorBox(mailText(context, state.error), onRetry = viewModel::load) } + state.items.isEmpty() -> item { ErrorBox(stringResource(R.string.mail_outbox_empty), onRetry = null) } + } + items(state.items, key = { it.id }) { o -> + val account = me?.account(o.accountId) + GcCard { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MailDot(accountColor(account)) + Text(o.subject.ifEmpty { stringResource(R.string.mail_no_subject) }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + when (o.state) { + "failed" -> stringResource(R.string.mail_outbox_failed) + (o.error?.let { " · " + mailText(context, it) } ?: "") + "sending" -> stringResource(R.string.mail_outbox_sending) + else -> o.sendAt?.let { stringResource(R.string.mail_planned_for, mailDateTime(it)) } ?: stringResource(R.string.mail_outbox_waiting) + }, + style = MaterialTheme.typography.bodySmall, + color = if (o.state == "failed") MaterialTheme.colorScheme.error else extra.muted, + ) + if (o.state != "sending") { + TextButton(onClick = { viewModel.cancel(o) }) { Text(stringResource(R.string.mail_outbox_edit)) } + } + } + } + } + } +} + +/** The search: words, "Ungelesen", "Mit Anhang", an account; the server searches its copy and, when needed, the provider. */ +@Composable +fun MailSearchScreen(onBack: () -> Unit, onOpen: (Long) -> Unit, viewModel: MailSearchViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar(title = stringResource(R.string.mail_search), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + GcTextField( + value = state.query, + onValueChange = viewModel::setQuery, + label = stringResource(R.string.mail_search_hint), + modifier = Modifier.padding(horizontal = 16.dp), + trailingIcon = { if (state.busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) }, + ) + Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcFilterChip(stringResource(R.string.mail_filter_unread), selected = state.unread, onClick = viewModel::toggleUnread) + GcFilterChip(stringResource(R.string.mail_filter_attachments), selected = state.attachments, onClick = viewModel::toggleAttachments) + } + if ((me?.accounts?.size ?: 0) > 1) { + Row(Modifier.padding(horizontal = 16.dp).padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GcFilterChip(stringResource(R.string.mail_box_all), selected = state.accountId == null, onClick = { viewModel.setAccount(null) }) + me?.accounts?.forEach { a -> GcFilterChip(a.label, selected = state.accountId == a.id, onClick = { viewModel.setAccount(a.id) }) } + } + } + if (state.remote) Text(stringResource(R.string.mail_search_remote), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.padding(horizontal = 16.dp)) + LazyColumn(Modifier.weight(1f)) { + if (state.error != null && state.items.isEmpty()) item { ErrorBox(mailText(context, state.error), onRetry = null) } + if (state.error == null && !state.busy && state.items.isEmpty() && state.query.length >= 2) { + item { ErrorBox(stringResource(R.string.mail_search_none), onRetry = null) } + } + items(state.items, key = { it.id }) { item -> + MailRow(item = item, account = me?.account(item.accountId), onClick = { onOpen(item.id) }, onLongClick = { onOpen(item.id) }) + MailDivider() + } + if (state.next != null) item { TextButton(onClick = viewModel::more, modifier = Modifier.padding(16.dp)) { Text(stringResource(R.string.mail_more_results)) } } + } + } +} + +/** The label of a swipe setting. */ +@Composable +private fun swipeLabel(v: String): String = stringResource( + when (v) { + MSettings.SWIPE_ARCHIVE -> R.string.mail_archive + MSettings.SWIPE_DELETE -> R.string.mail_delete + MSettings.SWIPE_READ -> R.string.mail_swipe_read + else -> R.string.mail_swipe_none + }, +) + +/** "Mail-Einstellungen": conversations, the swipes, the accounts and the storage. */ +@Composable +fun MailSettingsScreen(onBack: () -> Unit, onAccount: (Long) -> Unit, onAdd: () -> Unit, viewModel: MailSettingsViewModel = hiltViewModel()) { + val me by viewModel.me.collectAsStateWithLifecycle() + val toastText by viewModel.toast.collectAsStateWithLifecycle() + val context = mailContext() + LaunchedEffect(toastText) { + toastText?.let { + toast(context, it) + viewModel.toastShown() + } + } + Column(Modifier.fillMaxSize()) { + GcSubpageBar(title = stringResource(R.string.mail_settings), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + val m = me + if (m == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + return@Column + } + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + GcSectionLabel(stringResource(R.string.mail_settings_list)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp)) { + GcSwitchRow( + label = stringResource(R.string.mail_settings_threads), + description = stringResource(R.string.mail_settings_threads_text), + checked = m.settings.threads, + onCheckedChange = { viewModel.put(threads = it) }, + ) + } + listOf(true, false).forEach { left -> + Text(stringResource(if (left) R.string.mail_settings_swipe_left else R.string.mail_settings_swipe_right), style = MaterialTheme.typography.labelLarge) + GcSegmented( + options = MSettings.SWIPES.map { it to swipeLabel(it) }, + selected = if (left) m.settings.swipeLeft else m.settings.swipeRight, + onSelect = { v -> if (left) viewModel.put(swipeLeft = v) else viewModel.put(swipeRight = v) }, + ) + } + GcSectionLabel(stringResource(R.string.mail_accounts)) + GcCard(contentPadding = PaddingValues(vertical = 4.dp)) { + m.accounts.forEach { a -> + GcListRow( + title = a.label, + description = a.email, + onClick = { onAccount(a.id) }, + leading = { MailDot(accountColor(a), size = 12) }, + trailing = { GcChevron() }, + ) + } + GcListRow(title = stringResource(R.string.mail_add_account), onClick = onAdd, leading = { Icon(GcIcons.Plus, contentDescription = null) }) + } + com.gatecontrol.android.mail.MailLogic.quotaPercent(m.limits.usedBytes, m.limits.quotaBytes)?.let { p -> + GcSectionLabel(stringResource(R.string.mail_storage)) + GcCard { + Text(stringResource(R.string.mail_quota, mailSize(m.limits.usedBytes), mailSize(m.limits.quotaBytes)), style = MaterialTheme.typography.bodyMedium) + androidx.compose.material3.LinearProgressIndicator(progress = { p / 100f }, modifier = Modifier.fillMaxWidth()) + Text(pluralStringResource(R.plurals.mail_storage_text, m.limits.retentionMonths, m.limits.retentionMonths), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + } + } + } + } +} + +/** One account: name and color, kind, push, AI, attachments to Drive, signature, password or "Neu anmelden", remove. */ +@Composable +fun MailAccountScreen(onBack: () -> Unit, onRelogin: (Long) -> Unit, viewModel: MailAccountViewModel = hiltViewModel()) { + val me by viewModel.me.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + val gone by viewModel.gone.collectAsStateWithLifecycle() + val toastText by viewModel.toast.collectAsStateWithLifecycle() + val context = mailContext() + val extra = GateControlTheme.extraColors + LaunchedEffect(gone) { if (gone) onBack() } + LaunchedEffect(toastText) { + toastText?.let { + toast(context, it) + viewModel.toastShown() + } + } + val a = me?.account(viewModel.id) + Column(Modifier.fillMaxSize().imePadding()) { + GcSubpageBar(title = a?.label ?: stringResource(R.string.mail_account), onBack = onBack, backDescription = stringResource(R.string.mail_back)) + if (a == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if (me == null) CircularProgressIndicator() else Text(stringResource(R.string.mail_err_not_found)) } + return@Column + } + var name by rememberSaveable(a.id) { mutableStateOf(a.name.orEmpty()) } + var signature by rememberSaveable(a.id) { mutableStateOf(a.signature.orEmpty()) } + 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) + 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) + } + MAccount.STATE_UNREACHABLE -> GcBanner(tone = GcTone.Warn, icon = GcIcons.Alert) { + Text(stringResource(R.string.mail_account_unreachable, a.label), style = MaterialTheme.typography.bodySmall) + } + MAccount.STATE_SYNCING -> GcBanner(tone = GcTone.Info, icon = GcIcons.Refresh) { + Text(stringResource(R.string.mail_account_syncing), style = MaterialTheme.typography.bodySmall) + } + } + GcSectionLabel(stringResource(R.string.mail_name_color)) + GcTextField(value = name, onValueChange = { name = it }, label = stringResource(R.string.mail_name)) + if (name != a.name.orEmpty()) TextButton(onClick = { viewModel.patch(name = name) }, enabled = !busy) { Text(stringResource(R.string.mail_save)) } + ColorChoice(a.color) { viewModel.patch(color = it) } + GcSegmented( + options = listOf(MAccount.KIND_PERSONAL to stringResource(R.string.mail_kind_personal), MAccount.KIND_BUSINESS to stringResource(R.string.mail_kind_business)), + selected = a.kind, + onSelect = { viewModel.patch(kind = it) }, + ) + GcSectionLabel(stringResource(R.string.mail_push)) + GcSegmented( + options = listOf("off" to stringResource(R.string.mail_push_off), "relevant" to stringResource(R.string.mail_push_relevant), "all" to stringResource(R.string.mail_push_all)), + selected = a.push, + onSelect = { viewModel.patch(push = it) }, + ) + if (me?.has(MMe.FEATURE_AI) == true) { + GcCard(contentPadding = PaddingValues(vertical = 4.dp)) { + GcSwitchRow(label = stringResource(R.string.mail_account_ai), description = stringResource(R.string.mail_account_ai_text), checked = a.ai, onCheckedChange = { viewModel.patch(ai = it) }) + } + } + if (me?.has(MMe.FEATURE_DRIVE) == true) { + GcSectionLabel(stringResource(R.string.mail_account_attachments)) + GcSegmented( + options = listOf("off" to stringResource(R.string.mail_attachments_off), "suggest" to stringResource(R.string.mail_attachments_suggest), "auto" to stringResource(R.string.mail_attachments_auto)), + selected = a.attachments, + onSelect = { viewModel.patch(attachments = it) }, + ) + } + GcSectionLabel(stringResource(R.string.mail_signature)) + GcTextField(value = signature, onValueChange = { signature = it }, label = stringResource(R.string.mail_signature), singleLine = false) + if (signature != a.signature.orEmpty()) TextButton(onClick = { viewModel.patch(signature = signature) }, enabled = !busy) { Text(stringResource(R.string.mail_save)) } + GcSectionLabel(stringResource(R.string.mail_signin)) + if (a.microsoft) { + GcOutlineButton(stringResource(R.string.mail_relogin), onClick = { onRelogin(a.id) }) + } else { + GcTextField(value = password, onValueChange = { password = it }, label = stringResource(R.string.mail_new_password), visualTransformation = PasswordVisualTransformation()) + if (password.isNotEmpty()) { + GcPrimaryButton(stringResource(R.string.mail_save_password), onClick = { viewModel.patch(password = password); password = "" }, loading = busy, enabled = !busy) + } + } + GcOutlineButton(stringResource(R.string.mail_account_remove), onClick = { askRemove = true }, enabled = !busy) + } + if (askRemove) { + AlertDialog( + onDismissRequest = { askRemove = false }, + title = { Text(stringResource(R.string.mail_account_remove_title, a.label)) }, + text = { Text(stringResource(R.string.mail_account_remove_text)) }, + confirmButton = { TextButton(onClick = { askRemove = false; viewModel.remove() }) { Text(stringResource(R.string.mail_account_remove), color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { askRemove = false }) { Text(stringResource(R.string.mail_cancel)) } }, + ) + } + } +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt new file mode 100644 index 00000000..02a297fb --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailMoreViewModels.kt @@ -0,0 +1,326 @@ +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.R +import com.gatecontrol.android.mail.MAccount +import com.gatecontrol.android.mail.MFilingItem +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MOutboxItem +import com.gatecontrol.android.mail.MResult +import com.gatecontrol.android.mail.MSettings +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MailJson +import com.gatecontrol.android.mail.MailRepository +import com.gatecontrol.android.navigation.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** "Zum Ablegen" (mockup "Zum Ablegen"): attachments the AI was not sure about. */ +@HiltViewModel +class MailFilingViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, +) : ViewModel() { + + data class State(val items: List = emptyList(), val loading: Boolean = true, val error: String? = null, val busy: Set = emptySet()) + + val me: StateFlow = repository.me + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private val _toast = MutableStateFlow(null) + val toast: StateFlow = _toast.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + when (val r = repository.api.filing()) { + is MResult.Ok -> _state.value = State(items = r.value, loading = false) + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + /** "Ablegen" (the suggestion) or into the folder the person picked. */ + fun confirm(item: MFilingItem, folderId: Long? = null) { + _state.update { it.copy(busy = it.busy + item.id) } + viewModelScope.launch { + val r = repository.api.confirmFiling(item.id, folderId ?: item.suggestion?.folderId, if (folderId == null) item.suggestion?.name else null) + _state.update { s -> s.copy(busy = s.busy - item.id, items = if (r is MResult.Ok) s.items.filter { it.id != item.id } else s.items) } + _toast.value = when (r) { + is MResult.Ok -> context.getString(R.string.mail_saved_in, r.value.where) + else -> mailText(context, MailHomeViewModel.code(r)) + } + } + } + + /** "Nicht ablegen". */ + fun dismiss(item: MFilingItem) { + _state.update { it.copy(busy = it.busy + item.id) } + viewModelScope.launch { + val r = repository.api.dismissFiling(item.id) + _state.update { s -> s.copy(busy = s.busy - item.id, items = if (r is MResult.Ok) s.items.filter { it.id != item.id } else s.items) } + if (r !is MResult.Ok) _toast.value = mailText(context, MailHomeViewModel.code(r)) + } + } + + /** "Alle ablegen": every item with a suggestion. */ + fun confirmAll() { + _state.value.items.filter { it.suggestion != null && it.id !in _state.value.busy }.forEach { confirm(it) } + } + + fun toastShown() { + _toast.value = null + } +} + +/** "Postausgang": sends planned or failed; one can be stopped (its draft opens again). */ +@HiltViewModel +class MailOutboxViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, +) : ViewModel() { + + data class State(val items: List = emptyList(), val loading: Boolean = true, val error: String? = null) + + val me: StateFlow = repository.me + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private val _open = MutableStateFlow(null) + /** A stopped send's draft, to write on. */ + val open: StateFlow = _open.asStateFlow() + private val _toast = MutableStateFlow(null) + val toast: StateFlow = _toast.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + when (val r = repository.api.outbox()) { + is MResult.Ok -> _state.value = State(items = r.value, loading = false) + else -> _state.update { it.copy(loading = false, error = MailHomeViewModel.code(r)) } + } + } + } + + fun cancel(item: MOutboxItem) { + viewModelScope.launch { + when (val r = repository.api.cancel(item.id)) { + is MResult.Ok -> { + _state.update { s -> s.copy(items = s.items.filter { it.id != item.id }) } + repository.clearUndo(item.id) + (r.value ?: item.draftId)?.let { _open.value = it } + } + else -> _toast.value = mailText(context, MailHomeViewModel.code(r)) + } + } + } + + fun opened() { + _open.value = null + } + + fun toastShown() { + _toast.value = null + } +} + +/** The search (`GET search`): words, unread or with attachments, in all accounts or one. */ +@HiltViewModel +class MailSearchViewModel @Inject constructor(private val repository: MailRepository) : ViewModel() { + + data class State( + val query: String = "", + val unread: Boolean = false, + val attachments: Boolean = false, + val accountId: Long? = null, + val items: List = emptyList(), + val next: String? = null, + val remote: Boolean = false, + val busy: Boolean = false, + val error: String? = null, + ) + + val me: StateFlow = repository.me + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + fun setQuery(q: String) { + _state.update { it.copy(query = q) } + search() + } + + fun toggleUnread() { + _state.update { it.copy(unread = !it.unread) } + search() + } + + fun toggleAttachments() { + _state.update { it.copy(attachments = !it.attachments) } + search() + } + + fun setAccount(id: Long?) { + _state.update { it.copy(accountId = id) } + search() + } + + private fun query(s: State, cursor: String?): Map = buildMap { + put("q", s.query.trim()) + put("box", s.accountId?.toString() ?: "all") + if (s.unread) put("unread", "1") + if (s.attachments) put("attachments", "1") + cursor?.let { put("cursor", it) } + } + + private fun search() { + job?.cancel() + val s = _state.value + if (s.query.trim().length < 2 && !s.unread && !s.attachments) { + _state.update { it.copy(items = emptyList(), next = null, busy = false, error = null) } + return + } + job = viewModelScope.launch { + delay(350) + _state.update { it.copy(busy = true) } + when (val r = repository.api.search(query(_state.value, null))) { + is MResult.Ok -> _state.update { it.copy(items = r.value.items, next = r.value.next, remote = r.value.remote, busy = false, error = null) } + else -> _state.update { it.copy(busy = false, error = MailHomeViewModel.code(r)) } + } + } + } + + fun more() { + val s = _state.value + val next = s.next ?: return + if (s.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + when (val r = repository.api.search(query(s, next))) { + is MResult.Ok -> _state.update { it.copy(items = com.gatecontrol.android.mail.MailLogic.merge(it.items, r.value.items), next = r.value.next, busy = false) } + else -> _state.update { it.copy(busy = false) } + } + } + } +} + +/** "Mail-Einstellungen": the list settings (`PUT settings`) and the accounts. */ +@HiltViewModel +class MailSettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, +) : ViewModel() { + + val me: StateFlow = repository.me + private val _toast = MutableStateFlow(null) + val toast: StateFlow = _toast.asStateFlow() + + init { + repository.refreshSoon() + } + + fun put(threads: Boolean? = null, swipeLeft: String? = null, swipeRight: String? = null) { + val me = repository.me.value ?: return + // at once on the screen + repository.setMe(me.copy(settings = me.settings.copy(threads = threads ?: me.settings.threads, swipeLeft = swipeLeft ?: me.settings.swipeLeft, swipeRight = swipeRight ?: me.settings.swipeRight))) + viewModelScope.launch { + when (val r = repository.api.putSettings(threads, swipeLeft, swipeRight)) { + is MResult.Ok -> repository.me.value?.let { repository.setMe(it.copy(settings = r.value)) } + else -> { + repository.setMe(me) + _toast.value = mailText(context, MailHomeViewModel.code(r)) + } + } + } + } + + fun toastShown() { + _toast.value = null + } +} + +/** One account's settings: name, color, kind, push, AI, attachments to Drive, signature, password; remove it. */ +@HiltViewModel +class MailAccountViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + val id: Long = saved.get(Screen.MailAccount.ARG_ID)?.toLongOrNull() ?: 0L + val me: StateFlow = repository.me + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + private val _toast = MutableStateFlow(null) + val toast: StateFlow = _toast.asStateFlow() + private val _gone = MutableStateFlow(false) + /** The account was removed: the screen closes. */ + val gone: StateFlow = _gone.asStateFlow() + + fun account(): MAccount? = repository.me.value?.account(id) + + /** A change (only the given fields, `PATCH accounts/`). */ + fun patch( + name: String? = null, + color: String? = null, + kind: String? = null, + push: String? = null, + ai: Boolean? = null, + attachments: String? = null, + signature: String? = null, + password: String? = null, + ) { + _busy.value = true + viewModelScope.launch { + val r = repository.api.patchAccount(id, MailJson.accountPatch(name, color, kind, push, ai, attachments, signature, password)) + when (r) { + is MResult.Ok -> { + repository.me.value?.let { me -> repository.setMe(me.copy(accounts = me.accounts.map { if (it.id == id) r.value else it })) } + if (password != null || signature != null || name != null) _toast.value = context.getString(R.string.mail_saved) + } + else -> _toast.value = mailText(context, MailHomeViewModel.code(r)) + + (MailJson.errorHint((r as? MResult.Failed)?.error, context.resources.configuration.locales[0].language)?.let { "\n$it" } ?: "") + } + _busy.value = false + } + } + + fun remove() { + _busy.value = true + viewModelScope.launch { + when (val r = repository.api.deleteAccount(id)) { + is MResult.Ok -> { + repository.cache.forgetAccount(id) + repository.refresh() + _gone.value = true + } + else -> _toast.value = mailText(context, MailHomeViewModel.code(r)) + } + _busy.value = false + } + } + + fun toastShown() { + _toast.value = null + } + + companion object { + val SWIPES = MSettings.SWIPES + } +} 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 new file mode 100644 index 00000000..edcf721f --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadScreen.kt @@ -0,0 +1,536 @@ +package com.gatecontrol.android.ui.mail + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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.MAddress +import com.gatecontrol.android.mail.MAi +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MMessage +import com.gatecontrol.android.mail.MSummary +import com.gatecontrol.android.mail.MTodo +import com.gatecontrol.android.mail.MailLogic +import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.components.GcIconButton +import com.gatecontrol.android.ui.components.GcIcons +import com.gatecontrol.android.ui.components.GcTone +import com.gatecontrol.android.ui.theme.GateControlTheme + +/** Where the reading screen leads. */ +data class MailReadNav( + val back: () -> Unit, + /** Answer (`reply`, `replyAll`) or forward the mail, with an AI suggestion as the start of the text. */ + val compose: (mode: String, reply: String?) -> Unit, + /** A new mail to an address (a `mailto:` link, "Abmelden" by mail). */ + val mailTo: (String) -> Unit, + /** Another mail of the conversation. */ + val open: (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. + */ +@Composable +fun MailReadScreen(nav: MailReadNav, viewModel: MailReadViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val me by viewModel.me.collectAsStateWithLifecycle() + val event by viewModel.event.collectAsStateWithLifecycle() + val folders by viewModel.folders.collectAsStateWithLifecycle() + val context = mailContext() + var menu by remember { mutableStateOf(false) } + var move by remember { mutableStateOf(false) } + var driveFor by remember { mutableStateOf?>(null) } + val extra = GateControlTheme.extraColors + + LaunchedEffect(event) { + when (val e = event) { + null -> Unit + MailReadEvent.Close -> nav.back() + is MailReadEvent.Toast -> toast(context, e.text) + is MailReadEvent.Open -> openFile(context, e.file, e.mime) + } + if (event != null) viewModel.consumed() + } + + val m = state.message + val account = me?.account(m?.summary?.accountId) + val onLink: (String) -> Unit = { url -> + val to = MailLogic.mailtoAddress(url) + if (to != null) nav.mailTo(to.email) else openExternal(context, url) + } + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + GcIconButton(GcIcons.Back, contentDescription = stringResource(R.string.mail_back), onClick = nav.back, tint = MaterialTheme.colorScheme.onBackground) + Spacer(Modifier.weight(1f)) + if (m != null) { + GcIconButton(GcIcons.Archive, contentDescription = stringResource(R.string.mail_archive), onClick = { viewModel.act(MailLogic.SWIPE_ARCHIVE) }, tint = MaterialTheme.colorScheme.onBackground) + GcIconButton(GcIcons.Trash, contentDescription = stringResource(R.string.mail_delete), onClick = { viewModel.act(MailLogic.SWIPE_DELETE) }, tint = MaterialTheme.colorScheme.onBackground) + Box { + GcIconButton(GcIcons.MoreVertical, contentDescription = stringResource(R.string.mail_more), onClick = { menu = true }, tint = MaterialTheme.colorScheme.onBackground) + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.mail_mark_unread)) }, onClick = { menu = false; viewModel.act("unread") }) + DropdownMenuItem(text = { Text(stringResource(if (m.summary.flagged) R.string.mail_unflag else R.string.mail_flag)) }, onClick = { menu = false; viewModel.act("flag") }) + DropdownMenuItem( + text = { Text(stringResource(if (m.summary.tab == MSummary.TAB_OTHER) R.string.mail_to_relevant else R.string.mail_to_other)) }, + onClick = { menu = false; viewModel.act("tab") }, + ) + if (folders[m.summary.accountId].orEmpty().isNotEmpty()) { + DropdownMenuItem(text = { Text(stringResource(R.string.mail_move)) }, onClick = { menu = false; move = true }) + } + DropdownMenuItem(text = { Text(stringResource(R.string.mail_junk)) }, onClick = { menu = false; viewModel.act("junk") }) + m.unsubscribe?.let { u -> + DropdownMenuItem( + text = { Text(stringResource(R.string.mail_unsubscribe)) }, + onClick = { + menu = false + when { + u.url != null -> openLink(context, u.url) + u.mail != null -> MailLogic.mailtoAddress(if (u.mail.startsWith("mailto:")) u.mail else "mailto:" + u.mail)?.let { nav.mailTo(it.email) } + } + }, + ) + } + } + } + } + } + when { + m == null && state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + m == null -> ErrorBox(mailText(context, state.error), onRetry = viewModel::load) + else -> { + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Header(m, account?.label, accountColor(account)) + 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) + }) + val drive = me?.has(MMe.FEATURE_DRIVE) == true + state.attachments.forEach { a -> + AttachmentCard( + a = a, + sender = m.summary.from.label, + drive = drive, + onOpen = { viewModel.open(a.attachment) }, + onSave = { remember -> viewModel.saveToDrive(a, null, remember) }, + onOther = { remember -> driveFor = a to remember }, + ) + } + if (drive && state.attachments.count { it.savedTo == null } > 1) { + TextButton(onClick = viewModel::saveAll, enabled = !state.savingAll) { Text(stringResource(R.string.mail_save_all)) } + } + if (m.imagesBlocked) { + GcBanner(tone = GcTone.Info, icon = GcIcons.Photo) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.mail_images_blocked), style = MaterialTheme.typography.bodySmall) + if (me?.has(MMe.FEATURE_IMAGES) == true) { + Row { + TextButton(onClick = { viewModel.allowImages(false) }, enabled = !state.imagesBusy) { Text(stringResource(R.string.mail_images_load)) } + TextButton(onClick = { viewModel.allowImages(true) }, enabled = !state.imagesBusy) { Text(stringResource(R.string.mail_images_always)) } + } + } + } + } + } + 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)) } + } + } + 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) + } else { + SelectionContainer { + Text(m.text.orEmpty(), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.85f)) + } + } + if (m.trackers > 0 || (!m.imagesBlocked && m.html?.contains("img/") == true)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(GcIcons.Shield, contentDescription = null, tint = extra.muted, modifier = Modifier.size(14.dp)) + Text( + listOfNotNull( + m.trackers.takeIf { it > 0 }?.let { context.resources.getQuantityString(R.plurals.mail_trackers, it, it) }, + stringResource(R.string.mail_images_via).takeIf { !m.imagesBlocked && m.html?.contains("img/") == true }, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + } + } + BottomBar( + replies = (state.ai as? MailAiState.Ready)?.ai?.replies.orEmpty(), + replyAll = (m.summary.to.size + m.cc.size) > 1, + onReply = { text -> nav.compose("reply", text) }, + onReplyAll = { nav.compose("replyAll", null) }, + onForward = { nav.compose("forward", null) }, + ) + } + } + } + + if (move && m != null) { + FolderPicker( + folders = folders[m.summary.accountId].orEmpty().filter { it.id != m.summary.folderId }, + onPick = { f -> move = false; viewModel.act("move", f.id) }, + onDismiss = { move = false }, + ) + } + driveFor?.let { (a, remember) -> + com.gatecontrol.android.ui.drive.DriveFolderChooser( + onPicked = { folderId -> + driveFor = null + viewModel.saveToDrive(a, folderId, remember) + }, + onDismiss = { driveFor = null }, + ) + } +} + +@Composable +private fun Header(m: MMessage, accountLabel: String?, color: androidx.compose.ui.graphics.Color) { + 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, + ) + } + if (details) { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + DetailLine(stringResource(R.string.mail_from), listOf(m.summary.from)) + DetailLine(stringResource(R.string.mail_to), m.summary.to) + if (m.cc.isNotEmpty()) DetailLine(stringResource(R.string.mail_cc), m.cc) + if (m.replyTo.isNotEmpty()) DetailLine(stringResource(R.string.mail_reply_to), m.replyTo) + Text(mailDateTime(m.summary.date), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + } + } + } +} + +@Composable +private fun DetailLine(label: String, list: List) { + if (list.isEmpty()) return + Text( + "$label: " + list.joinToString(", ") { a -> a.name?.let { "$it <${a.email}>" } ?: a.email }, + style = MaterialTheme.typography.bodySmall, + color = GateControlTheme.extraColors.muted, + ) +} + +/** The earlier mails of the conversation (tap: open one). */ +@Composable +private fun ThreadBox(thread: List, onOpen: (Long) -> Unit) { + val extra = GateControlTheme.extraColors + 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) + } + } + } +} + +/** "Kurz gesagt" with the to-dos as chips (mockup: card with the sparkle). */ +@Composable +private fun AiCard(ai: MailAiState, me: MMe?, onRetry: () -> Unit, onFamily: (Int) -> Unit, onCalendar: (MTodo) -> Unit) { + if (ai is MailAiState.Off) return + val extra = GateControlTheme.extraColors + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface).padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(MailIcons.Sparkle, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(14.dp)) + Text(stringResource(R.string.mail_ai_short), style = MaterialTheme.typography.labelMedium, color = extra.accentText, fontWeight = FontWeight.SemiBold) + } + when (ai) { + MailAiState.Loading -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Text(stringResource(R.string.mail_ai_loading), style = MaterialTheme.typography.bodySmall, color = extra.muted) + } + is MailAiState.Error -> Row(verticalAlignment = Alignment.CenterVertically) { + Text(mailText(mailContext(), ai.code), style = MaterialTheme.typography.bodySmall, color = extra.muted, modifier = Modifier.weight(1f)) + if (ai.code != "ai_unavailable") TextButton(onClick = onRetry) { Text(stringResource(R.string.mail_retry)) } + } + is MailAiState.Ready -> AiContent(ai.ai, family = me?.has(MMe.FEATURE_FAMILY_CALENDAR) == true, onFamily = onFamily, onCalendar = onCalendar) + MailAiState.Off -> Unit + } + } +} + +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun AiContent(ai: MAi, family: Boolean, onFamily: (Int) -> Unit, onCalendar: (MTodo) -> Unit) { + ai.summary?.let { Text(it, style = MaterialTheme.typography.bodyLarge) } + if (ai.summary == null && ai.todos.isEmpty()) { + Text(stringResource(R.string.mail_ai_nothing), style = MaterialTheme.typography.bodySmall, color = GateControlTheme.extraColors.muted) + } + if (ai.todos.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ai.todos.forEachIndexed { i, todo -> TodoChip(todo, family = family, onFamily = { onFamily(i) }, onCalendar = { onCalendar(todo) }) } + } + } +} + +@Composable +private fun TodoChip(todo: MTodo, family: Boolean, onFamily: () -> Unit, onCalendar: () -> Unit) { + val extra = GateControlTheme.extraColors + var menu by remember { mutableStateOf(false) } + val calendar = MailLogic.calendarTodo(todo) + val icon = when (todo.kind) { + MTodo.DEADLINE -> GcIcons.Bell + MTodo.EVENT -> GcIcons.Calendar + MTodo.AMOUNT -> GcIcons.Wallet + MTodo.QUESTION -> GcIcons.Info + else -> GcIcons.Check + } + Box { + Row( + Modifier + .heightIn(min = 36.dp) + .clip(RoundedCornerShape(999.dp)) + .border(1.dp, extra.border2, RoundedCornerShape(999.dp)) + .then(if (calendar) Modifier.clickable(role = Role.Button) { menu = true } else Modifier) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) + Text(todo.text, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + if (family) DropdownMenuItem(text = { Text(stringResource(R.string.mail_todo_family)) }, onClick = { menu = false; onFamily() }) + DropdownMenuItem(text = { Text(stringResource(R.string.mail_todo_calendar)) }, onClick = { menu = false; onCalendar() }) + } + } +} + +/** An attachment: open it; with Drive its suggestion, "In Drive speichern", "Anderer Ordner" and "künftig automatisch". */ +@Composable +private fun AttachmentCard( + a: MailAttachmentUi, + sender: String, + drive: Boolean, + onOpen: () -> Unit, + onSave: (Boolean) -> Unit, + onOther: (Boolean) -> Unit, +) { + val extra = GateControlTheme.extraColors + var remember by remember(a.attachment.id) { mutableStateOf(false) } + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(MaterialTheme.colorScheme.surface).padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + Modifier.fillMaxWidth().clickable(role = Role.Button, onClickLabel = stringResource(R.string.mail_open_attachment), onClick = onOpen), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + 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) + } + if (a.busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + if (!drive) return@Column + val saved = a.savedTo + when { + saved != null -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(GcIcons.Check, contentDescription = null, tint = extra.accentText, modifier = Modifier.size(16.dp)) + Text(stringResource(R.string.mail_saved_in, saved.where), style = MaterialTheme.typography.bodySmall, color = extra.accentText) + } + else -> { + val s = a.suggestion + if (a.duplicate) Text(stringResource(R.string.mail_drive_duplicate), style = MaterialTheme.typography.bodySmall, color = extra.muted) + if (s != null) { + Column( + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.background).padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(stringResource(R.string.mail_drive_save_as), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Text(s.name ?: a.attachment.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text( + listOfNotNull( + (s.path ?: s.where)?.let { stringResource(R.string.mail_drive_in, it) }, + reasonText(s.reason, s.confidence), + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = extra.muted, + ) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (s != null) { + com.gatecontrol.android.ui.components.GcPrimaryButton( + stringResource(R.string.mail_drive_save), + onClick = { onSave(remember) }, + enabled = !a.busy, + modifier = Modifier.weight(1f), + minHeight = 44.dp, + ) + com.gatecontrol.android.ui.components.GcOutlineButton( + stringResource(R.string.mail_drive_other), + onClick = { onOther(remember) }, + enabled = !a.busy, + fillWidth = false, + minHeight = 44.dp, + ) + } else { + com.gatecontrol.android.ui.components.GcOutlineButton( + stringResource(R.string.mail_drive_save), + onClick = { onOther(remember) }, + enabled = !a.busy, + minHeight = 44.dp, + ) + } + } + Row( + Modifier.fillMaxWidth().heightIn(min = 40.dp).toggleable(value = remember, role = Role.Checkbox) { remember = it }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Checkbox(checked = remember, onCheckedChange = null) + Text(stringResource(R.string.mail_drive_remember, sender), style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +/** Why Drive suggests the folder ("wie die letzten Rechnungen", "72 % sicher"). */ +@Composable +internal fun reasonText(reason: String, confidence: Double): String = when (reason) { + "rule" -> stringResource(R.string.mail_reason_rule) + "learned" -> stringResource(R.string.mail_reason_learned) + "kind" -> stringResource(R.string.mail_reason_kind) + else -> stringResource(R.string.mail_reason_sure, MailLogic.percent(confidence)) +} + +/** Answer suggestions of the AI, "Antworten", "Allen antworten" and "Weiterleiten". */ +@Composable +private fun BottomBar(replies: List, replyAll: Boolean, onReply: (String?) -> Unit, onReplyAll: () -> Unit, onForward: () -> Unit) { + val extra = GateControlTheme.extraColors + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .navigationBarsPadding() + .imePadding() + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (replies.isNotEmpty()) { + Text(stringResource(R.string.mail_ai_replies), style = MaterialTheme.typography.bodySmall, color = extra.muted) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + replies.forEach { r -> + Box( + Modifier + .heightIn(min = 40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(extra.panel2) + .clickable(role = Role.Button) { onReply(r.text) } + .padding(horizontal = 14.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text(r.label, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + com.gatecontrol.android.ui.components.GcOutlineButton(stringResource(R.string.mail_reply), onClick = { onReply(null) }, modifier = Modifier.weight(1f), minHeight = 44.dp, icon = MailIcons.Reply) + if (replyAll) { + com.gatecontrol.android.ui.components.GcOutlineButton(stringResource(R.string.mail_reply_all), onClick = onReplyAll, modifier = Modifier.weight(1f), minHeight = 44.dp) + } + com.gatecontrol.android.ui.components.GcOutlineButton(stringResource(R.string.mail_forward), onClick = onForward, modifier = Modifier.weight(1f), minHeight = 44.dp, icon = MailIcons.Forward) + } + } +} 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 new file mode 100644 index 00000000..67a0c8fb --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/mail/MailReadViewModel.kt @@ -0,0 +1,338 @@ +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.R +import com.gatecontrol.android.mail.MAi +import com.gatecontrol.android.mail.MAttachment +import com.gatecontrol.android.mail.MMe +import com.gatecontrol.android.mail.MMessage +import com.gatecontrol.android.mail.MResult +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.MailLogic +import com.gatecontrol.android.mail.MailRepository +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +/** The AI bar of a mail. */ +sealed class MailAiState { + /** No AI for this mail (not offered, the account's AI off). */ + data object Off : MailAiState() + data object Loading : MailAiState() + data class Ready(val ai: MAi) : MailAiState() + data class Error(val code: String) : MailAiState() +} + +/** One attachment's Drive state on the screen. */ +data class MailAttachmentUi( + val attachment: MAttachment, + val suggestion: MSuggestion? = attachment.suggestion, + val savedTo: MSavedTo? = attachment.savedTo, + val duplicate: Boolean = false, + val busy: Boolean = false, +) + +data class MailReadState( + val message: MMessage? = null, + val loading: Boolean = true, + val offline: Boolean = false, + val error: String? = null, + /** The other mails of the conversation, oldest first. */ + val thread: List = emptyList(), + val attachments: List = emptyList(), + val ai: MailAiState = MailAiState.Off, + val imagesBusy: Boolean = false, + val savingAll: Boolean = false, +) + +/** Reading a mail (mockup "Mail lesen"): the AI bar, the attachments with "In Drive speichern", pictures, the thread. */ +@HiltViewModel +class MailReadViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MailRepository, + saved: SavedStateHandle, +) : ViewModel() { + + private val api = repository.api + private val cache = repository.cache + val id: Long = saved.get(Screen.MailMessage.ARG_ID)?.toLongOrNull() ?: 0L + + val me: StateFlow = repository.me + val folders: StateFlow>> = repository.folders + + private val _state = MutableStateFlow(MailReadState()) + val state: StateFlow = _state.asStateFlow() + + private val _event = MutableStateFlow(null) + val event: StateFlow = _event.asStateFlow() + + private var aiTried = false + + init { + load() + } + + fun load() { + if (id <= 0) { + _state.value = MailReadState(loading = false, error = "not_found") + return + } + viewModelScope.launch { + if (_state.value.message == null) cache.message(id)?.let { show(it, loading = true) } + when (val r = api.message(id)) { + is MResult.Ok -> { + val (m, raw) = r.value + show(m, loading = false) + cache.putMessage(raw) + if (m.summary.unread) markRead(m) + 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) + } + } + } + } + + private fun show(m: MMessage, loading: Boolean) { + _state.update { s -> + s.copy( + message = m, + loading = loading, + error = null, + attachments = m.attachments.filter { !it.inline }.map { a -> + // what the screen learned already (a save, a suggestion) stays + s.attachments.firstOrNull { it.attachment.id == a.id }?.copy(attachment = a, savedTo = a.savedTo ?: s.attachments.firstOrNull { it.attachment.id == a.id }?.savedTo) + ?: MailAttachmentUi(a) + }, + ) + } + } + + private fun markRead(m: MMessage) { + viewModelScope.launch { + if (api.flags(listOf(m.id), unread = false) is MResult.Ok) { + _state.update { s -> s.copy(message = s.message?.let { it.copy(summary = it.summary.copy(unread = false)) }) } + repository.refreshSoon(0) + } + } + } + + private suspend fun loadExtras(m: MMessage) { + 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 }) } } + } + // 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) } + when (val r = api.ai(m.id)) { + is MResult.Ok -> if (r.value.done) _state.update { it.copy(ai = MailAiState.Ready(r.value)) } else runAi() + else -> _state.update { it.copy(ai = aiError(r)) } + } + } + // Drive: a suggestion for each attachment that has none yet + if (me?.has(MMe.FEATURE_DRIVE) == true) { + _state.value.attachments.filter { it.savedTo == null && it.suggestion == null }.take(MAX_SUGGEST).forEach { a -> + api.suggest(m.id, a.attachment.id).valueOrNull()?.let { s -> + updateAttachment(a.attachment.id) { it.copy(suggestion = s.suggestion, duplicate = s.duplicate) } + } + } + } + } + + private fun aiError(r: MResult<*>): MailAiState = when ((r as? MResult.Failed)?.code) { + "ai_off" -> MailAiState.Off + else -> MailAiState.Error(MailHomeViewModel.code(r)) + } + + /** "Kurz gesagt" etc. made now (`POST ai/`), once per opening unless asked again. */ + fun runAi(force: Boolean = false) { + if (aiTried && !force) return + aiTried = true + _state.update { it.copy(ai = MailAiState.Loading) } + viewModelScope.launch { + when (val r = api.runAi(id, again = force)) { + is MResult.Ok -> _state.update { it.copy(ai = if (r.value.done) MailAiState.Ready(r.value) else MailAiState.Off) } + else -> _state.update { it.copy(ai = aiError(r)) } + } + } + } + + /** A to-do into the family calendar (Familie's `calendar_add`). */ + fun todoToFamily(index: Int) { + viewModelScope.launch { + _event.value = when (val r = api.todoToFamily(id, index)) { + is MResult.Ok -> MailReadEvent.Toast(context.getString(R.string.mail_todo_added, r.value.ifEmpty { context.getString(R.string.mail_family_calendar) })) + else -> MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + } + } + + /** "Bilder laden" (once) or "Immer für diesen Absender". */ + fun allowImages(sender: Boolean) { + _state.update { it.copy(imagesBusy = true) } + viewModelScope.launch { + when (val r = api.allowImages(id, sender)) { + is MResult.Ok -> { + show(r.value.first, loading = false) + cache.putMessage(r.value.second) + } + else -> _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + _state.update { it.copy(imagesBusy = false) } + } + } + + /** The bar's and the menu's actions; those that take the mail away close the screen. */ + fun act(action: String, folderId: Long? = null) { + val m = _state.value.message ?: return + val ids = listOf(m.id) + viewModelScope.launch { + val r = when (action) { + MailLogic.SWIPE_ARCHIVE -> api.archive(ids) + MailLogic.SWIPE_DELETE -> api.delete(ids) + "junk" -> api.junk(ids) + "unread" -> api.flags(ids, unread = true) + "flag" -> api.flags(ids, flagged = !m.summary.flagged) + "tab" -> api.setTab(ids, if (m.summary.tab == MSummary.TAB_OTHER) MSummary.TAB_RELEVANT else MSummary.TAB_OTHER) + "move" -> api.move(ids, folderId ?: return@launch) + else -> return@launch + } + when (r) { + is MResult.Ok -> { + repository.refreshSoon(0) + when (action) { + "flag" -> _state.update { s -> s.copy(message = s.message?.let { it.copy(summary = it.summary.copy(flagged = !it.summary.flagged)) }) } + "tab" -> { + _state.update { s -> + s.copy(message = s.message?.let { it.copy(summary = it.summary.copy(tab = if (it.summary.tab == MSummary.TAB_OTHER) MSummary.TAB_RELEVANT else MSummary.TAB_OTHER)) }) + } + _event.value = MailReadEvent.Toast(context.getString(R.string.mail_tab_learned)) + } + else -> { + cache.forgetMessages(ids) + _event.value = MailReadEvent.Close + } + } + } + else -> _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + } + } + + private fun updateAttachment(aid: Long, change: (MailAttachmentUi) -> MailAttachmentUi) { + _state.update { s -> s.copy(attachments = s.attachments.map { if (it.attachment.id == aid) change(it) else it }) } + } + + /** "In Drive speichern": into the suggested folder (its name), or into [folderId] the person picked. */ + fun saveToDrive(a: MailAttachmentUi, folderId: Long?, remember: Boolean) { + val target = folderId ?: a.suggestion?.folderId + val name = if (folderId == null) a.suggestion?.name else null + updateAttachment(a.attachment.id) { it.copy(busy = true) } + viewModelScope.launch { + when (val r = api.saveToDrive(id, a.attachment.id, target, name, remember)) { + is MResult.Ok -> { + updateAttachment(a.attachment.id) { it.copy(busy = false, savedTo = r.value) } + _event.value = MailReadEvent.Toast(context.getString(R.string.mail_saved_in, r.value.where)) + } + else -> { + updateAttachment(a.attachment.id) { it.copy(busy = false) } + _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + } + } + } + + fun saveAll() { + _state.update { it.copy(savingAll = true) } + viewModelScope.launch { + when (val r = api.saveAll(id)) { + is MResult.Ok -> { + r.value.saved.forEach { (aid, to) -> updateAttachment(aid) { it.copy(savedTo = to) } } + _event.value = MailReadEvent.Toast( + context.resources.getQuantityString(R.plurals.mail_saved_all, r.value.saved.size, r.value.saved.size) + + if (r.value.failed.isNotEmpty()) " · " + context.resources.getQuantityString(R.plurals.mail_saved_failed, r.value.failed.size, r.value.failed.size) else "", + ) + } + else -> _event.value = MailReadEvent.Toast(mailText(context, MailHomeViewModel.code(r))) + } + _state.update { it.copy(savingAll = false) } + } + } + + /** An attachment opened in another app: fetched into `cache/mail-open/-/`. */ + fun open(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))) + } + } + } + + private suspend fun fetch(a: MAttachment): MResult { + val dir = File(context.cacheDir, "${MailCache.OPEN_DIR}/$id-${a.id}") + 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) } + val body = api.media(media.value.mediaId) + if (body !is MResult.Ok) return MResult.Failed(MailHomeViewModel.code(body), 0) + return withContext(Dispatchers.IO) { + runCatching { + pruneOpened() + dir.mkdirs() + val tmp = File(dir, target.name + ".part") + body.value.use { b -> b.byteStream().use { input -> tmp.outputStream().use { input.copyTo(it) } } } + if (!tmp.renameTo(target)) error("rename") + MResult.Ok(target) as MResult + }.getOrElse { MResult.Failed("failed", 0) } + } + } + + /** Files opened more than a day ago go. */ + private fun pruneOpened() { + val root = File(context.cacheDir, MailCache.OPEN_DIR) + val cutoff = System.currentTimeMillis() - 24 * 3600_000L + 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) + + fun consumed() { + _event.value = null + } + + private companion object { + const val MAX_SUGGEST = 5 + } +} + +sealed class MailReadEvent { + data object Close : MailReadEvent() + data class Toast(val text: String) : MailReadEvent() + data class Open(val file: File, val mime: String) : MailReadEvent() +} diff --git a/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt b/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt index 2fcbde58..e2a6bd54 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/start/StartPageMenu.kt @@ -127,6 +127,7 @@ fun startPageLabel(resources: Resources, id: String): String { /** The tab's name of a function page. */ fun featureLabel(id: String): Int? = when (id) { StartPages.CHATS -> R.string.nav_chats + StartPages.MAIL -> R.string.mail_tab StartPages.VAULT -> R.string.vault_tab StartPages.DRIVE -> R.string.drive_tab StartPages.NETWORK -> R.string.nav_network @@ -138,6 +139,7 @@ fun featureLabel(id: String): Int? = when (id) { fun startPageIcon(id: String): ImageVector = when (id) { START_PAGE -> GcIcons.Shield StartPages.CHATS -> Icons.AutoMirrored.Outlined.Chat + StartPages.MAIL -> GcIcons.Mail StartPages.VAULT -> GcIcons.Vault StartPages.DRIVE -> GcIcons.Cloud StartPages.NETWORK -> GcIcons.Globe diff --git a/app/src/main/res/values-de/strings_mail.xml b/app/src/main/res/values-de/strings_mail.xml new file mode 100644 index 00000000..e93961ba --- /dev/null +++ b/app/src/main/res/values-de/strings_mail.xml @@ -0,0 +1,277 @@ + + + + Mail + Mail + Alle Posteingänge + Posteingang + Alle + Seitenmenü öffnen + Suchen + Mails durchsuchen + Nichts gefunden. + Auch beim Anbieter gesucht. + Weitere Treffer + Ungelesen + Mit Anhang + Relevant + Sonstige + Für diesen Absender gemerkt. + Hier sind keine Mails. + Nichts unter Sonstige. + Neue Mail + Gestern + Unbekannter Absender + (kein Betreff) + Markiert + Mit Anhang + In Drive + Aktionen + Archivieren + Löschen + Als gelesen markieren + Als ungelesen markieren + Markieren + Markierung entfernen + Nach Relevant + Nach Sonstige + In Ordner verschieben + Verschieben nach + Spam + Abbrechen + Erneut versuchen + Zurück + Schließen + Mehr + Gesendet + Entwürfe + Archiv + Spam + Papierkorb + Zum Ablegen + Postausgang + Konten + Konto hinzufügen + Mail-Einstellungen + %1$s von %2$s belegt + Offline – du siehst den zuletzt geladenen Stand. + %1$s: Anmeldung abgelehnt. Tippen, um dich neu anzumelden. + %1$s: Der Mail-Server ist nicht erreichbar. + Die Mails werden zum ersten Mal abgeholt … + Noch kein Mail-Konto + Füge dein Postfach hinzu – GMX, Web.de, T-Online, Gmail, Microsoft oder eine eigene Domain. + Wird in %1$d s gesendet … + Geplant für %1$s + Rückgängig + Nicht gesendet – der Entwurf ist wieder offen. + Zu spät – die Mail ist schon unterwegs. + Details zeigen + Von + An + Cc + Bcc + Cc/Bcc + Antwort an + an %1$s + Kurz gesagt + Die Mail wird gelesen … + In dieser Mail ist nichts zu tun. + Antwortvorschläge + In den Familienkalender + In meinen Kalender + In %1$s eingetragen. + den Familienkalender + Öffnen + In Drive speichern als + in %1$s + In Drive speichern + Anderer Ordner + Anhänge von %1$s künftig automatisch ablegen + Diese Datei liegt schon in deinem Drive. + Gespeichert in %1$s + Alle in Drive speichern + nach deiner Regel + wie die letzten von diesem Absender + nach Dokumentart + %1$d %% sicher + Bilder wurden blockiert, damit dich niemand verfolgt. + Bilder laden + Immer für diesen Absender + Bilder über GateControl geladen + Abmelden + Antworten + Allen antworten + Weiterleiten + Anhang hinzufügen + Senden + Senden planen + Datum und Uhrzeit wählen + Wähle einen Zeitpunkt in der Zukunft. + Verwerfen + Diese Mail verwerfen? + Der Entwurf wird gelöscht. + Als Entwurf gespeichert. + Betreff + Text + Zitierten Text anzeigen + Zitierten Text ausblenden + Entfernen + %1$s entfernen + wird hochgeladen … + Füge mindestens einen Empfänger hinzu. + Warte, bis die Anhänge hochgeladen sind. + Ohne Betreff senden? + Die Mail hat keinen Betreff. + Zu groß für den Anbieter: große Drive-Dateien gehen als Link. + Die Mail scheint ein Passwort zu enthalten. + Bitte prüfe die Mail vor dem Senden. + Vom Gerät + Aus Drive + In Drive suchen + Treffer + Kopie + Link + zu groß zum Anhängen + Link für „%1$s“ + „%1$s“ ist zu groß – teile die Datei aus Drive als Link. + Alle ablegen + Anhänge, bei denen die KI nicht sicher war. Jede Bestätigung oder Korrektur merkt sich die Ablage. + Nichts abzulegen. + Vorschlag: %1$s · %2$s + Ablegen + Nicht ablegen + Automatisch ablegen – %1$s. Ändern in den Mail-Einstellungen je Konto. + aus + nur Vorschläge + an + Nichts wartet. + Senden fehlgeschlagen + Wird gesendet … + Wartet + Anhalten und bearbeiten + Liste + Unterhaltungen gruppieren + Antworten erscheinen zusammen mit der Mail, auf die sie antworten. + Wischen nach links + Wischen nach rechts + Gelesen + Nichts + Speicher + Konto + Anzeigename + Anzeigename und Farbe + Speichern + Gespeichert. + Privat + Firma + Benachrichtigungen + Aus + Relevant + Alle + KI für dieses Konto + Kurz gesagt, Zu tun, Antwortvorschläge und Sortieren. + Anhänge nach Drive + Signatur + Anmeldung + Neu anmelden + Neues Passwort + Passwort speichern + Konto entfernen + %1$s entfernen? + GateControl vergisst dieses Konto und seine Mails. Das Postfach beim Anbieter bleibt, wie es ist. + %1$s hinzugefügt. + Microsoft 365 · outlook.com + noch nicht eingerichtet + Die Microsoft-Anmeldung ist noch nicht eingerichtet. Das kann dein Admin im Portal unter Mail › Einstellungen erledigen. + Du meldest dich im Browser bei Microsoft an. GateControl sieht dein Passwort nie. + Mit Microsoft anmelden + Warte auf den Browser … tippen, um es erneut zu versuchen + Anmeldung abgebrochen. + Microsoft hat die Anmeldung nicht erlaubt. + Anmeldung + Passwort + App-Passwort + IMAP + %1$s einrichten + E-Mail-Adresse + Passwort + App-Passwort + Benutzername (falls abweichend) + Eingang (IMAP) + Ausgang (SMTP) + Server + Port + So geht’s + Das Passwort wird verschlüsselt auf deinem GateControl-Server gespeichert, nicht auf diesem Gerät. + Verbinden + Keine App kann das öffnen. + Keine Kalender-App gefunden. + Der Server ist gerade nicht erreichbar. + Die App ist noch nicht eingerichtet. + Mail ist für dich nicht freigeschaltet. + Die Grenze ist erreicht. + Der Anbieter hat die Anmeldung abgelehnt. Prüfe Adresse und Passwort. + IMAP ist für dieses Postfach ausgeschaltet. Erlaube es zuerst im Webmail des Anbieters. + Der Mail-Server ist nicht erreichbar. + Keine sichere Verbindung zum Mail-Server. + Bitte prüfe deine Eingaben. + Zu groß. + Die KI ist aus. + Die KI ist gerade nicht verfügbar. + Zu viele Anfragen – versuch es gleich noch einmal. + Der Familienkalender ist nicht erreichbar. + 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. + 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. + Das Laden von Bildern ist auf dem Server ausgeschaltet. + Drive ist gerade nicht erreichbar. + Dafür gibt es keinen Ordner – wähle einen aus. + Das wartet nicht mehr – es wurde schon gesendet. + Dieses Konto gibt es schon. + Drive-Links sind ausgeschaltet. + Das hat nicht geklappt. + + Unterhaltung · %d Mail + Unterhaltung · %d Mails + + + Du kannst höchstens %d Konto hinzufügen. + Du kannst höchstens %d Konten hinzufügen. + + + gültig %d Tag · ohne Passwort · nur ansehen + gültig %d Tage · ohne Passwort · nur ansehen + + + Mails des letzten %d Monats liegen auf deinem GateControl-Server. + Mails der letzten %d Monate liegen auf deinem GateControl-Server (0: alle). + + + %d Konto + %d Konten + + + %d ungelesen + %d ungelesen + + + %d Datei hinzufügen + %d Dateien hinzufügen + + + %d in Drive gespeichert + %d in Drive gespeichert + + + %d fehlgeschlagen + %d fehlgeschlagen + + + %d Zählpixel entfernt + %d Zählpixel entfernt + + diff --git a/app/src/main/res/values/strings_mail.xml b/app/src/main/res/values/strings_mail.xml new file mode 100644 index 00000000..2c9ebdf9 --- /dev/null +++ b/app/src/main/res/values/strings_mail.xml @@ -0,0 +1,277 @@ + + + + Mail + Mail + All inboxes + Inbox + All + Open the side menu + Search + Search mail + Nothing found. + Also searched at the provider. + More results + Unread + With attachment + Relevant + Other + Noted for this sender. + No mail here. + Nothing under Other. + New mail + Yesterday + Unknown sender + (no subject) + Flagged + Has attachments + In Drive + Actions + Archive + Delete + Mark as read + Mark as unread + Flag + Remove flag + Move to Relevant + Move to Other + Move to folder + Move to + Spam + Cancel + Try again + Back + Close + More + Sent + Drafts + Archive + Spam + Trash + To file + Outbox + Accounts + Add account + Mail settings + %1$s of %2$s used + Offline – showing what was loaded last. + %1$s: sign-in refused. Tap to sign in again. + %1$s: the mail server cannot be reached. + Mails are being fetched for the first time … + No mail account yet + Add your mailbox – GMX, Web.de, T-Online, Gmail, Microsoft or your own domain. + Sending in %1$d s … + Planned for %1$s + Undo + Not sent – the draft is open again. + Too late – the mail is already on its way. + Show details + From + To + Cc + Bcc + Cc/Bcc + Reply to + to %1$s + In short + Reading the mail … + Nothing to do in this mail. + Reply suggestions + Add to the family calendar + Add to my calendar + Added to %1$s. + the family calendar + Open + Save to Drive as + in %1$s + Save to Drive + Other folder + File attachments from %1$s automatically from now on + This file is already in your Drive. + Saved in %1$s + Save all to Drive + by your rule + like the last ones from this sender + by document type + %1$d %% sure + Pictures were blocked to protect you from tracking. + Load pictures + Always for this sender + pictures loaded through GateControl + Unsubscribe + Reply + Reply all + Forward + Add attachment + Send + Schedule send + Pick date and time + Pick a time in the future. + Discard + Discard this mail? + The draft is deleted. + Saved as a draft. + Subject + Text + Show quoted text + Hide quoted text + Remove + Remove %1$s + uploading … + Add at least one recipient. + Wait until the attachments are uploaded. + Send without subject? + The mail has no subject. + Too large for the provider: large Drive files go as a link. + The mail seems to contain a password. + Please check the mail before sending. + From device + From Drive + Search in Drive + Results + Copy + Link + too large to attach + Link for “%1$s” + “%1$s” is too large – share it from Drive as a link. + File all + Attachments the AI was not sure about. Each confirmation or correction is remembered. + Nothing to file. + Suggestion: %1$s · %2$s + File + Don’t file + Filing automatically – %1$s. Change it per account in the mail settings. + off + suggestions only + on + Nothing waiting. + Sending failed + Sending … + Waiting + Stop and edit + List + Group conversations + Replies are shown together with the mail they answer. + Swipe left + Swipe right + Read + Nothing + Storage + Account + Display name + Display name and color + Save + Saved. + Private + Work + Notifications + Off + Relevant + All + AI for this account + In short, to-dos, reply suggestions and sorting. + Attachments to Drive + Signature + Sign-in + Sign in again + New password + Save password + Remove account + Remove %1$s? + GateControl forgets this account and its mails. The mailbox at the provider stays as it is. + %1$s added. + Microsoft 365 · outlook.com + not set up yet + Microsoft sign-in is not set up yet. Your administrator can set it up in the portal under Mail › Settings. + You sign in with Microsoft in the browser. GateControl never sees your password. + Sign in with Microsoft + Waiting for the browser … tap to try again + Sign-in cancelled. + Microsoft did not allow the sign-in. + Sign-in + Password + App password + IMAP + Set up %1$s + Email address + Password + App password + User name (if different) + Incoming (IMAP) + Outgoing (SMTP) + Server + Port + How it works + The password is stored encrypted on your GateControl server, not on this device. + Connect + No app can open this. + No calendar app found. + The server cannot be reached right now. + The app is not set up yet. + Mail is not enabled for you. + The limit is reached. + The provider refused the sign-in. Check address and password. + IMAP is switched off for this mailbox. Allow it in the provider’s webmail first. + The mail server cannot be reached. + No secure connection to the mail server. + Please check your entries. + Too large. + The AI is off. + The AI is not available right now. + Too many requests – try again in a moment. + The family calendar cannot be reached. + 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. + 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. + Loading pictures is switched off on the server. + Drive cannot be reached right now. + There is no folder for it – pick one. + It is no longer waiting – it was sent already. + This account exists already. + Drive links are switched off. + That did not work. + + Conversation · %d mail + Conversation · %d mails + + + You can add at most %d account. + You can add at most %d accounts. + + + valid %d day · without password · view only + valid %d days · without password · view only + + + Mails of the last %d month are kept on your GateControl server. + Mails of the last %d months are kept on your GateControl server (0: all). + + + %d account + %d accounts + + + %d unread + %d unread + + + Add %d file + Add %d files + + + %d saved to Drive + %d saved to Drive + + + %d failed + %d failed + + + %d tracking pixel removed + %d tracking pixels removed + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index fb6ba086..609ade5c 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -24,4 +24,9 @@ left and at the next start. The ciphertext of an upload in cacheDir/drive-vault-up stays private. --> + + diff --git a/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt b/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt index 3778e8a4..5a8242e2 100644 --- a/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ManifestSecurityTest.kt @@ -203,4 +203,21 @@ class ManifestSecurityTest { val category = (target.getElementsByTagName("category").item(0) as Element).androidAttr("name") assertEquals(com.gatecontrol.android.chat.ChatShortcutLogic.SHARE_CATEGORY, category) } + + @Test + fun `mail sign-in redirect is msauth of the app only, off until the server offers Mail`() { + val activity = components("activity").firstOrNull { it.androidAttr("name").endsWith("MailSigninActivity") } + assertNotNull(activity, "MailSigninActivity must be declared") + assertEquals("true", activity!!.androidAttr("exported")) + assertEquals("false", activity.androidAttr("enabled"), "enabled by MailStartup only while the plugin is there") + assertEquals("true", activity.androidAttr("excludeFromRecents")) + val data = activity.getElementsByTagName("data") + assertEquals(1, data.length) + val d = data.item(0) as org.w3c.dom.Element + assertEquals("msauth", d.androidAttr("scheme")) + assertEquals("com.gatecontrol.client", d.androidAttr("host")) + val xml = moduleFile("src/main/res/xml/file_paths.xml").readText() + assertTrue(xml.contains("path=\"mail-open/\""), "opened attachments only") + assertFalse(xml.contains("path=\"mail-up"), "uploads stay private") + } } diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt new file mode 100644 index 00000000..21acf085 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailJsonTest.kt @@ -0,0 +1,291 @@ +package com.gatecontrol.android.mail + +import com.google.gson.JsonParser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** Contract C2: the plugin's answers read from the tree, the bodies the app sends. */ +class MailJsonTest { + + private fun obj(s: String) = JsonParser.parseString(s).asJsonObject + + private val account = """{"id":3,"provider":"gmx","email":"a@gmx.de","name":"Privat · GMX","color":"#F5B544","kind":"personal", + "state":"ok","error":null,"push":"relevant","ai":true,"attachments":"auto","signature":"Gruß"}""" + + @Test + fun `me - features, limits, Microsoft, accounts, unread and settings`() { + val me = MailJson.me( + obj( + """{"features":["mail","ai","drive"],"limits":{"accountsMax":5,"quotaBytes":5368709120,"usedBytes":1024,"retentionMonths":12}, + "microsoft":{"clientId":null,"authority":"https://login.microsoftonline.com/common","scopes":["openid","Mail.Send"]}, + "accounts":[$account,{"id":0,"email":"bad"}],"unread":{"all":4,"byAccount":{"3":2,"x":1}}, + "settings":{"threads":false,"swipeLeft":"delete","swipeRight":"bogus"}}""", + ), + )!! + assertTrue(me.has(MMe.FEATURE_AI)) + assertFalse(me.has(MMe.FEATURE_MICROSOFT)) + assertEquals(5368709120L, me.limits.quotaBytes) + assertNull(me.microsoft.clientId, "no client id: Microsoft is not set up") + assertEquals(listOf("openid", "Mail.Send"), me.microsoft.scopes) + assertEquals(1, me.accounts.size, "an account without a valid id is dropped") + val a = me.accounts.single() + assertEquals("Privat · GMX", a.label) + assertEquals("#F5B544", a.color) + assertEquals("relevant", a.push) + assertEquals("auto", a.attachments) + assertEquals(4, me.unreadAll) + assertEquals(mapOf(3L to 2), me.unreadByAccount) + assertFalse(me.settings.threads) + assertEquals("delete", me.settings.swipeLeft) + assertEquals(MSettings.DEFAULT.swipeRight, me.settings.swipeRight, "an unknown swipe falls back") + } + + @Test + fun `me - without features it is no answer, defaults fill the rest`() { + assertNull(MailJson.me(obj("""{"accounts":[]}"""))) + val me = MailJson.me(obj("""{"features":["mail"]}"""))!! + assertEquals(MailMicrosoft.DEFAULT_AUTHORITY, me.microsoft.authority) + assertEquals(MailMicrosoft.DEFAULT_SCOPES, me.microsoft.scopes) + assertEquals(MSettings.DEFAULT, me.settings) + assertTrue(me.accounts.isEmpty()) + // an http authority is not taken + assertEquals(MailMicrosoft.DEFAULT_AUTHORITY, MailJson.me(obj("""{"features":[],"microsoft":{"authority":"http://evil"}}"""))!!.microsoft.authority) + } + + @Test + fun `account - a bad color is dropped, unknown values fall back`() { + val a = MailJson.account(obj("""{"id":"7","email":"x@y.de","color":"red","kind":"weird","push":"loud","attachments":"x","error":{"code":"auth_failed","at":5}}"""))!! + assertEquals(7L, a.id) + assertNull(a.color) + assertEquals(MAccount.KIND_PERSONAL, a.kind) + assertEquals("all", a.push) + assertEquals("off", a.attachments) + assertEquals("auth_failed", a.errorCode) + assertEquals(5L, a.errorAt) + assertEquals("x@y.de", a.label) + assertTrue(MailJson.account(obj("""{"id":1,"provider":"m365","email":"a@b.de"}"""))!!.microsoft) + } + + @Test + fun `providers - an array or an object with a list, hints in both languages`() { + val root = JsonParser.parseString("""[{"id":"gmx","label":"GMX","auth":"password","hint":{"de":"IMAP erlauben","en":"Allow IMAP"},"help":"https://x"}, + {"id":"gmail","label":"Gmail","auth":"app_password","hint":null,"help":"http://insecure"},{"label":"no id"}]""") + val list = MailJson.providers(root)!! + assertEquals(listOf("gmx", "gmail"), list.map { it.id }) + assertEquals("Allow IMAP", list[0].hint("en")) + assertEquals("IMAP erlauben", list[0].hint("de")) + assertNull(list[1].help, "only https help links") + assertEquals(1, MailJson.providers(JsonParser.parseString("""{"providers":[{"id":"imap"}]}"""))!!.size) + } + + @Test + fun `folders - per account with their role`() { + val f = MailJson.folders( + obj("""{"accounts":[{"accountId":3,"folders":[{"id":10,"name":"INBOX","path":"INBOX","role":"inbox","parentId":null,"unread":2,"total":9}, + {"id":11,"name":"Rechnungen","path":"INBOX/Rechnungen","role":"bogus","parentId":10,"unread":0,"total":1}]}]}"""), + )!! + val list = f.getValue(3L) + assertEquals(MFolder.ROLE_INBOX, list[0].role) + assertNull(list[1].role) + assertEquals(10L, list[1].parentId) + } + + private val summary = """{"id":42,"accountId":3,"folderId":10,"threadId":5,"threadCount":2,"from":{"name":"Stadtwerke","email":"info@sw.de"}, + "to":[{"name":"Max","email":"m@gmx.de"},"Erika "],"subject":"Rechnung","snippet":"Im Anhang","date":1730000000000,"unread":true, + "flagged":false,"answered":false,"hasAttachments":true,"tab":"other","savedToDrive":true,"ai":{"summary":"84,20 €"}}""" + + @Test + fun `a page of summaries with its cursor and counts`() { + val page = MailJson.page(obj("""{"items":[$summary,{"id":1}],"next":"c-1","counts":{"relevant":3,"other":7}}"""))!! + val s = page.items.single() + assertEquals(42L, s.id) + assertEquals("Stadtwerke", s.from.label) + assertEquals(listOf("m@gmx.de", "e@x.de"), s.to.map { it.email }, "objects and strings") + assertEquals(MSummary.TAB_OTHER, s.tab) + assertEquals("84,20 €", s.aiSummary) + assertEquals("c-1", page.next) + assertEquals(MCounts(3, 7), page.counts) + assertEquals("17", MailJson.page(obj("""{"items":[],"next":17}"""))!!.next, "a numeric cursor") + assertNull(MailJson.page(obj("""{"next":null}"""))) + } + + @Test + fun `a message - body, pictures, trackers, attachments with Drive`() { + val m = MailJson.message( + obj( + """{${summary.trim().removePrefix("{").removeSuffix("}")},"cc":[],"bcc":[],"replyTo":[{"email":"r@sw.de"}], + "html":"

Hallo

","text":"Hallo","imagesBlocked":true,"trackers":2, + "attachments":[{"id":7,"name":"Rechnung.pdf","mime":"application/pdf","size":212000,"inline":false,"cid":null, + "savedTo":null,"suggestion":{"folderId":99,"areaId":"mine:1","path":"Finanzen › Strom","where":"Meine Dateien","name":"2026-11-03 Stadtwerke Rechnung.pdf","confidence":0.72,"reason":"learned"}}, + {"id":8,"name":"logo.png","mime":"image/png","size":100,"inline":true,"cid":"","savedTo":{"nodeId":5,"where":"Bilder"},"suggestion":null}], + "listUnsubscribe":{"url":"javascript:alert(1)","mail":"unsub@sw.de"}}""", + ), + )!! + assertTrue(m.imagesBlocked) + assertEquals(2, m.trackers) + assertEquals("r@sw.de", m.replyTo.single().email) + val pdf = m.attachments[0] + assertEquals(0.72, pdf.suggestion!!.confidence) + assertEquals("learned", pdf.suggestion!!.reason) + assertEquals("logo@x", m.attachments[1].cid, "angle brackets dropped") + assertEquals(5L, m.attachments[1].savedTo!!.nodeId) + assertNull(m.unsubscribe!!.url, "only web links") + assertEquals("unsub@sw.de", m.unsubscribe!!.mail) + } + + @Test + fun `AI - done with to-dos and replies, none is empty`() { + val ai = MailJson.ai( + obj( + """{"status":"done","summary":"Kurz","todos":[{"kind":"deadline","text":"Zahlen bis 28.11.","date":"2026-11-28"}, + {"kind":"event","text":"Termin","date":"2026-13-01","time":"25:00"},{"kind":"task"}], + "replies":[{"label":"Danke","text":"Danke, ist angekommen."},{"text":"Ohne Label"}]}""", + ), + )!! + assertTrue(ai.done) + assertEquals(2, ai.todos.size, "a to-do without text is dropped") + assertEquals("2026-11-28", ai.todos[0].date) + assertNull(ai.todos[1].date, "an impossible date is dropped") + assertNull(ai.todos[1].time) + assertEquals("Ohne Label", ai.replies[1].label) + assertEquals(MAi.NONE, MailJson.ai(obj("""{"status":"none"}"""))) + assertNull(MailJson.ai(obj("""{}"""))) + } + + @Test + fun `drafts, sending and the outbox`() { + val d = MailJson.draftAnswer( + obj( + """{"draft":{"id":9,"accountId":3,"to":[{"name":"Tim","email":"tim@firma.de"}],"cc":[],"bcc":[],"subject":"Angebot","html":"

x

", + "attachments":[{"id":1,"name":"Preisliste.xlsx","size":46000000,"mime":"application/vnd.ms-excel","as":"link","url":"https://gc/l/x"}], + "warnings":[{"kind":"too_large","text":"Als Link"}]}}""", + ), + )!! + assertEquals(9L, d.id) + assertTrue(d.attachments.single().asLink) + assertEquals("too_large", d.warnings.single().kind) + val send = MailJson.send(obj("""{"outboxId":4,"undoUntil":1000,"sendAt":2000}"""))!! + assertEquals(MSend(4, 1000, 2000), send) + assertEquals(9L, MailJson.cancelled(obj("""{"draftId":9}"""))) + val out = MailJson.outbox(obj("""{"items":[{"id":4,"draftId":9,"accountId":3,"subject":"x","sendAt":5,"state":"failed","error":{"code":"rejected"}}]}"""))!! + assertEquals("rejected", out.single().error) + assertEquals(listOf("a@b.de"), MailJson.contacts(obj("""{"items":[{"name":"A","email":"a@b.de","source":"person"},{"email":"no-at"}]}"""))!!.map { it.email }) + } + + @Test + fun `filing, suggestions and saving`() { + val items = MailJson.filing( + obj("""{"items":[{"id":1,"messageId":42,"attachmentId":7,"name":"Beitrag.pdf","from":{"name":"Verein","email":"v@x.de"},"date":5, + "suggestion":{"folderId":99,"path":"Finanzen","confidence":0.64,"reason":"ai"}},{"id":2,"messageId":0,"attachmentId":1}]}"""), + )!! + assertEquals(1, items.size) + assertEquals(0.64, items[0].suggestion!!.confidence) + val s = MailJson.suggest(obj("""{"suggestion":null,"duplicate":true}""")) + assertNull(s.suggestion) + assertTrue(s.duplicate) + assertEquals(MSavedTo(5, "Finanzen › Strom"), MailJson.savedAnswer(obj("""{"savedTo":{"nodeId":5,"where":"Finanzen › Strom"}}"""))) + val all = MailJson.saveAll(obj("""{"saved":[{"attachmentId":7,"savedTo":{"nodeId":5,"where":"A"}}],"failed":[{"attachmentId":8,"code":"too_large"}]}""")) + assertEquals(7L, all.saved.single().first) + assertEquals(8L to "too_large", all.failed.single()) + } + + @Test + fun `errors - the plugin's code, the core's, else the status`() { + assertEquals("auth_failed", MailJson.failureCode(409, obj("""{"error":{"code":"auth_failed","hint":{"de":"IMAP erlauben","en":"Allow IMAP"}}}"""))) + assertEquals("not_found", MailJson.failureCode(404, obj("""{"ok":false,"code":"not_found"}"""))) + assertEquals("http_502", MailJson.failureCode(502, null)) + assertEquals("http_400", MailJson.failureCode(400, obj("""{"error":{"code":"Bad Code!"}}"""))) + assertTrue(MailJson.isRefusal(obj("""{"error":{"code":"x"}}"""))) + assertFalse(MailJson.isRefusal(obj("""{"ok":true}"""))) + val err = obj("""{"error":{"code":"auth_failed","hint":{"de":"IMAP erlauben","en":"Allow IMAP"}}}""") + assertEquals("Allow IMAP", MailJson.errorHint(err, "en")) + assertEquals("IMAP erlauben", MailJson.errorHint(err.getAsJsonObject("error"), "de")) + assertEquals("plain", MailJson.errorHint(obj("""{"hint":"plain"}"""), "de")) + } + + @Test + fun `the realtime event`() { + assertEquals(MChanged(3, 10), MailJson.changed(JsonParser.parseString("""{"kind":"changed","accountId":3,"folderId":10}"""))) + assertEquals(MChanged(3, null), MailJson.changed(JsonParser.parseString("""{"mail":{"kind":"changed","accountId":3}}"""))) + assertNull(MailJson.changed(JsonParser.parseString("""{"kind":"other"}"""))) + assertNull(MailJson.changed(null)) + } + + @Test + fun `bodies - drafts with every kind of attachment, flags, moves, accounts`() { + val body = MailJson.draftBody( + accountId = 3, + to = listOf(MAddress("Tim", "tim@firma.de")), + cc = emptyList(), + bcc = null, + subject = "Angebot", + html = "

x

", + replyTo = 42, + mode = "forward", + attachments = listOf( + MAtt.Kept(1), + MAtt.Upload("m1", "a.pdf", "application/pdf"), + MAtt.Forwarded(42, 7), + MAtt.Drive(5, asLink = false), + MAtt.Drive(6, asLink = true, expiresDays = 200, password = "pw"), + ), + ) + assertEquals(3L, body.get("accountId").asLong) + assertEquals("tim@firma.de", body.getAsJsonArray("to")[0].asJsonObject.get("email").asString) + assertFalse(body.has("bcc"), "null fields are left out (the server fills them in)") + assertEquals("forward", body.get("mode").asString) + val atts = body.getAsJsonArray("attachments").map { it.asJsonObject } + assertEquals(1L, atts[0].get("id").asLong) + assertEquals("m1", atts[1].get("mediaId").asString) + assertEquals("a.pdf", atts[1].get("name").asString) + assertFalse(atts[1].has("mime"), "Att of a device file: { mediaId, name }") + assertEquals(7L, atts[2].get("attachmentId").asLong) + assertEquals("copy", atts[3].get("as").asString) + assertFalse(atts[3].has("expiresDays")) + assertEquals("link", atts[4].get("as").asString) + assertEquals(90, atts[4].get("expiresDays").asInt, "at most 90 days") + assertEquals("pw", atts[4].get("password").asString) + assertFalse(MailJson.draftBody(null, null, null, null, null, null, mode = "bogus").has("mode")) + + val flags = MailJson.flagsBody(listOf(1, 2), unread = false, flagged = null) + assertEquals(2, flags.getAsJsonArray("ids").size()) + assertFalse(flags.get("unread").asBoolean) + assertFalse(flags.has("flagged")) + assertEquals(9L, MailJson.moveBody(listOf(1), 9).get("folderId").asLong) + assertEquals("other", MailJson.tabBody(listOf(1), "other").get("tab").asString) + assertFalse(MailJson.settingsBody(swipeLeft = "explode").has("swipeLeft")) + + val acc = MailJson.accountBody("imap", " a@b.de ", "pw", " ", "Name", "#46E0A0", "business", MServer("imap.b.de", 993, "tls"), null) + assertEquals("a@b.de", acc.get("email").asString) + assertFalse(acc.has("username")) + assertEquals(993, acc.getAsJsonObject("imap").get("port").asInt) + assertFalse(acc.has("smtp")) + val patch = MailJson.accountPatch(push = "relevant", attachments = "never") + assertEquals(setOf("push"), patch.keySet()) + val ms = MailJson.microsoftBody("code", "verifier", "msauth://x/y", null, "bad", null) + assertEquals(setOf("code", "codeVerifier", "redirectUri"), ms.keySet()) + assertEquals("sender", MailJson.imagesBody(true).get("allow").asString) + assertEquals("once", MailJson.imagesBody(false).get("allow").asString) + assertEquals("family_calendar", MailJson.todoBody().get("action").asString) + assertEquals(setOf("folderId", "remember"), MailJson.driveSaveBody(5, " ", true).keySet()) + } + + @Test + fun `pictures of the HTML - inline Base64 or a media file, never SVG`() { + val img = MailJson.picture(obj("""{"mime":"image/png","size":3,"data":"AQID"}"""))!! + assertEquals("image/png", img.mime) + assertEquals(listOf(1, 2, 3), img.data!!.toList()) + val cid = MailJson.picture(obj("""{"mediaId":"m_1","mime":"image/jpeg","size":10,"url":"/x"}"""))!! + assertEquals("m_1", cid.mediaId) + assertNull(cid.data) + assertNull(MailJson.picture(obj("""{"mime":"image/svg+xml","data":"PHN2Zz4="}""")), "SVG can carry script") + assertNull(MailJson.picture(obj("""{"mime":"text/html","data":"AQID"}"""))) + assertNull(MailJson.picture(obj("""{"mime":"image/png","mediaId":"../x"}"""))) + assertNull(MailJson.picture(obj("""{"mime":"image/png"}"""))) + assertTrue(MailJson.message(obj("""{"id":1,"accountId":2,"bodyMissing":true}"""))!!.bodyMissing) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt new file mode 100644 index 00000000..ffdd29ef --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailLogicTest.kt @@ -0,0 +1,224 @@ +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZonedDateTime + +class MailLogicTest { + + private val zone = ZoneId.of("Europe/Berlin") + + private fun s(id: Long, date: Long = 0, unread: Boolean = false) = MSummary( + id = id, accountId = 1, folderId = null, threadId = null, threadCount = 1, from = MAddress("A", "a@b.de"), to = emptyList(), + subject = "s$id", snippet = "", date = date, unread = unread, flagged = false, answered = false, hasAttachments = false, + tab = MSummary.TAB_RELEVANT, savedToDrive = false, aiSummary = null, + ) + + @Test + fun `boxes - all inboxes, one account, a folder`() { + val inbox = MFolder(10, 3, "INBOX", "INBOX", MFolder.ROLE_INBOX, null, 0, 0) + val sent = MFolder(11, 3, "Sent", "Sent", MFolder.ROLE_SENT, null, 0, 0) + assertEquals(mapOf("box" to "all", "tab" to "relevant", "limit" to "50"), MailLogic.listQuery(MailLogic.Box(), "relevant", inbox = true)) + assertEquals( + mapOf("box" to "3", "folder" to "11", "before" to "c", "unread" to "1", "limit" to "50"), + MailLogic.listQuery(MailLogic.Box(3, 11), "relevant", inbox = false, before = "c", unread = true), + ) + assertTrue(MailLogic.Box(3).isInbox(listOf(inbox, sent))) + assertTrue(MailLogic.Box(3, 10).isInbox(listOf(inbox, sent))) + assertFalse(MailLogic.Box(3, 11).isInbox(listOf(inbox, sent))) + assertTrue(MailLogic.Box().all) + assertEquals("box-all-0-other", MailLogic.Box().key("other")) + assertEquals("box-3-11-all", MailLogic.Box(3, 11).key("all")) + } + + @Test + fun `pages - newer data replaces, more is added, changes and removals`() { + val merged = MailLogic.merge(listOf(s(1), s(2)), listOf(s(2, unread = true), s(3))) + assertEquals(listOf(1L, 2L, 3L), merged.map { it.id }) + assertTrue(merged[1].unread) + val applied = MailLogic.apply(merged, listOf(s(1, unread = true)), removed = setOf(3)) + assertEquals(listOf(1L, 2L), applied.map { it.id }) + assertTrue(applied[0].unread) + } + + @Test + fun `swipes - only archive, delete and read act`() { + assertEquals("archive", MailLogic.swipe("archive")) + assertEquals("read", MailLogic.swipe("read")) + assertNull(MailLogic.swipe("none")) + assertNull(MailLogic.swipe(null)) + assertNull(MailLogic.swipe("explode")) + } + + @Test + fun `undo - the server's window when it fits, else 10 s less the call`() { + val now = 1_000_000L + assertEquals(9_000L, MailLogic.undoWindow(now + 9_000, now)) + assertEquals(10_000L, MailLogic.undoWindow(now + 10_000, now)) + // clocks apart (the server's time far ahead or behind): the contract's 10 s from the answer + assertEquals(10_000L, MailLogic.undoWindow(now + 70_000, now)) + assertEquals(9_200L, MailLogic.undoWindow(now - 50_000, now, callMs = 800)) + assertEquals(10_000L, MailLogic.undoWindow(null, now)) + assertEquals(0L, MailLogic.undoWindow(null, now, callMs = 20_000)) + assertFalse(MailLogic.planned(now + 10_000, now), "the undo time is no plan") + assertTrue(MailLogic.planned(now + 3_600_000, now)) + assertFalse(MailLogic.planned(null, now)) + } + + @Test + fun `planning - tomorrow and Monday morning`() { + val thursday = ZonedDateTime.of(2026, 10, 29, 15, 0, 0, 0, zone).toInstant().toEpochMilli() + val c = MailLogic.planChoices(thursday, zone).map { java.time.Instant.ofEpochMilli(it).atZone(zone) } + assertEquals(LocalDate.of(2026, 10, 30), c[0].toLocalDate()) + assertEquals(8, c[0].hour) + assertEquals(LocalDate.of(2026, 11, 2), c[1].toLocalDate()) + val sunday = ZonedDateTime.of(2026, 11, 1, 15, 0, 0, 0, zone).toInstant().toEpochMilli() + assertEquals(1, MailLogic.planChoices(sunday, zone).size, "tomorrow is Monday: one choice") + } + + @Test + fun `the time column`() { + val now = ZonedDateTime.of(2026, 10, 30, 12, 0, 0, 0, zone).toInstant().toEpochMilli() + fun at(y: Int, m: Int, d: Int) = ZonedDateTime.of(y, m, d, 9, 42, 0, 0, zone).toInstant().toEpochMilli() + assertEquals(MailLogic.When.TIME, MailLogic.whenOf(at(2026, 10, 30), now, zone)) + assertEquals(MailLogic.When.YESTERDAY, MailLogic.whenOf(at(2026, 10, 29), now, zone)) + assertEquals(MailLogic.When.WEEKDAY, MailLogic.whenOf(at(2026, 10, 26), now, zone)) + assertEquals(MailLogic.When.DATE, MailLogic.whenOf(at(2026, 3, 1), now, zone)) + assertEquals(MailLogic.When.DATE_YEAR, MailLogic.whenOf(at(2025, 12, 31), now, zone)) + assertEquals(MailLogic.When.TIME, MailLogic.whenOf(now + 3_600_000, now, zone), "a date ahead (clock skew) shows its time") + } + + @Test + fun `addresses - with names, lists, rest`() { + assertEquals(MAddress("Tim Brandt", "tim@firma.de"), MailLogic.parseAddress("Tim Brandt ")) + assertEquals(MAddress("Tim", "tim@firma.de"), MailLogic.parseAddress("\"Tim\" ")) + assertEquals(MAddress(null, "a@b.de"), MailLogic.parseAddress(" a@b.de ")) + assertNull(MailLogic.parseAddress("no address")) + assertNull(MailLogic.parseAddress("a@b")) + val p = MailLogic.parseRecipients("a@b.de, Tim ; c@d.de e@f.de, Sab") + assertEquals(listOf("a@b.de", "tim@firma.de", "c@d.de", "e@f.de"), p.addresses.map { it.email }) + assertEquals("Sab", p.rest) + assertEquals(1, MailLogic.parseRecipients("a@b.de, A@B.de").addresses.size, "the same address once") + } + + @Test + fun `initials, colors, quota`() { + assertEquals("SK", MailLogic.initials("Sabine Keller", "s@k.de")) + assertEquals("M", MailLogic.initials("Mama", "m@x.de")) + assertEquals("S", MailLogic.initials(null, "stadtwerke@x.de")) + assertEquals("?", MailLogic.initials("", "")) + assertEquals(0xFFF5B544L, MailLogic.color("#F5B544")) + assertNull(MailLogic.color("F5B544")) + assertNull(MailLogic.color("#GGGGGG")) + assertEquals("#79B8FF", MailLogic.nextColor(listOf("#f5b544"))) + assertEquals(MailLogic.COLORS[0], MailLogic.nextColor(MailLogic.COLORS)) + assertEquals(50, MailLogic.quotaPercent(50, 100)) + assertEquals(100, MailLogic.quotaPercent(500, 100)) + assertNull(MailLogic.quotaPercent(5, 0)) + assertEquals(72, MailLogic.percent(0.72)) + } + + @Test + fun `Drive files automatically only when sure`() { + fun sug(reason: String, c: Double) = MSuggestion(1, null, null, null, null, c, reason) + assertTrue(MailLogic.autoFiled(sug("rule", 0.1))) + assertTrue(MailLogic.autoFiled(sug("learned", 0.1))) + assertTrue(MailLogic.autoFiled(sug("ai", 0.9))) + assertFalse(MailLogic.autoFiled(sug("ai", 0.89))) + assertTrue(MailLogic.linkFirst(46L * 1024 * 1024)) + assertFalse(MailLogic.linkFirst(340L * 1024)) + } + + @Test + fun `to-dos into a calendar`() { + val deadline = MTodo(MTodo.DEADLINE, "Zahlen", "2026-11-28", null, null, null) + val (start, allDay) = MailLogic.todoStart(deadline, zone)!! + assertTrue(allDay) + assertEquals(LocalDate.of(2026, 11, 28).atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli(), start) + val event = deadline.copy(kind = MTodo.EVENT, time = "10:00") + val (s2, all2) = MailLogic.todoStart(event, zone)!! + assertFalse(all2) + assertEquals(ZonedDateTime.of(2026, 11, 28, 10, 0, 0, 0, zone).toInstant().toEpochMilli(), s2) + assertTrue(MailLogic.calendarTodo(event)) + assertFalse(MailLogic.calendarTodo(deadline.copy(kind = MTodo.QUESTION))) + assertFalse(MailLogic.calendarTodo(deadline.copy(date = null))) + assertNull(MailLogic.todoStart(deadline.copy(date = null), zone)) + } + + @Test + fun `cid pictures point to the plugin's route`() { + val atts = listOf( + MAttachment(7, "logo.png", "image/png", 10, true, "logo@x", null, null), + MAttachment(8, "a.pdf", "application/pdf", 10, false, null, null, null), + ) + assertEquals( + "", + MailLogic.rewriteCid("", 42, atts), + ) + assertEquals("", MailLogic.rewriteCid("", 42, atts)) + } + + @Test + fun `the WebView may only load the plugin's pictures`() { + assertEquals("img/abc_DEF-1", MailLogic.interceptPath("https://mail.gatecontrol.invalid/img/abc_DEF-1")) + assertEquals("cid/42/7", MailLogic.interceptPath("https://mail.gatecontrol.invalid/cid/42/7")) + assertNull(MailLogic.interceptPath("https://evil.example/img/abc"), "another host") + assertNull(MailLogic.interceptPath("http://mail.gatecontrol.invalid/img/abc"), "not https") + assertNull(MailLogic.interceptPath("https://mail.gatecontrol.invalid/img/abc?x=1"), "no query") + assertNull(MailLogic.interceptPath("https://mail.gatecontrol.invalid/img/../me")) + assertNull(MailLogic.interceptPath("https://mail.gatecontrol.invalid/cid/0/7")) + assertNull(MailLogic.interceptPath("https://mail.gatecontrol.invalid/messages/1"), "no other route") + assertNull(MailLogic.interceptPath("not a url")) + assertTrue(MailLogic.externalLink("https://x.de")) + assertTrue(MailLogic.externalLink("mailto:a@b.de")) + assertFalse(MailLogic.externalLink("javascript:alert(1)")) + assertFalse(MailLogic.externalLink("file:///etc/passwd")) + assertFalse(MailLogic.externalLink("intent://x#Intent;end")) + assertEquals("a@b.de", MailLogic.mailtoAddress("mailto:a%40b.de?subject=x")!!.email) + assertNull(MailLogic.mailtoAddress("https://x")) + } + + @Test + fun `the page has a strict policy and the mail inside`() { + val page = MailLogic.page("

Hallo

") + assertTrue(page.contains("script-src 'none'")) + assertTrue(page.contains("img-src https://mail.gatecontrol.invalid/ data:")) + assertTrue(page.contains("

Hallo

")) + } + + @Test + fun `a draft's body - typed text and quote apart and together`() { + val html = MailLogic.composeHtml("Hallo \nbis morgen", "
alt
") + assertTrue(html.contains("Hallo <Tim>
bis morgen")) + assertEquals("Hallo \nbis morgen" to "
alt
", MailLogic.splitDraft(html)) + // the server's prefilled answer: all quote + assertEquals("" to "
alt
", MailLogic.splitDraft("
alt
")) + // another app's draft: all text + assertEquals("Eins\nZwei", MailLogic.splitDraft("

Eins

Zwei

").first) + assertEquals("a & b", MailLogic.htmlToText("a & b")) + assertEquals("Hi\n\n-- \nGruß", MailLogic.withSignature("Hi", "Gruß")) + assertEquals("Hi", MailLogic.withSignature("Hi", " ")) + assertEquals("Hi\n\n-- \nGruß", MailLogic.withSignature("Hi\n\n-- \nGruß", "Gruß"), "not twice") + assertEquals("Danke!\n\nText", MailLogic.withReply("Danke!", "Text")) + assertEquals("Danke!", MailLogic.withReply("Danke!", " ")) + } + + @Test + fun `attachments - type labels and file names`() { + assertEquals("PDF", MailLogic.typeLabel("x.pdf", "application/octet-stream")) + assertEquals("XLS", MailLogic.typeLabel("Preisliste.xlsx", "")) + assertEquals("IMG", MailLogic.typeLabel("foto", "image/jpeg")) + assertEquals("ICS", MailLogic.typeLabel("einladung.ics", "")) + assertEquals("FILE", MailLogic.typeLabel("noext", "")) + assertEquals("a_b.pdf", MailLogic.fileName("dir/a:b.pdf")) + assertEquals("attachment", MailLogic.fileName("..")) + assertEquals("attachment", MailLogic.fileName(null)) + assertNotNull(MailLogic.isEmail("a@b.de")) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailMicrosoftTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailMicrosoftTest.kt new file mode 100644 index 00000000..d2283feb --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailMicrosoftTest.kt @@ -0,0 +1,75 @@ +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.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.net.URI +import java.net.URLDecoder + +/** Contract D: PKCE in the browser, the redirect `msauth://com.gatecontrol.client/`. */ +class MailMicrosoftTest { + + @Test + fun `PKCE - the challenge of RFC 7636, fresh verifiers`() { + assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", MailMicrosoft.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")) + val v = MailMicrosoft.newVerifier() + assertTrue(MailMicrosoft.isVerifier(v), v) + assertEquals(86, v.length) + assertNotEquals(v, MailMicrosoft.newVerifier()) + assertNotEquals(MailMicrosoft.newState(), MailMicrosoft.newState()) + assertFalse(MailMicrosoft.isVerifier("short")) + } + + @Test + fun `the redirect - Base64 SHA-1 of the signature, URL-encoded`() { + // SHA-1("abc") = a9993e36…, its Base64 has + and = + val hash = MailMicrosoft.certHash("abc".toByteArray()) + assertEquals("qZk+NkcGgWq6PiVxeFDCbJzQ2J0=", hash) + assertEquals("msauth://com.gatecontrol.client/qZk%2BNkcGgWq6PiVxeFDCbJzQ2J0%3D", MailMicrosoft.redirectUri(hash)) + } + + @Test + fun `the sign-in page`() { + val url = MailMicrosoft.authorizeUrl( + authority = "https://login.microsoftonline.com/common/", + clientId = "client-1", + scopes = listOf("openid", "offline_access", "Mail.Send"), + redirectUri = "msauth://com.gatecontrol.client/qZk%2BNkcGgWq6PiVxeFDCbJzQ2J0%3D", + state = "st", + challenge = "ch", + )!! + val uri = URI(url) + assertEquals("login.microsoftonline.com", uri.host) + assertEquals("/common/oauth2/v2.0/authorize", uri.path) + val q = uri.rawQuery.split('&').associate { it.substringBefore('=') to URLDecoder.decode(it.substringAfter('='), "UTF-8") } + assertEquals("client-1", q["client_id"]) + assertEquals("code", q["response_type"]) + assertEquals("openid offline_access Mail.Send", q["scope"]) + assertEquals("msauth://com.gatecontrol.client/qZk%2BNkcGgWq6PiVxeFDCbJzQ2J0%3D", q["redirect_uri"], "the redirect exactly as registered") + assertEquals("S256", q["code_challenge_method"]) + assertEquals("select_account", q["prompt"]) + assertTrue(MailMicrosoft.authorizeUrl("https://login.microsoftonline.com/common", "c", listOf("openid"), "r", "s", "c", loginHint = "a@b.de")!!.contains("login_hint=a%40b.de")) + assertNull(MailMicrosoft.authorizeUrl("http://login.microsoftonline.com/common", "c", listOf("openid"), "r", "s", "c"), "never without TLS") + assertNull(MailMicrosoft.authorizeUrl("https://x/common?y=1", "c", listOf("openid"), "r", "s", "c")) + } + + @Test + fun `the browser's answer`() { + val ok = MailMicrosoft.parse("msauth://com.gatecontrol.client/qZk%2BNkcGgWq6PiVxeFDCbJzQ2J0%3D?code=M.C1_abc&state=st&session_state=x")!! + assertEquals("M.C1_abc", ok.code) + assertEquals("st", ok.state) + assertNull(ok.error) + val denied = MailMicrosoft.parse("msauth://com.gatecontrol.client/h?error=access_denied&error_description=The+user+cancelled&state=st")!! + assertNull(denied.code) + assertEquals("access_denied", denied.error) + assertEquals("The user cancelled", denied.description) + assertNull(MailMicrosoft.parse("msauth://com.other.app/h?code=x&state=st"), "another app's redirect") + assertNull(MailMicrosoft.parse("https://com.gatecontrol.client/h?code=x")) + assertNull(MailMicrosoft.parse("msauth://com.gatecontrol.client/h"), "neither code nor error") + assertTrue(MailMicrosoft.isRedirect("MSAUTH://com.gatecontrol.client/x")) + assertFalse(MailMicrosoft.isRedirect(null)) + } +} diff --git a/app/src/test/java/com/gatecontrol/android/mail/MailPushCacheTest.kt b/app/src/test/java/com/gatecontrol/android/mail/MailPushCacheTest.kt new file mode 100644 index 00000000..3a4f33b8 --- /dev/null +++ b/app/src/test/java/com/gatecontrol/android/mail/MailPushCacheTest.kt @@ -0,0 +1,65 @@ +package com.gatecontrol.android.mail + +import com.gatecontrol.android.notify.model.PushMessage +import com.google.gson.JsonParser +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 + +class MailPushCacheTest { + + private fun msg(topic: String, data: String?) = PushMessage( + seq = 1, id = 1, eventId = "e", topic = topic, priority = "normal", title = "Stadtwerke", body = "Rechnung", + createdAt = null, expiresAt = null, collapseKey = null, silent = false, route = null, actions = emptyList(), dataJson = data, + ) + + @Test + fun `pushes - a new mail opens it, a failed send the outbox`() { + val m = msg("plugin:gatecontrol-mail:mail", """{"mail":{"messageId":42,"accountId":3}}""") + assertTrue(MailPush.isMailMessage(m)) + assertEquals("message:42", MailPush.destination(m)) + assertEquals(42L, MailPush.messageOf("message:42")) + assertNull(MailPush.messageOf("message:0")) + assertEquals("message:7", MailPush.destination(msg("plugin:gatecontrol-mail:mail", """{"mail":{"messageId":"7"}}"""))) + assertEquals("outbox:4", MailPush.destination(msg("plugin:gatecontrol-mail:mail_failed", """{"mail":{"outboxId":4}}"""))) + assertEquals("outbox", MailPush.destination(msg("plugin:gatecontrol-mail:mail_failed", null))) + assertTrue(MailPush.isOutbox("outbox:4")) + assertFalse(MailPush.isOutbox("message:4")) + assertNull(MailPush.destination(msg("plugin:gatecontrol-mail:mail", """{"mail":{"messageId":-1}}"""))) + assertNull(MailPush.destination(msg("plugin:gatecontrol-drive:mail", """{"mail":{"messageId":42}}""")), "another plugin") + assertNull(MailPush.destination(msg("plugin:gatecontrol-mail:mail", "not json"))) + assertTrue(MailPush.isMailTopic("plugin:gatecontrol-mail:mail")) + assertFalse(MailPush.isMailTopic("plugin:gatecontrol-mail:mail_failed")) + } + + private fun item(id: Long, date: Long) = JsonParser.parseString("""{"id":$id,"date":$date}""").asJsonObject + + @Test + fun `offline - at least 30 days of the inbox, and the newest hundred`() { + val day = 24 * 3600_000L + val now = 400 * day + val items = (1..150L).map { item(it, now - it * day / 2) } // two per day: 75 days back + val kept = MailCacheLogic.prune(items, now) + assertTrue(kept.size >= MailCacheLogic.MIN_ITEMS) + assertTrue(kept.all { it.get("id").asLong <= MailCacheLogic.MIN_ITEMS || it.get("date").asLong >= now - 30 * day }) + // few but old: kept all the same + assertEquals(3, MailCacheLogic.prune(listOf(item(1, 0), item(2, 0), item(3, 0)), now).size) + // no id: gone + assertEquals(0, MailCacheLogic.prune(listOf(JsonParser.parseString("""{"date":5}""").asJsonObject), now).size) + } + + @Test + fun `offline - pages merged, opened mails pruned`() { + val merged = MailCacheLogic.mergeRaw(listOf(item(1, 1), item(2, 2)), listOf(item(2, 20), item(3, 3))) + assertEquals(listOf(1L, 2L, 3L), merged.map { it.get("id").asLong }) + assertEquals(20L, merged[1].get("date").asLong) + val now = 100 * 24 * 3600_000L + val files = listOf("old.json" to 0L) + (1..(MailCacheLogic.MAX_MESSAGES + 2)).map { "m$it.json" to now - it } + val gone = MailCacheLogic.overflow(files, now) + assertTrue("old.json" in gone) + assertEquals(3, gone.size) + assertTrue("m${MailCacheLogic.MAX_MESSAGES + 2}.json" in gone, "the oldest above the limit") + } +} 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 74b097fb..fcf50598 100644 --- a/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt +++ b/app/src/test/java/com/gatecontrol/android/navigation/MainTabsTest.kt @@ -167,4 +167,39 @@ class MainTabsTest { assertFalse(MainTabs.hideBar(onlyStart, pages = emptyList(), hideWhenOnlyStart = true)) assertFalse(MainTabs.hideBar(listOf(Screen.Vpn.route, Screen.Chats.route), pages = listOf("network"), hideWhenOnlyStart = true)) } + + @Test + fun `Mail comes after Chats, only with its tab, and can be a page beside Start`() { + fun plugin(id: String, tab: Boolean) = com.gatecontrol.android.network.ClientPlugin(id = id, tab = if (tab) com.gatecontrol.android.network.ClientPluginTab("Mail", "M3 5h18v14H3z") else null) + assertTrue(PluginAvailability.from(listOf(plugin("gatecontrol-mail", true))).mail) + assertFalse(PluginAvailability.from(listOf(plugin("gatecontrol-mail", false))).mail, "listed without its tab: the person is not allowed") + assertFalse(PluginAvailability.from(emptyList()).mail, "no plugin: nothing changes") + val all = PluginAvailability(familieTab = true, messengerTab = true, mail = true, vault = true, drive = true) + assertEquals( + listOf(Screen.Vpn.route, Screen.Chats.route, Screen.Mail.route, Screen.Vault.route, Screen.Drive.route, Screen.Network.route, Screen.Familie.route), + MainTabs.routes(network = true, plugins = all), + ) + assertFalse(Screen.Mail.route in MainTabs.routes(network = true, plugins = all.copy(mail = false))) + assertTrue(Screen.Mail.route in MainTabs.BAR_ROUTES) + assertFalse(Screen.MailMessage.route in MainTabs.BAR_ROUTES) + assertEquals(com.gatecontrol.android.layout.StartPages.MAIL, MainTabs.pageOf(Screen.Mail.route)) + assertEquals(Screen.Mail.route, MainTabs.routeOf(com.gatecontrol.android.layout.StartPages.MAIL)) + assertEquals(com.gatecontrol.android.layout.StartPages.MAIL, MainTabs.pageFor(Screen.Mail.route)) + assertNull(MainTabs.pageFor(Screen.MailMessage.create(4)), "a mail lies on top of the tab") + assertTrue(com.gatecontrol.android.layout.StartPages.isFeature("mail")) + assertEquals(listOf(Screen.Vpn.route, Screen.Chats.route), MainTabs.visible(listOf(Screen.Vpn.route, Screen.Chats.route, Screen.Mail.route), listOf("mail"))) + } + + @Test + fun `mail routes`() { + assertEquals("mail/m/42", Screen.MailMessage.create(42)) + assertEquals("mail/compose", Screen.MailCompose.create()) + assertEquals("mail/compose?ref=42&mode=replyAll", Screen.MailCompose.create(ref = 42, mode = "replyAll")) + assertEquals("mail/compose?draft=9", Screen.MailCompose.create(draft = 9)) + assertEquals("mail/compose?account=3&to=a%40b.de", Screen.MailCompose.create(account = 3, to = "a@b.de")) + assertEquals("mail/compose?ref=1&mode=reply&reply=Danke%2C%20gern%21", Screen.MailCompose.create(ref = 1, mode = "reply", reply = "Danke, gern!")) + 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)) + } } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 52bf8e6c..aba4c6a1 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -97,7 +97,7 @@ Familie-Plugin mit Tab anbietet — **Familie**. Seit dem Messenger-Plugin Einstellungen mit der Seite daneben. ### Seiten neben Start statt Tabs -Jede Funktion, die sonst ein Tab ist (Chats, Vault, Drive, Netzwerk, +Jede Funktion, die sonst ein Tab ist (Chats, Mail, Vault, Drive, Netzwerk, Familie – soweit vorhanden), kann wahlweise **Tab** in der Leiste oder **Seite neben Start** sein. Start wird dann eine Reihe von Seiten: Start bleibt immer die erste Seite (und immer in der Leiste), rechts davon die @@ -721,6 +721,62 @@ Abgleich über die normale Server-Adresse, auch ohne aktiven Tunnel. --- + +## Mail (Plugin `gatecontrol-mail` 1.0, GateControl 1.216+) +Eigener Tab **Mail** (Tab-Id `mail`, nach Chats), nur wenn der Server das +Plugin mit Tab für die Person listet (installiert und freigeschaltet); +wie die anderen Tabs als **Seite neben Start** anheftbar. Ohne Plugin +ändert sich nichts (auch der Microsoft-Redirect ist dann abgeschaltet). + +- **Posteingang:** „Alle Posteingänge“ oder ein Konto (Chips mit dem + Farbpunkt des Kontos), **Relevant | Sonstige** (nur in Posteingängen), + Wischen nach links/rechts wie in den Mail-Einstellungen + (`archive`/`delete`/`read`/`none`, auch als Bedienungshilfen-Aktionen), + lange drücken: gelesen, markieren, Relevant/Sonstige (lernt pro + Absender), archivieren, verschieben, Spam, löschen. „Neue Mail“ unten + rechts; nach dem Senden 10 s die Leiste **Rückgängig** (Server-Fenster + `undoUntil`, bei abweichender Uhr 10 s ab Antwort). +- **Seitenmenü:** Alle Posteingänge, **Zum Ablegen** (mit Drive), + **Postausgang** (wenn etwas wartet), die Konten mit ihren Ordnern, + Speicherbelegung, „Konto hinzufügen“ und „Mail-Einstellungen“. + Kalender/Ressourcen kommen erst in Phase 2/3. +- **Lesen:** KI-Leiste (nur mit Feature `ai` und KI am Konto): „Kurz + gesagt“, Zu tun als Chips (Termine/Fristen in den Familienkalender oder + den eigenen Kalender), Antwortvorschläge über „Antworten“. Anhänge mit + Drives Vorschlag, „In Drive speichern“, „Anderer Ordner“ und „künftig + automatisch ablegen“ (nur mit Feature `drive`). „Bilder laden“ / + „Immer für diesen Absender“ (Feature `images`). Unterhaltung, Abmelden + (List-Unsubscribe). +- **HTML-Anzeige:** WebView ohne JavaScript, ohne Datei-/Content-Zugriff, + ohne Speicher und Fenster, mit Content-Security-Policy; alle Anfragen + werden abgefangen: nur `img/` (JSON mit Base64, ≤ 700 KB) und + `cid//` (JSON mit `mediaId`, Bild aus der Plugin-Media) lädt + die App mit ihrer Anmeldung über die Plugin-API (`cid:` wird + umgeschrieben); nur Rasterbilder, nie SVG; alles andere bleibt leer. Links öffnen außerhalb der App, `mailto:` im + Schreiben-Bildschirm. +- **Schreiben:** Absender-Konto wählbar, Empfänger mit Vorschlägen + (`GET contacts`), Cc/Bcc, Anhänge vom Gerät (≤ 25 MB, Plugin-Media) oder + „Aus Drive“ (Drive-Suche, je Datei Kopie oder Link, Link 14 Tage), + Warnungen des Servers, Entwurf wird laufend gespeichert, „Senden planen“ + (lange auf Senden drücken oder Menü). Antworten/Weiterleiten lässt den + Server Empfänger, Betreff und Zitat vorfüllen; die Signatur des Kontos + setzt die App oben in neue Mails, Antworten und Weiterleitungen. + Microsoft-Konten: Gerätedateien und Anhänge aus Mails anderer Konten + gehen nicht (Warnung, Senden → `attachments_unsupported` mit Erklärung). +- **Konto hinzufügen:** Anbieterliste des Servers, Formular mit Hinweis des + Anbieters, bei „Andere / eigene Domain“ IMAP/SMTP von Hand (nur TLS bzw. + STARTTLS). **Microsoft:** Autorisierungscode mit PKCE im Custom Tab, + Redirect `msauth://com.gatecontrol.client/` (zur Laufzeit berechnet, `MailSigninActivity`), der Server + löst den Code ein. Ohne Client-ID: „noch nicht eingerichtet“. +- **Push:** Topic `mail` (Kanal „Mail“) öffnet die Mail, `mail_failed` + den Postausgang. Realtime-Event `mail` lädt offene Listen neu. +- **Offline:** zuletzt geladene Listen (Posteingang mindestens 30 Tage, + wenigstens die neuesten 100) und geöffnete Mails (30 Tage, höchstens 500) + liegen pro Anmeldung in `noBackupFilesDir/mail`; sie werden gelöscht, + wenn das Plugin verschwindet, sich Server/Token ändern oder der Server + 401 antwortet (Fernlöschen), ebenso geöffnete Anhänge (`cache/mail-open`). + ## Einstellungen Seit 1.35 kein Tab mehr: das Zahnrad oben rechts im Profil öffnet sie (mit