Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package zed.rainxch.core.domain.model

object AssetPreferenceMatcher {
fun choosePreferredAsset(
assets: List<GithubAsset>,
preferredAssetNames: List<String?>,
): 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<GithubAsset>,
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<GithubAsset>(
{ 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<String> =
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]*$")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down