From 12d11663b02c8cecfb0fa53397787c93bb0c86ec Mon Sep 17 00:00:00 2001 From: ElCruncharino <59633028+ElCruncharino@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:44:48 -0400 Subject: [PATCH] Fix ViewModel polling coroutines leaking past onCleared TorrentDetailsViewModel.pollTorrentStatus and TorrentProcessingViewModel.startSelectionLoop each built their own CoroutineScope backed by a plain Job, completely separate from the ViewModel lifecycle. When the user navigated away before the loop's own stop condition fired, the coroutine kept running because nothing ever cancelled it (there is no onCleared override anywhere in the codebase). Both loops now launch on viewModelScope instead, so clearing the ViewModel cancels them automatically. The job field is kept as a nullable handle purely so a new call can cancel a previous in-flight loop and so the loop can stop itself once the tracked status reaches an end state, same as before. --- .../viewmodel/TorrentDetailsViewModel.kt | 25 +++---- .../viewmodel/TorrentProcessingViewModel.kt | 67 +++++++++---------- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt index 4595e1cc0..ad03cc294 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentdetails/viewmodel/TorrentDetailsViewModel.kt @@ -15,7 +15,6 @@ import com.github.livingwithhippos.unchained.utilities.extension.cancelIfActive import com.github.livingwithhippos.unchained.utilities.postEvent import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -37,7 +36,7 @@ constructor( val downloadLiveData = MutableLiveData>() val errorsLiveData = MutableLiveData>>() - private var job = Job() + private var job: Job? = null fun getFullTorrentInfo(id: String) { viewModelScope.launch { @@ -48,21 +47,19 @@ constructor( fun pollTorrentStatus(id: String) { // todo: test if I need to recreate a job when it is cancelled - job.cancelIfActive() - job = Job() + job?.cancelIfActive() - val scope = CoroutineScope(job + Dispatchers.IO) + job = + viewModelScope.launch(Dispatchers.IO) { + // / maybe job.isActive? + while (isActive) { + val torrentData = torrentsRepository.getTorrentInfo(id) + if (torrentData != null) torrentLiveData.postEvent(torrentData) + if (endedStatusList.contains(torrentData?.status)) job?.cancelIfActive() - scope.launch { - // / maybe job.isActive? - while (isActive) { - val torrentData = torrentsRepository.getTorrentInfo(id) - if (torrentData != null) torrentLiveData.postEvent(torrentData) - if (endedStatusList.contains(torrentData?.status)) job.cancelIfActive() - - delay(2000.milliseconds) + delay(2000.milliseconds) + } } - } } fun deleteTorrent(id: String) { diff --git a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt index 1dcc327ff..ec08239b5 100644 --- a/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt +++ b/app/app/src/main/java/com/github/livingwithhippos/unchained/torrentfilepicker/viewmodel/TorrentProcessingViewModel.kt @@ -18,7 +18,6 @@ import com.github.livingwithhippos.unchained.utilities.extension.cancelIfActive import com.github.livingwithhippos.unchained.utilities.postEvent import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -39,7 +38,7 @@ constructor( val torrentLiveData = MutableLiveData>() val structureLiveData = MutableLiveData>>() - private var job = Job() + private var job: Job? = null fun fetchAddedMagnet(magnet: String) { viewModelScope.launch { @@ -103,48 +102,46 @@ constructor( return } - job.cancelIfActive() - job = Job() - - val scope = CoroutineScope(job + Dispatchers.IO) - - scope.launch { - var selected = false - // / maybe job.isActive? - while (isActive) { - if (!selected) { - when (val selectResponse = torrentsRepository.selectFiles(id, files)) { - is EitherResult.Failure -> { - if (selectResponse.failure is EmptyBodyError) { - Timber.d( - "Select torrent files success returned ${selectResponse.failure.returnCode}" - ) + job?.cancelIfActive() + + job = + viewModelScope.launch(Dispatchers.IO) { + var selected = false + // / maybe job.isActive? + while (isActive) { + if (!selected) { + when (val selectResponse = torrentsRepository.selectFiles(id, files)) { + is EitherResult.Failure -> { + if (selectResponse.failure is EmptyBodyError) { + Timber.d( + "Select torrent files success returned ${selectResponse.failure.returnCode}" + ) + selected = true + } else { + Timber.e( + "Exception during torrent files selection call: ${selectResponse.failure}" + ) + } + } + is EitherResult.Success -> { + Timber.d("Select torrent files success") selected = true - } else { - Timber.e( - "Exception during torrent files selection call: ${selectResponse.failure}" - ) } } - is EitherResult.Success -> { - Timber.d("Select torrent files success") - selected = true - } } - } - if (selected) { - val torrentItem: TorrentItem? = torrentsRepository.getTorrentInfo(id) - if (torrentItem != null) { - if (!beforeSelectionStatusList.contains(torrentItem.status)) { - job.cancelIfActive() - torrentLiveData.postEvent(TorrentEvent.FilesSelected(torrentItem)) + if (selected) { + val torrentItem: TorrentItem? = torrentsRepository.getTorrentInfo(id) + if (torrentItem != null) { + if (!beforeSelectionStatusList.contains(torrentItem.status)) { + job?.cancelIfActive() + torrentLiveData.postEvent(TorrentEvent.FilesSelected(torrentItem)) + } } } + delay(1500.milliseconds) } - delay(1500.milliseconds) } - } } fun triggerTorrentEvent(event: TorrentEvent) {