Add instant push via a foreground IMAP IDLE service

Increment 7 — IMAP IDLE push.

- ImapClient.idle() holds a long-lived IMAP connection in IDLE. The server pushes
  new-mail notifications during the blocking idle() call, which Jakarta dispatches to a
  MessageCountListener (idle() does not itself return), so each push is forwarded to a
  sync via a conflated channel. It syncs once on connect to catch up, and closes the
  store from the cancellation handler to unblock idle().
- IdleService: a dataSync foreground service running one reconnecting IDLE loop per
  account (exponential backoff) that triggers MailSyncer on each push, with an ongoing
  "Watching for new mail" status notification.
- IdlePushManager starts/stops the service; LibreMailApplication observes the pushIdle
  setting (the existing Advanced toggle) and reacts. Adds FOREGROUND_SERVICE and
  FOREGROUND_SERVICE_DATA_SYNC permissions plus the service declaration.
- assemble/test/lint green; verified on the Android 17 emulator against GreenMail —
  delivering a message while the app idled pushed an on-device notification within ~2s,
  with no polling and no user action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:11:33 -05:00
co-authored by Claude Opus 4.8
parent 40b0d9b3ad
commit 92e5005474
7 changed files with 258 additions and 19 deletions
+4 -3
View File
@@ -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)
+8
View File
@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".LibreMailApplication"
@@ -39,5 +41,11 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>
<!-- Holds a long-lived IMAP IDLE connection for instant push (opt-in via Advanced Settings). -->
<service
android:name=".push.IdleService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -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() }
}
}
}
@@ -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<Unit>(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 <T> 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"
}
}
@@ -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)) }
}
}
@@ -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
}
}
+3
View File
@@ -62,6 +62,9 @@
<!-- Notifications -->
<string name="notif_channel_new_mail">New mail</string>
<string name="notif_new_mail_count">%1$d new messages</string>
<string name="notif_channel_push_status">Push (IMAP IDLE)</string>
<string name="notif_push_status_title">Watching for new mail</string>
<string name="notif_push_status_text">Connected for instant delivery</string>
<!-- Settings -->
<string name="settings_accounts">Accounts</string>