diff --git a/README.md b/README.md index b412f97..7c745b1 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ experience with power-user features tucked under an **Advanced Settings** group. > Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and > rendered in a hardened WebView (JavaScript off, remote images blocked by default), > with mark-read, star, and delete; and **composing** — a compose screen with device- -> contacts autocomplete that sends over SMTP, plus reply; and **on-device new-mail -> notifications** (no push service) with persisted settings. Outlook Graph send, IMAP -> IDLE push, attachments, and multi-account polish are upcoming. +> contacts autocomplete that sends over SMTP, plus reply; **on-device new-mail +> notifications** (no push service) with persisted settings; and **instant push** via a +> foreground **IMAP IDLE** service. Outlook Graph send, attachments, and multi-account +> polish are upcoming. ## Features (target MVP) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index afe04fb..e9a012d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index ce5597c..facdb01 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -6,7 +6,15 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.libremail.data.settings.SettingsRepository import org.libremail.data.sync.SyncScheduler +import org.libremail.push.IdlePushManager @HiltAndroidApp class LibreMailApplication : Application(), Configuration.Provider { @@ -15,6 +23,12 @@ class LibreMailApplication : Application(), Configuration.Provider { @Inject lateinit var syncScheduler: SyncScheduler + @Inject lateinit var settingsRepository: SettingsRepository + + @Inject lateinit var idlePushManager: IdlePushManager + + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -23,5 +37,12 @@ class LibreMailApplication : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() syncScheduler.schedulePeriodicSync() + // Start or stop the IMAP IDLE push service to match the user's preference, reactively. + appScope.launch { + settingsRepository.settings + .map { it.pushIdle } + .distinctUntilChanged() + .collect { enabled -> if (enabled) idlePushManager.start() else idlePushManager.stop() } + } } } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 5590a58..11d36d9 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.mail +import android.util.Log import jakarta.mail.FetchProfile import jakarta.mail.Flags import jakarta.mail.Folder @@ -9,12 +10,20 @@ import jakarta.mail.Part import jakarta.mail.Session import jakarta.mail.Store import jakarta.mail.UIDFolder +import jakarta.mail.event.MessageCountAdapter +import jakarta.mail.event.MessageCountEvent import jakarta.mail.internet.InternetAddress import java.util.Properties import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.eclipse.angus.mail.imap.IMAPFolder import org.libremail.domain.model.ImapConnectionParams import org.libremail.domain.model.MailSecurity @@ -130,6 +139,54 @@ class ImapClient @Inject constructor() { } } + /** + * Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The + * server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them + * to the message-count listener (not by returning from idle()), so we forward each push to + * [onActivity] via a conflated channel. Runs until the coroutine is cancelled (which closes the + * connection to unblock idle()) or a connection error is thrown, leaving reconnection to the caller. + */ + suspend fun idle(params: ImapConnectionParams, onActivity: suspend () -> Unit) = + withContext(Dispatchers.IO) { + val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) + store.connect(params.host, params.port, params.username, params.secret) + val inbox = store.getFolder("INBOX") as IMAPFolder + inbox.open(Folder.READ_ONLY) + Log.d(TAG, "IDLE connected for ${params.username}") + + val pushes = Channel(Channel.CONFLATED) + inbox.addMessageCountListener(object : MessageCountAdapter() { + override fun messagesAdded(event: MessageCountEvent) { + Log.d(TAG, "IDLE push: ${event.messages.size} new message(s)") + pushes.trySend(Unit) + } + }) + + coroutineScope { + val syncer = launch { + for (signal in pushes) onActivity() + } + // Closing the store from the cancellation handler unblocks the blocking idle() below. + val handle = coroutineContext.job.invokeOnCompletion { runCatching { store.close() } } + // Sync once on connect to catch anything that arrived before IDLE was established. + pushes.trySend(Unit) + try { + while (isActive) { + inbox.idle() + } + } catch (e: Exception) { + if (isActive) throw e // a real connection error: let the caller reconnect + } finally { + handle.dispose() + pushes.close() + syncer.cancel() + runCatching { inbox.close(false) } + runCatching { store.close() } + } + } + } + /** Recursively finds the best body part: HTML preferred, plain text otherwise. */ private fun extractBody(part: Part): MessageContent? { if (part.isMimeType("text/html")) return MessageContent(part.content.toString(), isHtml = true) @@ -151,22 +208,7 @@ class ImapClient @Inject constructor() { private inline fun withStore(params: ImapConnectionParams, block: (Store) -> T): T { val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap" - val props = Properties().apply { - put("mail.store.protocol", protocol) - put("mail.$protocol.host", params.host) - put("mail.$protocol.port", params.port.toString()) - put("mail.$protocol.connectiontimeout", TIMEOUT_MS) - put("mail.$protocol.timeout", TIMEOUT_MS) - put("mail.$protocol.writetimeout", TIMEOUT_MS) - if (params.security == MailSecurity.STARTTLS) { - put("mail.$protocol.starttls.enable", "true") - put("mail.$protocol.starttls.required", "true") - } - if (params.useXoauth2) { - put("mail.$protocol.auth.mechanisms", "XOAUTH2") - } - } - val store = Session.getInstance(props).getStore(protocol) + val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol) store.connect(params.host, params.port, params.username, params.secret) return try { block(store) @@ -175,7 +217,24 @@ class ImapClient @Inject constructor() { } } + private fun buildProps(protocol: String, params: ImapConnectionParams): Properties = Properties().apply { + put("mail.store.protocol", protocol) + put("mail.$protocol.host", params.host) + put("mail.$protocol.port", params.port.toString()) + put("mail.$protocol.connectiontimeout", TIMEOUT_MS) + put("mail.$protocol.timeout", TIMEOUT_MS) + put("mail.$protocol.writetimeout", TIMEOUT_MS) + if (params.security == MailSecurity.STARTTLS) { + put("mail.$protocol.starttls.enable", "true") + put("mail.$protocol.starttls.required", "true") + } + if (params.useXoauth2) { + put("mail.$protocol.auth.mechanisms", "XOAUTH2") + } + } + private companion object { const val TIMEOUT_MS = "15000" + const val TAG = "LibreMailIdle" } } diff --git a/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt new file mode 100644 index 0000000..25f2089 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/IdlePushManager.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** Starts and stops [IdleService] to match the user's push-mail (IMAP IDLE) preference. */ +@Singleton +class IdlePushManager @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun start() { + // Starting a foreground service from a background process is disallowed on modern Android; + // swallow that case — periodic WorkManager sync still covers mail, and IDLE starts the next + // time the app is in the foreground. + runCatching { + ContextCompat.startForegroundService(context, Intent(context, IdleService::class.java)) + } + } + + fun stop() { + runCatching { context.stopService(Intent(context, IdleService::class.java)) } + } +} diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt new file mode 100644 index 0000000..0484581 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.libremail.R +import org.libremail.data.local.dao.AccountDao +import org.libremail.data.local.toDomain +import org.libremail.data.sync.MailConnectionFactory +import org.libremail.data.sync.MailSyncer +import org.libremail.domain.model.Account +import org.libremail.mail.ImapClient + +/** + * Foreground service that holds a long-lived IMAP IDLE connection per account so the server can + * push new mail to us instantly — no polling, and no third-party push service. When IDLE reports + * activity we run a normal sync, which writes to Room and fires the new-mail notification. + */ +@AndroidEntryPoint +class IdleService : Service() { + + @Inject lateinit var accountDao: AccountDao + @Inject lateinit var connectionFactory: MailConnectionFactory + @Inject lateinit var imapClient: ImapClient + @Inject lateinit var mailSyncer: MailSyncer + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var watching = false + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startAsForeground() + if (!watching) { + watching = true + scope.launch { watchAllAccounts() } + } + return START_STICKY + } + + private suspend fun watchAllAccounts() { + val accounts = accountDao.getAll().map { it.toDomain() } + if (accounts.isEmpty()) { + stopSelf() + return + } + accounts.forEach { account -> scope.launch { watchAccount(account) } } + } + + /** Holds IDLE for one account, reconnecting with exponential backoff whenever it drops. */ + private suspend fun watchAccount(account: Account) { + var backoffMs = INITIAL_BACKOFF_MS + while (scope.isActive) { + try { + val params = connectionFactory.imapParamsFor(account) + imapClient.idle(params) { mailSyncer.syncAll() } + backoffMs = INITIAL_BACKOFF_MS + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startAsForeground() { + NotificationManagerCompat.from(this).createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.notif_channel_push_status), + NotificationManager.IMPORTANCE_LOW, + ), + ) + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle(getString(R.string.notif_push_status_title)) + .setContentText(getString(R.string.notif_push_status_text)) + .setOngoing(true) + .setShowWhen(false) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + ServiceCompat.startForeground( + this, + FOREGROUND_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private companion object { + const val CHANNEL_ID = "push_status" + const val FOREGROUND_ID = 1002 + const val INITIAL_BACKOFF_MS = 5_000L + const val MAX_BACKOFF_MS = 5 * 60_000L + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2008b4..63b68b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,9 @@ New mail %1$d new messages + Push (IMAP IDLE) + Watching for new mail + Connected for instant delivery Accounts