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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 15:09:27 -05:00
co-authored by Claude Opus 4.8
parent ee0bbacb98
commit 1d918cdd75
2 changed files with 34 additions and 11 deletions
@@ -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() }
}
}
}
@@ -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<String, Job>()
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) } }
}
/**