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 c72299b5..fd95a375 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 41230f84..3059233a 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 00000000..9b799a63 --- /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 85842ccc..642113fa 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 1c01907d..b5626d1a 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 }