From bd1d5e9099c0653a195c4a5a2879c751e675fc64 Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Wed, 7 Jan 2026 12:13:45 +0500 Subject: [PATCH 1/5] refactor(download): Skip download if file already exists and improve cleanup This commit introduces several enhancements to the download and cleanup logic: - Checks if a file already exists before starting a download. If it does, the download is skipped, and the existing file is used for installation. - Adds new methods to the `Downloader` interface (`listDownloadedFiles`, `getLatestDownload`, `getLatestDownloadForAssets`) to query downloaded files. These are implemented for both Android and Desktop. - Refactors the `onCleared` method in `DetailsViewModel` to perform a more robust cleanup. It now cancels the current download job and attempts to delete all partially downloaded files from the downloads directory upon screen exit. - Removes an unnecessary comment in `DetailsAction`. --- .../core/data/services/AndroidDownloader.kt | 33 +++++++ .../core/data/services/Downloader.kt | 7 ++ .../core/domain/model/DownloadedFile.kt | 8 ++ .../details/presentation/DetailsAction.kt | 2 - .../details/presentation/DetailsState.kt | 2 +- .../details/presentation/DetailsViewModel.kt | 88 ++++++++++++++----- .../core/data/services/DesktopDownloader.kt | 33 +++++++ 7 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/domain/model/DownloadedFile.kt diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt index c72299b5f..9b1e9bafa 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.flowOn import zed.rainxch.githubstore.feature.details.domain.model.DownloadProgress import java.util.concurrent.ConcurrentHashMap import androidx.core.net.toUri +import zed.rainxch.githubstore.core.domain.model.DownloadedFile class AndroidDownloader( private val context: Context, @@ -174,4 +175,36 @@ class AndroidDownloader( cancelled || deleted } + + override suspend fun listDownloadedFiles(): List = withContext(Dispatchers.IO) { + val dir = File(files.appDownloadsDir()) + if (!dir.exists()) return@withContext emptyList() + + dir.listFiles() + ?.filter { it.isFile && it.length() > 0 } + ?.map { file -> + DownloadedFile( + fileName = file.name, + filePath = file.absolutePath, + fileSizeBytes = file.length(), + downloadedAt = file.lastModified() + ) + } + ?.sortedByDescending { it.downloadedAt } + ?: emptyList() + } + + override suspend fun getLatestDownload(): DownloadedFile? = withContext(Dispatchers.IO) { + listDownloadedFiles().firstOrNull() + } + + override suspend fun getLatestDownloadForAssets(assetNames: List): DownloadedFile? = + withContext(Dispatchers.IO) { + listDownloadedFiles() + .firstOrNull { downloadedFile -> + assetNames.any { assetName -> + downloadedFile.fileName == assetName + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt index 41230f845..712ce9579 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt @@ -1,6 +1,7 @@ package zed.rainxch.githubstore.core.data.services import kotlinx.coroutines.flow.Flow +import zed.rainxch.githubstore.core.domain.model.DownloadedFile import zed.rainxch.githubstore.feature.details.domain.model.DownloadProgress interface Downloader { @@ -12,4 +13,10 @@ interface Downloader { suspend fun getDownloadedFilePath(fileName: String): String? suspend fun cancelDownload(fileName: String): Boolean + + suspend fun listDownloadedFiles(): List + + suspend fun getLatestDownload(): DownloadedFile? + + suspend fun getLatestDownloadForAssets(assetNames: List): DownloadedFile? } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/domain/model/DownloadedFile.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/domain/model/DownloadedFile.kt new file mode 100644 index 000000000..9b799a638 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/domain/model/DownloadedFile.kt @@ -0,0 +1,8 @@ +package zed.rainxch.githubstore.core.domain.model + +data class DownloadedFile( + val fileName: String, + val filePath: String, + val fileSizeBytes: Long, + val downloadedAt: Long +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsAction.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsAction.kt index b3c5c4a22..ad928e2d6 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsAction.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsAction.kt @@ -20,8 +20,6 @@ sealed interface DetailsAction { data object OnToggleInstallDropdown : DetailsAction data object OnNavigateBackClick : DetailsAction - - // NEW ACTIONS data object OnToggleFavorite : DetailsAction data object CheckForUpdates : DetailsAction data object UpdateApp : DetailsAction diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsState.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsState.kt index d1f03ff54..3679cf79a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsState.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsState.kt @@ -51,7 +51,7 @@ data class DetailsState( val isAppManagerEnabled: Boolean = false, val installedApp: InstalledApp? = null, - val isFavorite: Boolean = false + val isFavorite: Boolean = false, ) enum class DownloadStage { diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt index 85842ccc7..516d70b9b 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt @@ -7,6 +7,9 @@ import githubstore.composeapp.generated.resources.Res import githubstore.composeapp.generated.resources.added_to_favourites import githubstore.composeapp.generated.resources.installer_saved_downloads import githubstore.composeapp.generated.resources.removed_from_favourites +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel @@ -513,10 +516,9 @@ class DetailsViewModel( assetName = assetName, size = sizeBytes, tag = releaseTag, - result = if (isUpdate) { - LogResult.UpdateStarted - } else LogResult.DownloadStarted + result = if (isUpdate) LogResult.UpdateStarted else LogResult.DownloadStarted ) + _state.value = _state.value.copy( downloadError = null, installError = null, @@ -527,23 +529,38 @@ class DetailsViewModel( extOrMime = assetName.substringAfterLast('.', "").lowercase() ) - _state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING) - downloader.download(downloadUrl, assetName).collect { p -> - _state.value = _state.value.copy(downloadProgressPercent = p.percent) - if (p.percent == 100) { - _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) + val existingFilePath = downloader.getDownloadedFilePath(assetName) + + val filePath = if (existingFilePath != null) { + Logger.d { "File already exists, skipping download: $existingFilePath" } + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = LogResult.Downloaded + ) + existingFilePath + } else { + _state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING) + downloader.download(downloadUrl, assetName).collect { p -> + _state.value = _state.value.copy(downloadProgressPercent = p.percent) + if (p.percent == 100) { + _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) + } } - } - val filePath = downloader.getDownloadedFilePath(assetName) - ?: throw IllegalStateException("Downloaded file not found") + val downloadedPath = downloader.getDownloadedFilePath(assetName) + ?: throw IllegalStateException("Downloaded file not found") - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = LogResult.Downloaded - ) + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = LogResult.Downloaded + ) + + downloadedPath + } _state.value = _state.value.copy(downloadStage = DownloadStage.INSTALLING) val ext = assetName.substringAfterLast('.', "").lowercase() @@ -575,9 +592,7 @@ class DetailsViewModel( assetName = assetName, size = sizeBytes, tag = releaseTag, - result = if (isUpdate) { - LogResult.Updated - } else LogResult.Installed + result = if (isUpdate) LogResult.Updated else LogResult.Installed ) } catch (t: Throwable) { @@ -785,11 +800,36 @@ class DetailsViewModel( override fun onCleared() { super.onCleared() - currentDownloadJob?.cancel() - currentAssetName?.let { assetName -> - viewModelScope.launch { - downloader.cancelDownload(assetName) + CoroutineScope(Dispatchers.IO).launch { + launch { + currentDownloadJob?.cancel() + currentDownloadJob = null + } + + launch { + try { + val allFiles = downloader.listDownloadedFiles() + + Logger.d { "Starting cleanup of ${allFiles.size} files" } + + allFiles.forEach { file -> + try { + val deleted = downloader.cancelDownload(file.fileName) + if (deleted) { + Logger.d { "✓ Cleaned up file on screen exit: ${file.fileName}" } + } else { + Logger.w { "✗ Failed to delete file: ${file.fileName}" } + } + } catch (e: Exception) { + Logger.e { "✗ Error deleting ${file.fileName}: ${e.message}" } + } + } + + Logger.d { "Cleanup complete - all files processed" } + } catch (t: Throwable) { + Logger.e { "Failed to cleanup downloads: ${t.message}" } + } } } } diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt index 1c01907da..0956e7b85 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext +import zed.rainxch.githubstore.core.domain.model.DownloadedFile import zed.rainxch.githubstore.feature.details.domain.model.DownloadProgress import java.io.File import java.io.FileOutputStream @@ -128,6 +129,38 @@ class DesktopDownloader( } } + override suspend fun listDownloadedFiles(): List = withContext(Dispatchers.IO) { + val dir = File(files.userDownloadsDir()) + if (!dir.exists()) return@withContext emptyList() + + dir.listFiles() + ?.filter { it.isFile && it.length() > 0 } + ?.map { file -> + DownloadedFile( + fileName = file.name, + filePath = file.absolutePath, + fileSizeBytes = file.length(), + downloadedAt = file.lastModified() + ) + } + ?.sortedByDescending { it.downloadedAt } + ?: emptyList() + } + + override suspend fun getLatestDownload(): DownloadedFile? = withContext(Dispatchers.IO) { + listDownloadedFiles().firstOrNull() + } + + override suspend fun getLatestDownloadForAssets(assetNames: List): DownloadedFile? = + withContext(Dispatchers.IO) { + listDownloadedFiles() + .firstOrNull { downloadedFile -> + assetNames.any { assetName -> + downloadedFile.fileName == assetName + } + } + } + companion object { private const val DEFAULT_BUFFER_SIZE = 8 * 1024 } From 8160d88e8e3fc9b75066dc93b14403a876ecc402 Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Wed, 7 Jan 2026 12:32:37 +0500 Subject: [PATCH 2/5] feat(download): Clean up leftover download files on startup Moves the download cleanup logic from `onCleared()` to the initial `load()` function in `DetailsViewModel`. This ensures that any leftover or incomplete download files from a previous session are cleared when the view model is first initialized, rather than waiting for it to be destroyed. The cleanup now runs in a separate coroutine on the IO dispatcher to avoid blocking the main data loading flow. --- .../details/presentation/DetailsViewModel.kt | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt index 516d70b9b..5324c044a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt @@ -84,6 +84,35 @@ class DetailsViewModel( try { _state.value = _state.value.copy(isLoading = true, errorMessage = null) + launch(Dispatchers.IO) { + try { + val allFiles = downloader.listDownloadedFiles() + if (allFiles.isNotEmpty()) { + Logger.d { "Cleaning up ${allFiles.size} leftover files from previous session" } + + allFiles.forEach { file -> + try { + val deleted = downloader.cancelDownload(file.fileName) + if (deleted) { + Logger.d { "✓ Cleaned up leftover file: ${file.fileName}" } + } else { + Logger.w { "✗ Failed to delete file: ${file.fileName}" } + } + } catch (e: Exception) { + Logger.e { "✗ Error deleting ${file.fileName}: ${e.message}" } + } + } + + Logger.d { "Cleanup complete - all leftover files processed" } + } else { + Logger.d { "No leftover files to clean up" } + } + } catch (t: Throwable) { + Logger.e { "Failed to cleanup leftover files: ${t.message}" } + } + } + + val syncResult = syncInstalledAppsUseCase() if (syncResult.isFailure) { Logger.w { "Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}" } @@ -801,37 +830,8 @@ class DetailsViewModel( override fun onCleared() { super.onCleared() - CoroutineScope(Dispatchers.IO).launch { - launch { - currentDownloadJob?.cancel() - currentDownloadJob = null - } - - launch { - try { - val allFiles = downloader.listDownloadedFiles() - - Logger.d { "Starting cleanup of ${allFiles.size} files" } - - allFiles.forEach { file -> - try { - val deleted = downloader.cancelDownload(file.fileName) - if (deleted) { - Logger.d { "✓ Cleaned up file on screen exit: ${file.fileName}" } - } else { - Logger.w { "✗ Failed to delete file: ${file.fileName}" } - } - } catch (e: Exception) { - Logger.e { "✗ Error deleting ${file.fileName}: ${e.message}" } - } - } - - Logger.d { "Cleanup complete - all files processed" } - } catch (t: Throwable) { - Logger.e { "Failed to cleanup downloads: ${t.message}" } - } - } - } + currentDownloadJob?.cancel() + currentDownloadJob = null } private companion object { From f913300617a00fbded6aa934dc69d2c824ff8e2f Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Wed, 7 Jan 2026 12:39:53 +0500 Subject: [PATCH 3/5] feat(download): Add file size validation before and after download This commit introduces a file size check to prevent incomplete or corrupted downloads. The `Downloader` interface now includes a `getFileSize` method, implemented for both Android and Desktop. In the `DetailsViewModel`, this is used to: 1. Verify the size of an already existing file. If the size doesn't match the expected size from the release asset, the existing file is discarded and a re-download is initiated. 2. Validate the size of a newly downloaded file against the expected size. If there's a mismatch, an exception is thrown and the download is considered failed. --- .../core/data/services/AndroidDownloader.kt | 14 +++++++ .../core/data/services/Downloader.kt | 1 + .../details/presentation/DetailsViewModel.kt | 37 ++++++++++++++----- .../core/data/services/DesktopDownloader.kt | 14 +++++++ 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt index 9b1e9bafa..03ed70277 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/core/data/services/AndroidDownloader.kt @@ -207,4 +207,18 @@ class AndroidDownloader( } } } + + override suspend fun getFileSize(filePath: String): Long? = withContext(Dispatchers.IO) { + try { + val file = File(filePath) + if (file.exists() && file.isFile) { + file.length() + } else { + null + } + } catch (e: Exception) { + Logger.e { "Failed to get file size for $filePath: ${e.message}" } + null + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt index 712ce9579..99d9dc6ef 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/core/data/services/Downloader.kt @@ -19,4 +19,5 @@ interface Downloader { suspend fun getLatestDownload(): DownloadedFile? suspend fun getLatestDownloadForAssets(assetNames: List): DownloadedFile? + suspend fun getFileSize(filePath: String): Long? } diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt index 5324c044a..0d4d26d9a 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt @@ -559,17 +559,27 @@ class DetailsViewModel( ) val existingFilePath = downloader.getDownloadedFilePath(assetName) - - val filePath = if (existingFilePath != null) { - Logger.d { "File already exists, skipping download: $existingFilePath" } - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = LogResult.Downloaded - ) - existingFilePath + val validatedFilePath = if (existingFilePath != null) { + val fileSize = downloader.getFileSize(existingFilePath) + if (fileSize == sizeBytes) { + Logger.d { "File already exists with correct size, skipping download: $existingFilePath" } + appendLog( + assetName = assetName, + size = sizeBytes, + tag = releaseTag, + result = LogResult.Downloaded + ) + existingFilePath + } else { + Logger.w { "Existing file size mismatch (expected: $sizeBytes, found: $fileSize), re-downloading" } + downloader.cancelDownload(assetName) + null + } } else { + null + } + + val filePath = validatedFilePath ?: run { _state.value = _state.value.copy(downloadStage = DownloadStage.DOWNLOADING) downloader.download(downloadUrl, assetName).collect { p -> _state.value = _state.value.copy(downloadProgressPercent = p.percent) @@ -581,6 +591,13 @@ class DetailsViewModel( val downloadedPath = downloader.getDownloadedFilePath(assetName) ?: throw IllegalStateException("Downloaded file not found") + val downloadedSize = downloader.getFileSize(downloadedPath) + if (downloadedSize != sizeBytes) { + Logger.e { "Downloaded file size mismatch (expected: $sizeBytes, got: $downloadedSize)" } + downloader.cancelDownload(assetName) + throw IllegalStateException("Downloaded file size mismatch") + } + appendLog( assetName = assetName, size = sizeBytes, diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt index 0956e7b85..304040ee2 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/core/data/services/DesktopDownloader.kt @@ -161,6 +161,20 @@ class DesktopDownloader( } } + override suspend fun getFileSize(filePath: String): Long? = withContext(Dispatchers.IO) { + try { + val file = File(filePath) + if (file.exists() && file.isFile) { + file.length() + } else { + null + } + } catch (e: Exception) { + Logger.e { "Failed to get file size for $filePath: ${e.message}" } + null + } + } + companion object { private const val DEFAULT_BUFFER_SIZE = 8 * 1024 } From 9cd7c9eb9c3c2909f5a3cba255af9fc4605ff72f Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Wed, 7 Jan 2026 12:51:40 +0500 Subject: [PATCH 4/5] feat: Implement stale download cleanup on app start This introduces a `CleanupStaleDownloadsUseCase` to automatically remove downloaded files that are older than 24 hours. The cleanup logic is now triggered once per session from the main `Application` class, moving this responsibility out of the `DetailsViewModel`. This ensures that stale files are cleared on app startup. --- .../rainxch/githubstore/app/GithubStoreApp.kt | 14 +++++ .../use_case/CleanupStaleDownloadsUseCase.kt | 52 +++++++++++++++++++ .../details/presentation/DetailsViewModel.kt | 29 ----------- 3 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 26ad49b69..9e763d945 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -1,16 +1,30 @@ package zed.rainxch.githubstore.app import android.app.Application +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext import zed.rainxch.githubstore.app.di.initKoin +import zed.rainxch.githubstore.feature.details.domain.use_case.CleanupStaleDownloadsUseCase class GithubStoreApp : Application() { + @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { super.onCreate() initKoin { androidContext(this@GithubStoreApp) } + + val cleanupUseCase = CleanupStaleDownloadsUseCase(get()) + + GlobalScope.launch(Dispatchers.IO) { + cleanupUseCase() + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt new file mode 100644 index 000000000..a82958469 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt @@ -0,0 +1,52 @@ +package zed.rainxch.githubstore.feature.details.domain.use_case + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import zed.rainxch.githubstore.core.data.services.Downloader + +class CleanupStaleDownloadsUseCase( + private val downloader: Downloader +) { + private var hasRunThisSession = false + + suspend operator fun invoke() { + if (hasRunThisSession) { + Logger.d { "Stale downloads cleanup already ran this session" } + return + } + + withContext(Dispatchers.IO) { + try { + val allFiles = downloader.listDownloadedFiles() + val staleThreshold = System.currentTimeMillis() - (24 * 60 * 60 * 1000) // 24 hours + val staleFiles = allFiles.filter { it.downloadedAt < staleThreshold } + + if (staleFiles.isNotEmpty()) { + Logger.d { "Cleaning up ${staleFiles.size} stale files (older than 24h)" } + + staleFiles.forEach { file -> + try { + val deleted = downloader.cancelDownload(file.fileName) + if (deleted) { + Logger.d { "✓ Cleaned up stale file: ${file.fileName}" } + } else { + Logger.w { "✗ Failed to delete stale file: ${file.fileName}" } + } + } catch (e: Exception) { + Logger.e { "✗ Error deleting ${file.fileName}: ${e.message}" } + } + } + + Logger.d { "Stale files cleanup complete" } + } else { + Logger.d { "No stale files to clean up" } + } + + hasRunThisSession = true + } catch (t: Throwable) { + Logger.e { "Failed to cleanup stale files: ${t.message}" } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt index 0d4d26d9a..ace0f3688 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt @@ -84,35 +84,6 @@ class DetailsViewModel( try { _state.value = _state.value.copy(isLoading = true, errorMessage = null) - launch(Dispatchers.IO) { - try { - val allFiles = downloader.listDownloadedFiles() - if (allFiles.isNotEmpty()) { - Logger.d { "Cleaning up ${allFiles.size} leftover files from previous session" } - - allFiles.forEach { file -> - try { - val deleted = downloader.cancelDownload(file.fileName) - if (deleted) { - Logger.d { "✓ Cleaned up leftover file: ${file.fileName}" } - } else { - Logger.w { "✗ Failed to delete file: ${file.fileName}" } - } - } catch (e: Exception) { - Logger.e { "✗ Error deleting ${file.fileName}: ${e.message}" } - } - } - - Logger.d { "Cleanup complete - all leftover files processed" } - } else { - Logger.d { "No leftover files to clean up" } - } - } catch (t: Throwable) { - Logger.e { "Failed to cleanup leftover files: ${t.message}" } - } - } - - val syncResult = syncInstalledAppsUseCase() if (syncResult.isFailure) { Logger.w { "Sync had issues but continuing: ${syncResult.exceptionOrNull()?.message}" } From 04c59dabd01f42cb4006a666a37061a3b111faa7 Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Wed, 7 Jan 2026 12:59:30 +0500 Subject: [PATCH 5/5] refactor(concurrency): Ensure stale download cleanup runs only once Replaced a simple boolean flag with `AtomicBoolean` in `CleanupStaleDownloadsUseCase` to guarantee the cleanup process is thread-safe and executes only once per application session. Additionally, switched from `GlobalScope` to a custom `CoroutineScope` tied to the application's lifecycle, ensuring that the cleanup coroutine is properly cancelled when the application terminates. --- .../zed/rainxch/githubstore/app/GithubStoreApp.kt | 15 ++++++++++++--- .../use_case/CleanupStaleDownloadsUseCase.kt | 7 +++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 9e763d945..fa3bf24d7 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -1,10 +1,11 @@ package zed.rainxch.githubstore.app import android.app.Application +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext @@ -13,6 +14,8 @@ import zed.rainxch.githubstore.feature.details.domain.use_case.CleanupStaleDownl class GithubStoreApp : Application() { + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { super.onCreate() @@ -23,8 +26,14 @@ class GithubStoreApp : Application() { val cleanupUseCase = CleanupStaleDownloadsUseCase(get()) - GlobalScope.launch(Dispatchers.IO) { + applicationScope.launch(Dispatchers.IO) { cleanupUseCase() } } + + override fun onTerminate() { + applicationScope.cancel() + + super.onTerminate() + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt index a82958469..c3e1d1e58 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/domain/use_case/CleanupStaleDownloadsUseCase.kt @@ -4,14 +4,15 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import zed.rainxch.githubstore.core.data.services.Downloader +import java.util.concurrent.atomic.AtomicBoolean class CleanupStaleDownloadsUseCase( private val downloader: Downloader ) { - private var hasRunThisSession = false + private val hasRunThisSession = AtomicBoolean(false) suspend operator fun invoke() { - if (hasRunThisSession) { + if (!hasRunThisSession.compareAndSet(false, true)) { Logger.d { "Stale downloads cleanup already ran this session" } return } @@ -42,8 +43,6 @@ class CleanupStaleDownloadsUseCase( } else { Logger.d { "No stale files to clean up" } } - - hasRunThisSession = true } catch (t: Throwable) { Logger.e { "Failed to cleanup stale files: ${t.message}" } }