diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 6da9595f1..1b752a31a 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -19,6 +19,7 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.mappers.toEntity import zed.rainxch.core.data.network.executeRequest +import zed.rainxch.core.domain.model.AssetPreferenceMatcher import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp @@ -120,7 +121,11 @@ class InstalledAppsRepositoryImpl( latestRelease.assets.filter { asset -> installer.isAssetInstallable(asset.name) } - val primaryAsset = installer.choosePrimaryAsset(installableAssets) + val preferredAsset = + AssetPreferenceMatcher.choosePreferredAsset( + assets = installableAssets, + preferredAssetNames = listOf(app.latestAssetName, app.installedAssetName), + ) ?: installer.choosePrimaryAsset(installableAssets) // Only flag as update if the latest version is actually newer // (not just different — avoids false "downgrade" notifications) @@ -142,9 +147,9 @@ class InstalledAppsRepositoryImpl( packageName = packageName, available = isUpdateAvailable, version = latestRelease.tagName, - assetName = primaryAsset?.name, - assetUrl = primaryAsset?.downloadUrl, - assetSize = primaryAsset?.size, + assetName = preferredAsset?.name, + assetUrl = preferredAsset?.downloadUrl, + assetSize = preferredAsset?.size, releaseNotes = latestRelease.description ?: "", timestamp = System.currentTimeMillis(), latestVersionName = latestRelease.tagName, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AssetPreferenceMatcher.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AssetPreferenceMatcher.kt new file mode 100644 index 000000000..aa76995a5 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/AssetPreferenceMatcher.kt @@ -0,0 +1,104 @@ +package zed.rainxch.core.domain.model + +object AssetPreferenceMatcher { + fun choosePreferredAsset( + assets: List, + preferredAssetNames: List, + ): GithubAsset? { + if (assets.isEmpty()) return null + + for (preferredName in preferredAssetNames) { + val match = findBestMatchForPreferred(assets, preferredName) + if (match != null) return match + } + + return null + } + + private fun findBestMatchForPreferred( + assets: List, + preferredName: String?, + ): GithubAsset? { + val normalizedPreferred = preferredName?.trim()?.lowercase().orEmpty() + if (normalizedPreferred.isBlank()) return null + + assets.firstOrNull { it.name.equals(preferredName, ignoreCase = true) }?.let { return it } + + val preferredFamily = familyKey(normalizedPreferred) + val familyCandidates = + assets.filter { asset -> + familyKey(asset.name.lowercase()) == preferredFamily + } + if (familyCandidates.isEmpty()) return null + + val preferredExt = extension(normalizedPreferred) + val sameExtCandidates = + familyCandidates.filter { asset -> + extension(asset.name.lowercase()) == preferredExt + } + val candidates = sameExtCandidates.ifEmpty { familyCandidates } + + return candidates.maxWithOrNull( + compareBy( + { similarityScore(normalizedPreferred, it.name.lowercase()) }, + { it.size }, + ), + ) + } + + private fun extension(assetName: String): String = assetName.substringAfterLast('.', "") + + private fun familyKey(assetName: String): String { + val normalized = assetName.substringBeforeLast('.', assetName).lowercase() + val tokens = + normalized + .split(NON_ALNUM_REGEX) + .filter { it.isNotBlank() } + .filterNot { isLikelyVersionToken(it) } + + return if (tokens.isNotEmpty()) { + tokens.joinToString("-") + } else { + normalized + } + } + + private fun similarityScore( + preferredName: String, + candidateName: String, + ): Int { + val preferredBase = preferredName.substringBeforeLast('.', preferredName) + val candidateBase = candidateName.substringBeforeLast('.', candidateName) + + val sharedTokens = + tokenize(preferredBase) + .intersect(tokenize(candidateBase)) + .size + val prefixLength = commonPrefixLength(preferredBase, candidateBase) + + return (sharedTokens * 100) + prefixLength + } + + private fun tokenize(name: String): Set = + name + .split(NON_ALNUM_REGEX) + .filter { it.isNotBlank() } + .toSet() + + private fun commonPrefixLength( + first: String, + second: String, + ): Int { + val limit = minOf(first.length, second.length) + var idx = 0 + while (idx < limit && first[idx] == second[idx]) { + idx++ + } + return idx + } + + private fun isLikelyVersionToken(token: String): Boolean = VERSION_TOKEN_REGEX.matches(token) + + private val NON_ALNUM_REGEX = Regex("[^a-z0-9]+") + private val VERSION_TOKEN_REGEX = Regex("^v?\\d+(?:[._-]?\\d+)*[a-z]*$") +} diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt index f5dca9a58..c4eb7111a 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt @@ -151,6 +151,9 @@ class AppsRepositoryImpl( override suspend fun linkAppToRepo( deviceApp: DeviceApp, repoInfo: GithubRepoInfo, + selectedAssetName: String?, + selectedAssetUrl: String?, + selectedAssetSize: Long?, ) { val now = Clock.System.now().toEpochMilliseconds() val globalPreRelease = tweaksRepository.getIncludePreReleases().first() @@ -166,12 +169,12 @@ class AppsRepositoryImpl( primaryLanguage = repoInfo.language, repoUrl = repoInfo.htmlUrl, installedVersion = deviceApp.versionName ?: "unknown", - installedAssetName = null, - installedAssetUrl = null, + installedAssetName = selectedAssetName, + installedAssetUrl = selectedAssetUrl, latestVersion = repoInfo.latestReleaseTag, - latestAssetName = null, - latestAssetUrl = null, - latestAssetSize = null, + latestAssetName = selectedAssetName, + latestAssetUrl = selectedAssetUrl, + latestAssetSize = selectedAssetSize, appName = deviceApp.appName, installSource = InstallSource.MANUAL, installedAt = now, diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt index 609a6200a..64b67f284 100644 --- a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt +++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt @@ -27,7 +27,13 @@ interface AppsRepository { suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? - suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) + suspend fun linkAppToRepo( + deviceApp: DeviceApp, + repoInfo: GithubRepoInfo, + selectedAssetName: String? = null, + selectedAssetUrl: String? = null, + selectedAssetSize: Long? = null, + ) suspend fun exportApps(): String diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index cb6b167a1..bc9eeab89 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -28,6 +28,7 @@ import zed.rainxch.apps.presentation.model.InstalledAppUi import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.AssetPreferenceMatcher import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.network.Downloader @@ -475,7 +476,10 @@ class AppsViewModel( } val primaryAsset = - installer.choosePrimaryAsset(installableAssets) + AssetPreferenceMatcher.choosePreferredAsset( + assets = installableAssets, + preferredAssetNames = listOf(app.latestAssetName, app.installedAssetName), + ) ?: installer.choosePrimaryAsset(installableAssets) ?: throw IllegalStateException("Could not determine primary asset") logger.debug( @@ -1018,7 +1022,13 @@ class AppsViewModel( val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) if (apkInfo == null) { logger.debug("Could not extract APK info for validation, linking anyway") - appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo.toDomain()) + appsRepository.linkAppToRepo( + deviceApp = selectedApp.toDomain(), + repoInfo = repoInfo.toDomain(), + selectedAssetName = asset.name, + selectedAssetUrl = asset.downloadUrl, + selectedAssetSize = asset.size, + ) _state.update { it.copy( linkDownloadProgress = null, @@ -1070,7 +1080,13 @@ class AppsViewModel( return@launch } - appsRepository.linkAppToRepo(selectedApp.toDomain(), repoInfo.toDomain()) + appsRepository.linkAppToRepo( + deviceApp = selectedApp.toDomain(), + repoInfo = repoInfo.toDomain(), + selectedAssetName = asset.name, + selectedAssetUrl = asset.downloadUrl, + selectedAssetSize = asset.size, + ) _state.update { it.copy( linkDownloadProgress = null, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 271eb345b..25ac5b723 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -688,9 +688,13 @@ class DetailsViewModel( if (installedApp != null && selectedRelease != null && installedApp.isUpdateAvailable) { val latestAsset = - _state.value.installableAssets.firstOrNull { - it.name == installedApp.latestAssetName - } ?: _state.value.primaryAsset + _state.value.primaryAsset + ?: _state.value.installableAssets.firstOrNull { + it.name == installedApp.latestAssetName + } + ?: _state.value.installableAssets.firstOrNull { + it.name == installedApp.installedAssetName + } if (latestAsset != null) { installAsset(