From 1d918cdd75cf61b39f876f40d47fb8277b89e450 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 15:09:27 -0500 Subject: [PATCH] Stop an account's IDLE watcher when the account is removed IdleService read the account list once at startup, so a removed account's IDLE loop kept reconnecting (backing off harmlessly) until the service restarted, and a newly-added account wasn't watched until then either. - IdleService now observes the accounts and reconciles one IDLE watcher per account: it starts a watcher for an added account and cancels the watcher for a removed one, which closes that account's IDLE connection promptly via the existing cancellation path. - The app runs the service only while push is enabled AND at least one account exists, so it auto-starts on the first account and stops on the last. - Verified on the Android 17 emulator: two accounts held two IDLE connections; removing one dropped to a single connection with no retry loop, and the other kept idling. Co-Authored-By: Claude Opus 4.8 --- .../org/libremail/LibreMailApplication.kt | 16 +++++++--- .../kotlin/org/libremail/push/IdleService.kt | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index facdb01..01395c2 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -9,11 +9,13 @@ import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine 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.domain.repository.AccountRepository import org.libremail.push.IdlePushManager @HiltAndroidApp @@ -25,6 +27,8 @@ class LibreMailApplication : Application(), Configuration.Provider { @Inject lateinit var settingsRepository: SettingsRepository + @Inject lateinit var accountRepository: AccountRepository + @Inject lateinit var idlePushManager: IdlePushManager private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -37,12 +41,16 @@ 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. + // Run the IMAP IDLE push service only while it has something to do: the push setting is on + // AND at least one account exists. This starts it when the first account is added and stops + // it when the last is removed, reactively. appScope.launch { - settingsRepository.settings - .map { it.pushIdle } + combine( + settingsRepository.settings.map { it.pushIdle }, + accountRepository.observeAccounts().map { it.isNotEmpty() }, + ) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts } .distinctUntilChanged() - .collect { enabled -> if (enabled) idlePushManager.start() else idlePushManager.stop() } + .collect { active -> if (active) idlePushManager.start() else idlePushManager.stop() } } } } diff --git a/app/src/main/kotlin/org/libremail/push/IdleService.kt b/app/src/main/kotlin/org/libremail/push/IdleService.kt index 3c2b9b9..46247ff 100644 --- a/app/src/main/kotlin/org/libremail/push/IdleService.kt +++ b/app/src/main/kotlin/org/libremail/push/IdleService.kt @@ -15,9 +15,11 @@ import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -45,22 +47,35 @@ class IdleService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var watching = false + /** Active IDLE watcher per account id, so we can start/stop them as accounts change. */ + private val watchers = mutableMapOf() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { startAsForeground() if (!watching) { watching = true - scope.launch { watchAllAccounts() } + scope.launch { reconcileWatchers() } } return START_STICKY } - private suspend fun watchAllAccounts() { - val accounts = accountDao.getAll().map { it.toDomain() } - if (accounts.isEmpty()) { - stopSelf() - return + /** + * Observes the account list and keeps one IDLE watcher per account: a watcher is started for a + * newly-added account and cancelled when its account is removed (which promptly closes that + * account's IDLE connection). The service is started/stopped by the app based on whether any + * accounts exist, so reaching zero here is just a transient state. + */ + private suspend fun reconcileWatchers() { + accountDao.observeAll().collect { entities -> + val accounts = entities.map { it.toDomain() } + val currentIds = accounts.mapTo(mutableSetOf()) { it.id } + (watchers.keys - currentIds).forEach { id -> watchers.remove(id)?.cancel() } + accounts.forEach { account -> + if (account.id !in watchers) { + watchers[account.id] = scope.launch { watchAccount(account) } + } + } } - accounts.forEach { account -> scope.launch { watchAccount(account) } } } /**