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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user