From ffad659cbfc2e1a5f3ab9ff17af46d0e81e48098 Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Fri, 9 Jan 2026 18:04:59 +0500 Subject: [PATCH] feat(download): Add file validation and cleanup This commit introduces several enhancements to the download functionality: - **File Validation:** Before starting a new download, the system now checks if the file already exists. If it does, it validates the file size against the expected size. The download is skipped if the sizes match, preventing redundant downloads. - **Post-Download Verification:** After a download completes, the size of the downloaded file is verified to ensure its integrity. - **Automatic Cleanup:** The downloads directory is now automatically cleaned of files that do not belong to the current repository's release assets. - **Code Refactoring:** - A new `DownloadedFile` data class has been created. - The `Downloader` interface and its implementations (`AndroidDownloader`, `DesktopDownloader`) have been updated to support listing downloaded files, getting file sizes, and changing the download directory from the user's public downloads to a dedicated application downloads directory. - The download cancellation logic in `DetailsViewModel`'s `onCleared` method has been simplified. --- .../core/data/services/AndroidDownloader.kt | 47 +++++++ .../core/data/services/Downloader.kt | 7 ++ .../core/domain/model/DownloadedFile.kt | 8 ++ .../details/presentation/DetailsViewModel.kt | 115 ++++++++++++++---- .../core/data/services/DesktopDownloader.kt | 57 ++++++++- 5 files changed, 204 insertions(+), 30 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..fd95a3756 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,50 @@ 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 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 + } + } + + 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..3059233af 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? + + // Add this new method + suspend fun getFileSize(filePath: String): Long? } 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/DetailsViewModel.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/feature/details/presentation/DetailsViewModel.kt index 85842ccc7..642113fa1 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,7 @@ 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.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel @@ -184,6 +185,39 @@ class DetailsViewModel( val primary = installer.choosePrimaryAsset(installable) + launch(Dispatchers.IO) { + try { + val allFiles = downloader.listDownloadedFiles() + val currentRepoAssetNames = installable.map { it.name }.toSet() + val filesToDelete = allFiles.filter { file -> + file.fileName !in currentRepoAssetNames + } + + if (filesToDelete.isNotEmpty()) { + Logger.d { "Cleaning up ${filesToDelete.size} files from other repositories" } + + filesToDelete.forEach { file -> + try { + val deleted = downloader.cancelDownload(file.fileName) + if (deleted) { + Logger.d { "✓ Cleaned up file from other repo: ${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 - ${filesToDelete.size} files removed" } + } else { + Logger.d { "No files from other repos to clean up" } + } + } catch (t: Throwable) { + Logger.e { "Failed to cleanup files from other repos: ${t.message}" } + } + } + val isObtainiumAvailable = installer.isObtainiumInstalled() val isAppManagerAvailable = installer.isAppManagerInstalled() @@ -513,10 +547,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 +560,61 @@ 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) + // Check if file already exists and validate + val existingFilePath = downloader.getDownloadedFilePath(assetName) + val validatedFilePath = if (existingFilePath != null) { + // Verify file size matches expected + val fileSize = downloader.getFileSize(existingFilePath) + if (fileSize == sizeBytes) { + Logger.d { "File already exists with correct size ($fileSize bytes), 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 = downloader.getDownloadedFilePath(assetName) - ?: throw IllegalStateException("Downloaded file not found") + // Download if no valid file exists + 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) + if (p.percent == 100) { + _state.value = _state.value.copy(downloadStage = DownloadStage.VERIFYING) + } + } - appendLog( - assetName = assetName, - size = sizeBytes, - tag = releaseTag, - result = LogResult.Downloaded - ) + val downloadedPath = downloader.getDownloadedFilePath(assetName) + ?: throw IllegalStateException("Downloaded file not found") + + // Verify downloaded file size + 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 - expected $sizeBytes bytes, got $downloadedSize bytes") + } + + Logger.d { "Download verified - file size matches: $downloadedSize bytes" } + + 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 +646,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,13 +854,9 @@ class DetailsViewModel( override fun onCleared() { super.onCleared() - currentDownloadJob?.cancel() - currentAssetName?.let { assetName -> - viewModelScope.launch { - downloader.cancelDownload(assetName) - } - } + currentDownloadJob?.cancel() + currentDownloadJob = null } private companion object { 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..b5626d1a8 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 @@ -23,7 +24,7 @@ class DesktopDownloader( override fun download(url: String, suggestedFileName: String?): Flow = channelFlow { withContext(Dispatchers.IO) { - val dir = File(files.userDownloadsDir()) + val dir = File(files.appDownloadsDir()) if (!dir.exists()) dir.mkdirs() val safeName = (suggestedFileName?.takeIf { it.isNotBlank() } @@ -82,7 +83,7 @@ class DesktopDownloader( } override suspend fun saveToFile(url: String, suggestedFileName: String?): String = withContext(Dispatchers.IO) { - val dir = File(files.userDownloadsDir()) + val dir = File(files.appDownloadsDir()) // Changed from userDownloadsDir() val safeName = (suggestedFileName?.takeIf { it.isNotBlank() } ?: url.substringAfterLast('/') .ifBlank { "asset-${UUID.randomUUID()}" }) @@ -101,7 +102,7 @@ class DesktopDownloader( } override suspend fun getDownloadedFilePath(fileName: String): String? = withContext(Dispatchers.IO) { - val dir = File(files.userDownloadsDir()) + val dir = File(files.appDownloadsDir()) // Changed from userDownloadsDir() val file = File(dir, fileName) if (file.exists() && file.length() > 0) { @@ -112,13 +113,13 @@ class DesktopDownloader( } override suspend fun cancelDownload(fileName: String): Boolean = withContext(Dispatchers.IO) { - val dir = File(files.userDownloadsDir()) + val dir = File(files.appDownloadsDir()) // Changed from userDownloadsDir() val file = File(dir, fileName) if (file.exists()) { val deleted = file.delete() if (deleted) { - Logger.d { "Deleted file from Downloads: ${file.absolutePath}" } + Logger.d { "Deleted file from app Downloads: ${file.absolutePath}" } } else { Logger.w { "Failed to delete file: ${file.absolutePath}" } } @@ -128,6 +129,52 @@ class DesktopDownloader( } } + override suspend fun listDownloadedFiles(): List = withContext(Dispatchers.IO) { + val dir = File(files.appDownloadsDir()) // Already correct + 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 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 + } + } + + 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 }