diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ca85f979a..031e7c219 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,8 @@ "WebFetch(domain:www.jetbrains.com)", "WebFetch(domain:youtrack.jetbrains.com)", "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:blog.jetbrains.com)" + "WebFetch(domain:blog.jetbrains.com)", + "Bash(rtk grep *)" ] } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index aae972a66..bd21a1de4 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -13,6 +13,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor +import zed.rainxch.core.domain.util.VersionVerdict +import zed.rainxch.core.domain.util.resolveExternalInstallVerdict /** * Listens for package install/replace/remove broadcasts to update tracked app state. @@ -28,11 +30,17 @@ class PackageEventReceiver() : KoinComponent { private val installedAppsRepositoryKoin: InstalledAppsRepository by inject() private val packageMonitorKoin: PackageMonitor by inject() + private val appScopeKoin: CoroutineScope by inject() // Explicitly provided dependencies (dynamic registration path) private var explicitRepository: InstalledAppsRepository? = null private var explicitMonitor: PackageMonitor? = null + // Local fallback scope for the manifest-registered path when + // `onReceive` fires but Koin somehow couldn't resolve the shared + // app scope (extremely unlikely — the Application installs Koin + // synchronously in onCreate). The async backstop below prefers + // the Koin scope via `getBackstopScope`. private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) constructor( @@ -47,6 +55,13 @@ class PackageEventReceiver() : private fun getMonitor(): PackageMonitor = explicitMonitor ?: packageMonitorKoin + private fun getBackstopScope(): CoroutineScope = + // Koin's app-scoped CoroutineScope outlives a manifest-registered + // receiver whose local `scope` would die with the instance. Fall + // back to the local scope only if Koin isn't initialized yet + // (shouldn't happen post-Application.onCreate, but defensive). + runCatching { appScopeKoin }.getOrElse { scope } + override fun onReceive( context: Context?, intent: Intent?, @@ -122,22 +137,114 @@ class PackageEventReceiver() : Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } } } else { - val systemInfo = monitor.getInstalledPackageInfo(packageName) - if (systemInfo != null) { - repo.updateApp( - app.copy( - installedVersionName = systemInfo.versionName, - installedVersionCode = systemInfo.versionCode, - ), - ) - Logger.d { "Updated version info via broadcast: $packageName (v${systemInfo.versionName})" } - } + handleExternalInstall(packageName, app, repo, monitor) } } catch (e: Exception) { Logger.e { "PackageEventReceiver error for $packageName: ${e.message}" } } } + /** + * Path taken when the broadcast fires for a tracked app that the + * user did NOT install from inside the store (sideload, browser + * download, Play Store update, F-Droid update of a shared + * package, etc.). The pending-install branch above handles the + * in-app install case. + * + * Strategy (GitHub-Store#378): + * + * 1. Refresh every version field from PackageManager — this is + * the strictest source of truth for what is actually on + * device right now. + * 2. Apply [resolveExternalInstallVerdict] for an immediate + * decision about `isUpdateAvailable`. The resolver uses a + * priority ladder (versionCode → versionName vs + * latestVersionName → versionName vs release tag) and only + * returns [VersionVerdict.UNKNOWN] when none of those + * produce a reliable answer. + * 3. Dispatch an async `checkForUpdates(packageName)` on the + * app-scoped coroutine scope. That call re-fetches the + * latest release list from GitHub and applies + * [zed.rainxch.core.domain.util.VersionMath] with the freshly + * updated `installedVersion`, so even an incorrect optimistic + * verdict is corrected within the RTT of a single GitHub + * API hit. + * + * The async backstop runs on the Koin-provided app scope so it + * survives the receiver instance being torn down after + * `onReceive` returns — critical for the manifest-registered + * path. + */ + private suspend fun handleExternalInstall( + packageName: String, + app: zed.rainxch.core.domain.model.InstalledApp, + repo: InstalledAppsRepository, + monitor: PackageMonitor, + ) { + val systemInfo = monitor.getInstalledPackageInfo(packageName) ?: return + val versionChanged = + systemInfo.versionCode != app.installedVersionCode || + systemInfo.versionName != app.installedVersionName + if (!versionChanged) { + Logger.d { + "Broadcast touch with no version change: $packageName (v${systemInfo.versionName})" + } + return + } + + val verdict = + resolveExternalInstallVerdict( + app = app, + newVersionName = systemInfo.versionName, + newVersionCode = systemInfo.versionCode, + ) + + val newIsUpdateAvailable = + when (verdict) { + VersionVerdict.UP_TO_DATE -> false + VersionVerdict.UPDATE_AVAILABLE -> true + // Preserve the current flag for UNKNOWN — the async + // checkForUpdates below is about to overwrite it with + // an authoritative answer anyway. + VersionVerdict.UNKNOWN -> app.isUpdateAvailable + } + + // Targeted column-only write: avoids clobbering sibling fields + // (download orchestrator metadata, variant pin, favourite + // toggle, checkForUpdates results…) that may have landed + // between `onPackageInstalled`'s initial `getAppByPackage` and + // this write. See `InstalledAppsRepository.updateInstalledVersion`. + repo.updateInstalledVersion( + packageName = packageName, + installedVersion = systemInfo.versionName, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + isUpdateAvailable = newIsUpdateAvailable, + ) + + Logger.i { + "External version change via broadcast: $packageName " + + "DB v${app.installedVersionName}(${app.installedVersionCode}) → " + + "System v${systemInfo.versionName}(${systemInfo.versionCode}), " + + "verdict=$verdict, updateAvailable=$newIsUpdateAvailable" + } + + // Authoritative re-validation against fresh GitHub release data. + // Runs on the app scope so it outlives this broadcast. + getBackstopScope().launch { + try { + repo.checkForUpdates(packageName) + Logger.d { + "External-install re-validation completed for $packageName" + } + } catch (e: Exception) { + Logger.w { + "External-install re-validation failed for $packageName: ${e.message}" + } + } + } + } + private suspend fun onPackageRemoved(packageName: String) { try { getRepository().deleteInstalledApp(packageName) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt index 962e13870..9c83f86e0 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt @@ -142,6 +142,32 @@ interface InstalledAppDao { timestamp: Long, ) + /** + * Atomically writes the installed-version columns and the + * `isUpdateAvailable` flag for [packageName]. Used by the external + * install / sideload path (`PackageEventReceiver.handleExternalInstall`) + * where a stale snapshot + full-row update could otherwise clobber + * concurrent writes to sibling columns (download orchestrator, + * variant pin, favourite toggle, `checkForUpdates`, etc.). + */ + @Query( + """ + UPDATE installed_apps + SET installedVersion = :installedVersion, + installedVersionName = :installedVersionName, + installedVersionCode = :installedVersionCode, + isUpdateAvailable = :isUpdateAvailable + WHERE packageName = :packageName + """, + ) + suspend fun updateInstalledVersion( + packageName: String, + installedVersion: String, + installedVersionName: String?, + installedVersionCode: Long, + isUpdateAvailable: Boolean, + ) + /** * Sets the path + version + asset name of a * downloaded-but-not-yet-installed asset. Pass all `null` to 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 e7d2b124e..5c8036b17 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 @@ -28,8 +28,10 @@ import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.repository.MatchingPreview import zed.rainxch.core.domain.system.Installer +import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.util.AssetFilter import zed.rainxch.core.domain.util.AssetVariant +import zed.rainxch.core.domain.util.VersionMath class InstalledAppsRepositoryImpl( private val database: AppDatabase, @@ -101,8 +103,17 @@ class InstalledAppsRepositoryImpl( /** * Fetches up to [RELEASE_WINDOW] releases for [owner]/[repo], filters - * out drafts, applies the pre-release flag, and returns them sorted by - * `publishedAt` descending. Empty list on failure (logged at error). + * out drafts, applies the pre-release policy, and returns them sorted + * by `publishedAt` descending. Empty list on failure (logged at error). + * + * Pre-release policy: a release is filtered out when + * `includePreReleases = false` AND either the GitHub `prerelease` + * flag is `true` OR the tag/name contains a recognised pre-release + * marker (see [GithubRelease.isEffectivelyPreRelease]). The tag + * heuristic catches the common maintainer mistake of tagging + * `v2.0.0-rc.1` with `prerelease: false`. Whenever the flag and + * heuristic disagree we emit a diagnostic so the drift is + * traceable in session logs. */ private suspend fun fetchReleaseWindow( owner: String, @@ -122,9 +133,23 @@ class InstalledAppsRepositoryImpl( releases .asSequence() .filter { it.draft != true } - .filter { includePreReleases || it.prerelease != true } .sortedByDescending { it.publishedAt ?: it.createdAt ?: "" } .map { it.toDomain() } + .onEach { release -> + val flagSays = release.isPrerelease + val tagSays = + VersionMath.isPreReleaseTag(release.tagName) || + VersionMath.isPreReleaseTag(release.name) + if (flagSays != tagSays) { + Logger.w { + "Pre-release flag/tag mismatch for $owner/$repo " + + "release '${release.tagName}' (name='${release.name}'): " + + "apiFlag=$flagSays, tagMarker=$tagSays — " + + "treating as pre-release=${flagSays || tagSays}" + } + } + } + .filter { includePreReleases || !it.isEffectivelyPreRelease() } .toList() } catch (e: CancellationException) { // Structured concurrency: cancellation must propagate. Never @@ -318,15 +343,12 @@ class InstalledAppsRepositoryImpl( } val (matchedRelease, primaryAsset, variantWasLost) = resolved - val normalizedInstalledTag = normalizeVersion(app.installedVersion) - val normalizedLatestTag = normalizeVersion(matchedRelease.tagName) val isUpdateAvailable = - if (normalizedInstalledTag == normalizedLatestTag) { - false - } else { - isVersionNewer(normalizedLatestTag, normalizedInstalledTag) - } + VersionMath.isVersionNewer( + candidate = matchedRelease.tagName, + current = app.installedVersion, + ) Logger.d { "Update check for ${app.appName}: " + @@ -435,6 +457,22 @@ class InstalledAppsRepositoryImpl( installedAppsDao.updateApp(app.toEntity()) } + override suspend fun updateInstalledVersion( + packageName: String, + installedVersion: String, + installedVersionName: String?, + installedVersionCode: Long, + isUpdateAvailable: Boolean, + ) { + installedAppsDao.updateInstalledVersion( + packageName = packageName, + installedVersion = installedVersion, + installedVersionName = installedVersionName, + installedVersionCode = installedVersionCode, + isUpdateAvailable = isUpdateAvailable, + ) + } + override suspend fun updatePendingStatus( packageName: String, isPending: Boolean, @@ -583,153 +621,8 @@ class InstalledAppsRepositoryImpl( ) } - /** - * Reduces a tag or installed-version string to a form that - * [parseSemanticVersion] can actually digest. - * - * Why this matters: when a user sideloads an update from outside - * GitHub Store, [SyncInstalledAppsUseCase] picks up the new - * `versionName` from the Android package manager and writes it back - * to `installedVersion`. But the immediately-following - * [checkForUpdates] then compares that fresh value against the - * GitHub release `tagName`. If the maintainer publishes tags like - * `release-1.2.0` or `App-1.2.0` (any prefix that isn't just `v`), - * the OLD normalize-by-stripping-v left them alone, the equality - * check failed, and [isVersionNewer] fell through to a lexicographic - * comparison where the leading letter (`'r'` = 114) is "greater - * than" the digit (`'1'` = 49), incorrectly re-flagging the update. - * - * The new normalization tries, in order: - * 1. Strip leading `v` / `V` - * 2. Drop `+build` metadata (semver says it's ignored for ordering) - * 3. If the result is still not parseable, extract the first - * dotted-digit substring (optionally followed by a `-pre` - * identifier) and use that. - * - * Examples: - * `v1.2.3` → `1.2.3` - * `1.2.3+sha.abcd` → `1.2.3` - * `1.2.3-rc1` → `1.2.3-rc1` (preserved — affects ordering) - * `release-1.2.0` → `1.2.0` - * `App-v1.2.0-stable` → `1.2.0-stable` - * `build-2025.04.10` → `2025.04.10` - * `not-a-version` → `not-a-version` (unchanged — let caller fall back) - */ - private fun normalizeVersion(version: String): String { - val cleaned = version.trim().removePrefix("v").removePrefix("V").trim() - val withoutBuildMetadata = cleaned.substringBefore('+') - if (parseSemanticVersion(withoutBuildMetadata) != null) { - return withoutBuildMetadata - } - val match = - Regex("""\d+(?:\.\d+)*(?:-[\w.]+)?""") - .find(withoutBuildMetadata) - return match?.value ?: withoutBuildMetadata - } - - /** - * Compare two version strings and return true if [candidate] is newer than [current]. - * Handles semantic versioning (1.2.3), pre-release suffixes (1.2.3-beta.1), - * and falls back to lexicographic comparison for non-standard formats. - * - * Pre-release versions are considered older than their stable counterparts: - * 1.2.3-beta < 1.2.3 (per semver spec) - * - * This prevents false "downgrade" notifications when a user has a pre-release - * installed and the latest stable version has a lower or equal base version. - */ - private fun isVersionNewer( - candidate: String, - current: String, - ): Boolean { - val candidateParsed = parseSemanticVersion(candidate) - val currentParsed = parseSemanticVersion(current) - - if (candidateParsed != null && currentParsed != null) { - // Compare major.minor.patch - for (i in 0 until maxOf(candidateParsed.numbers.size, currentParsed.numbers.size)) { - val c = candidateParsed.numbers.getOrElse(i) { 0 } - val r = currentParsed.numbers.getOrElse(i) { 0 } - if (c > r) return true - if (c < r) return false - } - // Numbers are equal; compare pre-release suffixes - // No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta) - return when { - candidateParsed.preRelease == null && currentParsed.preRelease != null -> { - true - } - - candidateParsed.preRelease != null && currentParsed.preRelease == null -> { - false - } - - candidateParsed.preRelease != null && currentParsed.preRelease != null -> { - comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0 - } - - else -> { - false - } // both null, versions are equal - } - } - - // Fallback: lexicographic comparison (better than just "not equal") - return candidate > current - } - - private data class SemanticVersion( - val numbers: List, - val preRelease: String?, - ) - - private fun parseSemanticVersion(version: String): SemanticVersion? { - // Split off pre-release suffix: "1.2.3-beta.1" -> "1.2.3" and "beta.1" - val hyphenIndex = version.indexOf('-') - val numberPart = if (hyphenIndex >= 0) version.substring(0, hyphenIndex) else version - val preRelease = if (hyphenIndex >= 0) version.substring(hyphenIndex + 1) else null - - val parts = numberPart.split(".") - val numbers = parts.mapNotNull { it.toIntOrNull() } - - // Only valid if we could parse at least one number and all parts were valid numbers - if (numbers.isEmpty() || numbers.size != parts.size) return null - - return SemanticVersion(numbers, preRelease) - } - - /** - * Compare pre-release identifiers per semver spec: - * Identifiers consisting of only digits are compared numerically. - * Identifiers with letters are compared lexically. - * Numeric identifiers always have lower precedence than alphanumeric. - * A larger set of pre-release fields has higher precedence if all preceding are equal. - */ - private fun comparePreRelease( - a: String, - b: String, - ): Int { - val aParts = a.split(".") - val bParts = b.split(".") - - for (i in 0 until minOf(aParts.size, bParts.size)) { - val aNum = aParts[i].toIntOrNull() - val bNum = bParts[i].toIntOrNull() - - val cmp = - when { - aNum != null && bNum != null -> aNum.compareTo(bNum) - - aNum != null -> -1 - - // numeric < alphanumeric - bNum != null -> 1 - - else -> aParts[i].compareTo(bParts[i]) - } - if (cmp != 0) return cmp - } - - return aParts.size.compareTo(bParts.size) - } + // Version normalization + comparison lives in + // `core.domain.util.VersionMath` so the periodic update check, + // the external-install verdict in `PackageEventReceiver`, and any + // future surfaces all share one comparator. See #378. } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt new file mode 100644 index 000000000..315ccb043 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubReleaseExt.kt @@ -0,0 +1,44 @@ +package zed.rainxch.core.domain.model + +import zed.rainxch.core.domain.util.VersionMath + +/** + * Single source of truth for "should this release be treated as a + * pre-release across the app". + * + * Combines: + * - [GithubRelease.isPrerelease] — the authoritative GitHub API flag. + * - [VersionMath.isPreReleaseTag] on [GithubRelease.tagName] — catches + * the common case where a maintainer publishes `v2.0.0-rc.1` but + * forgets to tick the "This is a pre-release" box. Without the + * tag heuristic, an opted-out user would be silently offered that + * build as if it were stable. + * - [VersionMath.isPreReleaseTag] on [GithubRelease.name] — some + * maintainers only put the `beta` marker in the human-readable + * release title (e.g. tag=`2.0.0`, name=`2.0.0 (beta)`). + * + * Every UI that shows a "Pre-release" badge and every filter that + * decides whether to surface a release to a given user MUST use this + * helper, otherwise the flag-vs-tag mismatch surfaces as a silent + * bug. + */ +fun GithubRelease.isEffectivelyPreRelease(): Boolean = + isPrerelease || + VersionMath.isPreReleaseTag(tagName) || + VersionMath.isPreReleaseTag(name) + +/** + * Specific label for this release's pre-release marker — `"Beta"`, + * `"Alpha"`, `"RC"`, etc. — or `null` if no marker was detected. + * + * Tries the tag first (where the marker most often lives), falls + * back to the release name (some maintainers put the marker only + * in the title). Returns `null` when neither contains a recognised + * marker, in which case callers that still want to show a badge + * should check [isEffectivelyPreRelease] and fall back to a generic + * "Pre-release" pill — an opted-in API flag with no visible marker + * is still a pre-release, just one without a specific channel name. + */ +fun GithubRelease.preReleaseLabel(): String? = + VersionMath.preReleaseMarkerLabel(tagName) + ?: VersionMath.preReleaseMarkerLabel(name) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt index 33b6fce11..10160814a 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt @@ -41,6 +41,24 @@ interface InstalledAppsRepository { suspend fun updateApp(app: InstalledApp) + /** + * Atomically writes only the installed-version columns + the + * `isUpdateAvailable` flag for [packageName]. Prefer this over + * [updateApp] on hot paths where the caller holds a possibly-stale + * snapshot and only wants to persist a version change — full-row + * updates from stale snapshots can clobber concurrent writes to + * sibling columns (download orchestrator, variant pin, favourite + * toggle, periodic update check). Introduced for the external + * install path (`PackageEventReceiver`). + */ + suspend fun updateInstalledVersion( + packageName: String, + installedVersion: String, + installedVersionName: String?, + installedVersionCode: Long, + isUpdateAvailable: Boolean, + ) + suspend fun updatePendingStatus( packageName: String, isPending: Boolean, diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt new file mode 100644 index 000000000..fcaa45f87 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/ExternalInstallVerdict.kt @@ -0,0 +1,107 @@ +package zed.rainxch.core.domain.util + +import zed.rainxch.core.domain.model.InstalledApp + +/** + * Decision produced by [resolveExternalInstallVerdict] after an + * externally-performed install/update surfaces via PackageManager. + */ +enum class VersionVerdict { + /** System version meets or exceeds every signal we have. */ + UP_TO_DATE, + + /** System version is strictly older than at least one reliable signal. */ + UPDATE_AVAILABLE, + + /** + * No signal was reliable enough to decide. The caller should + * leave the current `isUpdateAvailable` flag alone and trigger + * an authoritative re-check via + * [zed.rainxch.core.domain.repository.InstalledAppsRepository.checkForUpdates]. + */ + UNKNOWN, +} + +/** + * Best-effort local verdict after an external install/update. + * + * An Android APK install gives us two pieces of truth — [newVersionName] + * and [newVersionCode] — both of which are stricter than what we can + * usually know about the tracked "latest" release. We try a ladder of + * signals, stopping at the first one that can produce a reliable answer: + * + * 1. **versionCode comparison.** Monotonic integer by Android contract. + * Only usable when we've captured a non-zero `latestVersionCode` for + * this app (i.e. a previous install round stamped it). If both sides + * have a real `versionCode`, one comparison nails the answer. + * + * 2. **versionName vs latestVersionName.** These are the post-install + * values from PackageManager, which means same axis. Run through + * [VersionMath.normalizeVersion] so `1.2.3` and `v1.2.3-stable` line + * up, then semver-compare. + * + * 3. **versionName vs latestVersion (release tag).** Works when the + * maintainer's tag contains the real version (e.g. `v1.2.3`, + * `release-1.2.3`, `build-2025.04.10`) — [VersionMath.normalizeVersion] + * extracts the dotted-digit core. + * + * 4. **Give up.** Return [VersionVerdict.UNKNOWN]. The caller is + * expected to defer to the network-backed + * [zed.rainxch.core.domain.repository.InstalledAppsRepository.checkForUpdates], + * which does the same comparison with fresh GitHub release data. + * + * This function is pure — it does not read or write any state, and it + * makes no network or disk calls. Callers combine it with an async + * authoritative re-check so that an incorrect optimistic answer is + * corrected within seconds. + */ +fun resolveExternalInstallVerdict( + app: InstalledApp, + newVersionName: String, + newVersionCode: Long, +): VersionVerdict { + // Priority 1: integer versionCode (most reliable when available). + val latestVersionCode = app.latestVersionCode ?: 0L + if (latestVersionCode > 0L && newVersionCode > 0L) { + return if (newVersionCode >= latestVersionCode) { + VersionVerdict.UP_TO_DATE + } else { + VersionVerdict.UPDATE_AVAILABLE + } + } + + // Priority 2: versionName ↔ latestVersionName (same axis — both + // come from PackageManager on their respective installs). + val latestName = app.latestVersionName + if (!latestName.isNullOrBlank() && newVersionName.isNotBlank()) { + val verdict = compareAndDecide(newVersionName, latestName) + if (verdict != VersionVerdict.UNKNOWN) return verdict + } + + // Priority 3: versionName ↔ latestVersion (release tag). Different + // axis but VersionMath.normalizeVersion handles the common cases + // (`v1.2.3`, `release-1.2.0`, `App-v1.2.0-stable`, …). + val latestTag = app.latestVersion + if (!latestTag.isNullOrBlank() && newVersionName.isNotBlank()) { + val verdict = compareAndDecide(newVersionName, latestTag) + if (verdict != VersionVerdict.UNKNOWN) return verdict + } + + return VersionVerdict.UNKNOWN +} + +private fun compareAndDecide( + systemVersion: String, + latestVersion: String, +): VersionVerdict { + val system = VersionMath.normalizeVersion(systemVersion) + val latest = VersionMath.normalizeVersion(latestVersion) + if (system.isEmpty() || latest.isEmpty()) return VersionVerdict.UNKNOWN + + val cmp = VersionMath.compareVersions(system, latest) + return when { + cmp >= 0 -> VersionVerdict.UP_TO_DATE + VersionMath.isVersionNewer(latest, system) -> VersionVerdict.UPDATE_AVAILABLE + else -> VersionVerdict.UNKNOWN + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt new file mode 100644 index 000000000..7283403cb --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/VersionMath.kt @@ -0,0 +1,284 @@ +package zed.rainxch.core.domain.util + +/** + * Single source of truth for version-string normalization and ordering + * across the app. Both the periodic update check + * (`InstalledAppsRepositoryImpl.checkForUpdates`) and the external-install + * detection path (`ExternalInstallVerdict`) now call through here so a + * single comparator change propagates everywhere instead of drifting + * between private copies. + * + * Design invariants: + * - Every public function is pure; no I/O, no time, no randomness. + * - Inputs are `String?` where realistic so callers don't have to + * guard against nulls from the DB or the release feed. + * - Semver-compatible strings get semver semantics (including + * `-preRelease` ordering per spec: `1.0.0-beta < 1.0.0`). + * - Non-semver strings degrade gracefully: we try to extract a + * dotted-digit core (so `release-1.2.3` still compares like + * `1.2.3`), and only fall back to lexicographic comparison when + * the string has no recognisable version core at all. + */ +object VersionMath { + /** + * Reduces a tag or installed-version string to a form that + * [parseSemanticVersion] can digest. + * + * Strategy, in order: + * 1. Trim and strip common tag prefixes (`refs/tags/`, `v`, `V`). + * 2. Drop `+build` metadata (per semver spec, ignored for + * ordering). + * 3. If the result parses as semver, return it. + * 4. Otherwise extract the first dotted-digit substring + * (optionally followed by a `-pre` identifier) and return + * that — handles maintainer prefixes like `release-1.2.0`, + * `App-v1.2.0-stable`, `build-2025.04.10`. + * 5. If nothing numeric is found at all, return the cleaned + * string so the caller can fall back to equality / lex. + * + * Examples: + * `v1.2.3` → `1.2.3` + * `1.2.3+sha.abcd` → `1.2.3` + * `1.2.3-rc1` → `1.2.3-rc1` + * `release-1.2.0` → `1.2.0` + * `App-v1.2.0-stable` → `1.2.0-stable` + * `build-2025.04.10` → `2025.04.10` + * `refs/tags/v1.2.3` → `1.2.3` + * `not-a-version` → `not-a-version` + * `null` → `""` + */ + fun normalizeVersion(version: String?): String { + if (version.isNullOrBlank()) return "" + val withoutRefs = + version + .trim() + .removePrefix("refs/tags/") + .removePrefix("v") + .removePrefix("V") + .trim() + val withoutBuildMetadata = withoutRefs.substringBefore('+') + if (parseSemanticVersion(withoutBuildMetadata) != null) { + return withoutBuildMetadata + } + val match = DOTTED_DIGIT_PATTERN.find(withoutBuildMetadata) + return match?.value ?: withoutBuildMetadata + } + + /** + * Returns `true` if [candidate] is strictly newer than [current] + * after normalization. Handles semver (including pre-release + * ordering per spec) and falls back to lexicographic comparison + * for strings with no parseable version core. + * + * Both arguments are normalized via [normalizeVersion] before + * comparison, so callers can pass raw tag strings. + */ + fun isVersionNewer(candidate: String?, current: String?): Boolean { + val normCandidate = normalizeVersion(candidate) + val normCurrent = normalizeVersion(current) + if (normCandidate.isEmpty() || normCurrent.isEmpty()) return false + if (normCandidate == normCurrent) return false + return compareNormalized(normCandidate, normCurrent) > 0 + } + + /** + * Three-way comparison of two raw version strings after + * normalization. Returns a positive int if [a] > [b], negative if + * [a] < [b], `0` if equal or both empty. + * + * Use this when you need the full ordering (e.g. detecting + * downgrades). Prefer [isVersionNewer] when you just need a + * boolean. + */ + fun compareVersions(a: String?, b: String?): Int { + val normA = normalizeVersion(a) + val normB = normalizeVersion(b) + return compareNormalized(normA, normB) + } + + private fun compareNormalized(a: String, b: String): Int { + if (a == b) return 0 + val parsedA = parseSemanticVersion(a) + val parsedB = parseSemanticVersion(b) + if (parsedA != null && parsedB != null) { + return compareSemver(parsedA, parsedB) + } + // Neither is parseable as semver — last-resort lexicographic + // comparison. Callers should treat this as low-confidence. + return a.compareTo(b) + } + + private fun compareSemver(a: SemanticVersion, b: SemanticVersion): Int { + val maxLen = maxOf(a.numbers.size, b.numbers.size) + for (i in 0 until maxLen) { + val ai = a.numbers.getOrElse(i) { 0L } + val bi = b.numbers.getOrElse(i) { 0L } + if (ai != bi) return ai.compareTo(bi) + } + // Numeric parts equal — spec: stable > pre-release when + // pre-release only present on one side. + return when { + a.preRelease == null && b.preRelease == null -> 0 + a.preRelease == null -> 1 // a has no pre, so a > b + b.preRelease == null -> -1 + else -> comparePreRelease(a.preRelease, b.preRelease) + } + } + + /** + * Compare pre-release identifiers per semver spec: + * - Identifiers consisting of only digits are compared + * numerically. + * - Identifiers with letters are compared lexically. + * - Numeric identifiers always have lower precedence than + * alphanumeric. + * - A larger set of pre-release fields has higher precedence if + * all preceding are equal. + */ + private fun comparePreRelease(a: String, b: String): Int { + val aParts = a.split(".") + val bParts = b.split(".") + for (i in 0 until minOf(aParts.size, bParts.size)) { + val ap = aParts[i] + val bp = bParts[i] + val aNum = ap.toLongOrNull() + val bNum = bp.toLongOrNull() + val cmp = + when { + aNum != null && bNum != null -> aNum.compareTo(bNum) + aNum != null -> -1 // numeric < alphanumeric + bNum != null -> 1 + else -> ap.compareTo(bp) + } + if (cmp != 0) return cmp + } + return aParts.size.compareTo(bParts.size) + } + + private data class SemanticVersion( + val numbers: List, + val preRelease: String?, + ) + + private fun parseSemanticVersion(version: String): SemanticVersion? { + if (version.isEmpty()) return null + val hyphenIndex = version.indexOf('-') + val numberPart = if (hyphenIndex >= 0) version.substring(0, hyphenIndex) else version + val preRelease = + if (hyphenIndex >= 0 && hyphenIndex < version.length - 1) { + version.substring(hyphenIndex + 1) + } else { + null + } + val parts = numberPart.split(".") + val numbers = parts.mapNotNull { it.toLongOrNull() } + if (numbers.isEmpty() || numbers.size != parts.size) return null + return SemanticVersion(numbers, preRelease) + } + + private val DOTTED_DIGIT_PATTERN = Regex("""\d+(?:\.\d+)*(?:-[\w.]+)?""") + + /** + * Heuristic: returns `true` when [tag] contains a well-known + * pre-release marker. + * + * Why this exists: the GitHub API exposes a `prerelease: bool` + * flag on every release, but **maintainers regularly forget to + * set it**. A release tagged `v2.0.0-rc.1` with `prerelease: + * false` is still semantically a pre-release, and surfacing it + * as a stable update to opted-out users is a silent foot-gun. + * `GithubRelease.isEffectivelyPreRelease()` combines the API flag + * with this tag heuristic so one is enough. + * + * Recognised markers (case-insensitive), preceded by `-`, `.`, + * or `_`, and followed by a separator, digit, or end-of-string: + * - `alpha`, `beta`, `rc` — classic semver pre-release labels + * - `preview`, `snapshot`, `canary`, `nightly` — CI / early builds + * - `milestone` / `m\d+` — JetBrains-style milestone builds + * - `ea` — early access (Oracle / JetBrains / vendor convention) + * - `dev` — dev build shorthand + * - `pre` — generic pre-release prefix when followed by digit or dot + * + * Intentionally **not** recognised (too ambiguous / too many + * false positives): + * - `test` (`-test-build` is often a real release artefact) + * - `a\d+` / `b\d+` alone (collides with `-arm64`, `-amd64`, etc.) + * - `stable` / `release` (explicit non-markers) + * + * Examples that match: + * `v1.2.3-beta`, `1.2.3-alpha.1`, `v2-rc.2`, `1.0.0-preview2`, + * `2025.04-nightly`, `v1.0.0-canary.3`, `1.0-m5`, + * `0.9.0-snapshot`, `7.0-ea` + * + * Examples that DO NOT match: + * `v1.2.3`, `1.2.3-stable`, `v1.2.3-android`, `release-1.2.3`, + * `v2.0-final`, `v1.0.0-test-3` + */ + fun isPreReleaseTag(tag: String?): Boolean { + if (tag.isNullOrBlank()) return false + return PRE_RELEASE_MARKER_PATTERN.containsMatchIn(tag) + } + + /** + * Returns the canonical label for the first pre-release marker + * found in [tag], or `null` if none. Intended for UI badges that + * want to show "Beta" / "Alpha" / "RC" instead of a generic + * "Pre-release" pill — a much better signal for users deciding + * whether to install. + * + * Labels are returned in title-case regardless of how they were + * spelled in the tag (so `V1.0-BETA` and `v1.0-beta` both + * resolve to `"Beta"`). + * + * Mapping rules: + * - `alpha` → `Alpha` + * - `beta` → `Beta` + * - `rc` / `rc\d+` → `RC` + * - `preview` → `Preview` + * - `prerelease` → `Pre-release` + * - `snapshot` → `Snapshot` + * - `canary` → `Canary` + * - `nightly` → `Nightly` + * - `milestone` / `m\d+` → `Milestone` + * - `ea` → `Early Access` + * - `dev` → `Dev` + * - `pre` → `Pre` + * + * Callers that also want to treat the API `prerelease` flag as + * authoritative should use this alongside + * [zed.rainxch.core.domain.model.isEffectivelyPreRelease]. + */ + fun preReleaseMarkerLabel(tag: String?): String? { + if (tag.isNullOrBlank()) return null + val match = PRE_RELEASE_MARKER_PATTERN.find(tag) ?: return null + val raw = match.groupValues.getOrNull(1)?.lowercase().orEmpty() + return when { + raw.startsWith("alpha") -> "Alpha" + raw.startsWith("beta") -> "Beta" + raw.startsWith("rc") -> "RC" + raw == "preview" -> "Preview" + raw == "prerelease" -> "Pre-release" + raw == "snapshot" -> "Snapshot" + raw == "canary" -> "Canary" + raw == "nightly" -> "Nightly" + raw == "milestone" || raw.startsWith("m") -> "Milestone" + raw == "ea" -> "Early Access" + raw == "dev" -> "Dev" + raw == "pre" -> "Pre" + else -> null + } + } + + private val PRE_RELEASE_MARKER_PATTERN = + // `\b` word boundaries cleanly separate markers from the + // surrounding tag (so `alpha` matches `v1.0-alpha` but not + // `alphabet`). The trailing `\d*` allows shorthand suffixes + // like `rc1`, `beta2`, `preview3` without requiring a + // separator between the word and the number. Longer + // alternatives (`prerelease`) come before shorter prefixes + // (`pre`) so the regex engine finds the longest match. + Regex( + "\\b(alpha|beta|rc|preview|prerelease|snapshot|canary|nightly|milestone|ea|dev|pre|m\\d+)\\d*\\b", + RegexOption.IGNORE_CASE, + ) +} diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index c2800f4f8..dc535c5b8 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -459,6 +459,17 @@ Select version Pre-release Latest + + + Include betas + Stable only + Toggle beta releases for this app + Switch to stable %1$s + No stable release in %1$d months + No stable release in %1$d days + Active pre-releases but the project hasn\'t shipped a stable build in a while. Betas may not converge to a stable release. + What\'s changed since %1$s + — %1$s — No version Versions Assets diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt index c3aacf117..23f8239e9 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt @@ -42,6 +42,7 @@ val detailsModule = installer = get(), installedAppsRepository = get(), favouritesRepository = get(), + tweaksRepository = get(), logger = get(), ) } diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt index 4b0fc10bd..dd7a1f301 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/system/InstallationManagerImpl.kt @@ -1,12 +1,14 @@ package zed.rainxch.details.data.system import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.ApkPackageInfo import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.repository.TweaksRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.util.AssetVariant import zed.rainxch.details.domain.model.ApkValidationResult @@ -21,6 +23,7 @@ class InstallationManagerImpl( private val installer: Installer, private val installedAppsRepository: InstalledAppsRepository, private val favouritesRepository: FavouritesRepository, + private val tweaksRepository: TweaksRepository, private val logger: GitHubStoreLogger, ) : InstallationManager { override suspend fun validateApk( @@ -79,6 +82,15 @@ class InstallationManagerImpl( val pickedIndex = params.pickedAssetIndex?.takeIf { it >= 0 } val siblingCount = params.siblingAssetCount.takeIf { it > 0 } + // New apps inherit the global "include betas" preference + // so users who track betas across the board don't have to + // flip the per-app toggle for every install. Existing + // rows keep their own value; the global toggle is only + // consulted on creation. + val defaultIncludePreReleases = + runCatching { tweaksRepository.getIncludePreReleases().first() } + .getOrDefault(false) + val installedApp = InstalledApp( packageName = apkInfo.packageName, @@ -117,6 +129,7 @@ class InstallationManagerImpl( assetGlobPattern = fingerprint?.glob, pickedAssetIndex = pickedIndex, pickedAssetSiblingCount = siblingCount, + includePreReleases = defaultIncludePreReleases, ) installedAppsRepository.saveInstalledApp(installedApp) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 18bc704f6..5d5a9bc26 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -112,4 +112,19 @@ sealed interface DetailsAction { * sheet. */ data object UnpinPreferredVariant : DetailsAction + + /** + * Flips the per-app `includePreReleases` flag. Exposed as the + * inline channel toggle on Details so users can opt in/out of + * beta updates without digging into the apps advanced settings + * sheet (GitHub-Store release UX #2). + */ + data object ToggleIncludeBetas : DetailsAction + + /** + * Switches the currently-tracked app from a pre-release to the + * latest stable release. Selects the stable release and + * initiates the install flow on it (GitHub-Store release UX #3). + */ + data object SwitchToStable : DetailsAction } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index 4f210d932..04dcb9b58 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -72,6 +72,7 @@ import zed.rainxch.details.presentation.components.sections.header import zed.rainxch.details.presentation.components.sections.logs import zed.rainxch.details.presentation.components.sections.reportIssue import zed.rainxch.details.presentation.components.sections.stats +import zed.rainxch.details.presentation.components.sections.releaseChannel import zed.rainxch.details.presentation.components.sections.whatsNew import zed.rainxch.details.presentation.components.states.ErrorState import zed.rainxch.details.presentation.model.TranslationTarget @@ -450,6 +451,11 @@ fun DetailsScreen( ) } + releaseChannel( + state = state, + onAction = onAction, + ) + if (state.isComingFromUpdate) { state.selectedRelease?.let { release -> whatsNew( diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index e2295af48..6cc7ca83e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -6,6 +6,7 @@ import zed.rainxch.core.domain.model.GithubRepoSummary import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.SystemArchitecture +import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.AttestationStatus @@ -69,15 +70,82 @@ data class DetailsState( val pendingInstallFilePath: String? = null, val showUninstallConfirmation: Boolean = false, val attestationStatus: AttestationStatus = AttestationStatus.UNCHECKED, + /** + * Days since the most recent stable release when the project is + * actively shipping pre-releases on top of it. `null` means + * either healthy (recent stable) or no applicable signal + * (project has no stable releases at all). Set by the ViewModel + * from `latestStable.publishedAt` vs `Clock.now()` when releases + * load. See release UX #6. + */ + val stalledStableSinceDays: Int? = null, + /** + * Concatenated release notes for every release newer than the + * user's `installedApp.installedVersion`, most-recent-first. + * Populated when the user is tracking the app and at least one + * newer release exists. Null when there's no installed version + * or no newer releases. See release UX #4. + */ + val mergedChangelog: String? = null, + /** + * Release tag for the head of [mergedChangelog] (the version the + * user would jump from). Used to title the merged section as + * "What's changed since v1.2.3". + */ + val mergedChangelogBaseTag: String? = null, + /** + * Whether [latestStableRelease] has at least one asset that the + * platform installer can handle. Computed by the ViewModel + * whenever `allReleases` changes — we can't compute it here + * because the installer's per-platform asset-extension policy + * lives outside the data model. Gates [canSwitchToStable] so + * the rollback chip never advertises an action that would + * silently no-op for releases that ship only source tarballs. + */ + val latestStableHasInstallableAsset: Boolean = false, ) { val filteredReleases: List get() = when (selectedReleaseCategory) { - ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease } - ReleaseCategory.PRE_RELEASE -> allReleases.filter { it.isPrerelease } + ReleaseCategory.STABLE -> allReleases.filter { !it.isEffectivelyPreRelease() } + ReleaseCategory.PRE_RELEASE -> allReleases.filter { it.isEffectivelyPreRelease() } ReleaseCategory.ALL -> allReleases } + /** + * Most recent non-pre-release release, or `null` when the + * project has no stable releases in the current window. Drives + * the "Switch to stable vX.Y.Z" rollback action. + */ + val latestStableRelease: GithubRelease? + get() = + allReleases + .filter { !it.isEffectivelyPreRelease() } + .maxByOrNull { it.publishedAt } + + /** + * True when the install button should expose a "switch to + * stable" rollback affordance: the user is tracking this app, + * is currently on a release that's effectively a pre-release, + * a distinct stable release exists, AND that stable release has + * at least one installable asset on the current platform. The + * handler (`DetailsAction.SwitchToStable`) selects the stable + * release and invokes the normal install path. + */ + val canSwitchToStable: Boolean + get() { + val app = installedApp ?: return false + val stable = latestStableRelease ?: return false + if (!latestStableHasInstallableAsset) return false + val installedIsPreRelease = + allReleases.firstOrNull { it.tagName == app.installedVersion } + ?.isEffectivelyPreRelease() == true + if (!installedIsPreRelease) return false + // Don't offer the button if the stable release IS the + // one the user has already (same tag string). + return stable.tagName != app.installedVersion + } + /** * True when the currently-tracked app has a *parked* install file * that matches the user's current selection (release tag + asset 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 793fd7a41..adce81161 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 @@ -29,6 +29,7 @@ import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.domain.model.RateLimitException +import zed.rainxch.core.domain.model.isEffectivelyPreRelease import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.FavouritesRepository import zed.rainxch.core.domain.repository.InstalledAppsRepository @@ -46,6 +47,7 @@ import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.util.AssetVariant +import zed.rainxch.core.domain.util.VersionMath import zed.rainxch.core.domain.utils.BrowserHelper import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.details.domain.model.ApkValidationResult @@ -394,7 +396,162 @@ class DetailsViewModel( DetailsAction.UnpinPreferredVariant -> { unpinPreferredVariant() } + + DetailsAction.ToggleIncludeBetas -> { + toggleIncludeBetas() + } + + DetailsAction.SwitchToStable -> { + switchToStable() + } + } + } + + /** + * Derived signals surfaced in the Details UX for pre-release + * handling (release UX #4 and #6). Computed once per release-list + * load and re-used across the two call sites that update state + * with a fresh `allReleases`. + */ + private data class ReleaseInsights( + val stalledStableSinceDays: Int?, + val mergedChangelog: String?, + val mergedChangelogBaseTag: String?, + val latestStableHasInstallableAsset: Boolean, + ) + + @OptIn(kotlin.time.ExperimentalTime::class) + private fun computeReleaseInsights( + allReleases: List, + installedApp: InstalledApp?, + ): ReleaseInsights { + // Merged "What's changed since v…": concatenate release notes + // for every release strictly newer than the installed tag, + // most-recent-first. Mirrors what app stores do when the user + // skips versions between updates — they deserve to see every + // intermediate changelog, not just the head one. + val (merged, mergedBase) = + if (installedApp != null && allReleases.size > 1) { + val installedTag = installedApp.installedVersion + val newer = + allReleases.filter { release -> + VersionMath.isVersionNewer(release.tagName, installedTag) + } + if (newer.size >= 2) { + val body = + newer.joinToString(separator = "\n\n") { release -> + val heading = "— ${release.tagName} —" + val notes = release.description?.trim().orEmpty() + if (notes.isEmpty()) heading else "$heading\n$notes" + } + body to installedTag + } else { + null to null + } + } else { + null to null + } + + val latestStable = + allReleases + .filter { !it.isEffectivelyPreRelease() } + .maxByOrNull { it.publishedAt } + + // Stalled-project warning: the project has at least one stable + // release, has shipped pre-releases on top of it, and the last + // stable is older than [STALLED_STABLE_THRESHOLD_DAYS]. That's + // the "beta spiral with no stabilisation" signal that warrants + // a heads-up before the user opts into betas. + val stalledDays: Int? = + run { + val stable = latestStable ?: return@run null + val preReleasesAfter = + allReleases.any { release -> + release.isEffectivelyPreRelease() && + VersionMath.isVersionNewer(release.tagName, stable.tagName) + } + if (!preReleasesAfter) return@run null + val days = daysSinceIso(stable.publishedAt) ?: return@run null + if (days >= STALLED_STABLE_THRESHOLD_DAYS) days else null + } + + val latestStableHasInstallableAsset = + latestStable?.assets?.any { installer.isAssetInstallable(it.name) } == true + + return ReleaseInsights( + stalledStableSinceDays = stalledDays, + mergedChangelog = merged, + mergedChangelogBaseTag = mergedBase, + latestStableHasInstallableAsset = latestStableHasInstallableAsset, + ) + } + + @OptIn(kotlin.time.ExperimentalTime::class) + private fun daysSinceIso(isoTimestamp: String?): Int? { + if (isoTimestamp.isNullOrBlank()) return null + return try { + val published = kotlin.time.Instant.parse(isoTimestamp) + val now = System.now() + val diffMs = now.toEpochMilliseconds() - published.toEpochMilliseconds() + if (diffMs < 0) null else (diffMs / MILLIS_PER_DAY).toInt() + } catch (e: Exception) { + null + } + } + + /** + * Flips the per-app `includePreReleases` flag via + * [InstalledAppsRepository.setIncludePreReleases]. Kicks off a + * fresh `checkForUpdates` so the new channel takes effect + * immediately on-screen instead of waiting for the next + * periodic cycle. + */ + private fun toggleIncludeBetas() { + val app = _state.value.installedApp ?: return + val newValue = !app.includePreReleases + viewModelScope.launch { + try { + installedAppsRepository.setIncludePreReleases( + packageName = app.packageName, + enabled = newValue, + ) + // Re-validate against the new channel immediately so + // the user sees the result of the toggle in the next + // frame (the DB observer will also refresh state). + installedAppsRepository.checkForUpdates(app.packageName) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + logger.warn("toggleIncludeBetas failed for ${app.packageName}: ${t.message}") + } + } + } + + /** + * Switches the currently-tracked app to the latest stable + * release: selects it as the picked release, and triggers the + * normal install flow. Reuses the existing `InstallPrimary` + * path so downgrade warnings, signing-key checks, and asset + * picking all kick in exactly as they do for a manual version + * selection. + */ + private fun switchToStable() { + val stable = _state.value.latestStableRelease ?: return + // Defence in depth: the chip should already be hidden when the + // stable release ships nothing the platform installer can + // handle, but a stale state could still drive us here. Resolve + // the primary asset up front and bail before the dispatch chain + // would otherwise reach `install()` with `primaryAsset = null` + // and silently no-op. + val (_, primary) = recomputeAssetsForRelease(stable, _state.value.installedApp) + if (primary == null) { + logger.warn( + "switchToStable: stable ${stable.tagName} has no installable asset; skipping", + ) + return } + onAction(DetailsAction.SelectRelease(stable)) + onAction(DetailsAction.InstallPrimary) } /** @@ -528,17 +685,26 @@ class DetailsViewModel( // the category too so the UI doesn't end up with a category // selected but no matching release. val byPrevCategory = when (prevCategory) { - ReleaseCategory.STABLE -> releases.firstOrNull { !it.isPrerelease } - ReleaseCategory.PRE_RELEASE -> releases.firstOrNull { it.isPrerelease } + ReleaseCategory.STABLE -> releases.firstOrNull { !it.isEffectivelyPreRelease() } + ReleaseCategory.PRE_RELEASE -> releases.firstOrNull { it.isEffectivelyPreRelease() } ReleaseCategory.ALL -> releases.firstOrNull() } val selected = byPrevCategory - ?: releases.firstOrNull { !it.isPrerelease } + ?: releases.firstOrNull { !it.isEffectivelyPreRelease() } ?: releases.firstOrNull() - val resolvedCategory = - if (byPrevCategory != null) prevCategory else ReleaseCategory.STABLE + // When the previous category yields nothing, derive the + // category from the actually-selected release so the + // filter matches what's on screen — otherwise a + // pre-release-only project leaves the user with category + // STABLE and an empty filtered list. + val resolvedCategory = when { + byPrevCategory != null -> prevCategory + selected?.isEffectivelyPreRelease() == true -> ReleaseCategory.PRE_RELEASE + else -> ReleaseCategory.STABLE + } val (installable, primary) = recomputeAssetsForRelease(selected, _state.value.installedApp) + val insights = computeReleaseInsights(releases, _state.value.installedApp) _state.update { it.copy( allReleases = releases, @@ -548,6 +714,11 @@ class DetailsViewModel( selectedReleaseCategory = resolvedCategory, installableAssets = installable, primaryAsset = primary, + stalledStableSinceDays = insights.stalledStableSinceDays, + mergedChangelog = insights.mergedChangelog, + mergedChangelogBaseTag = insights.mergedChangelogBaseTag, + latestStableHasInstallableAsset = + insights.latestStableHasInstallableAsset, ) } } catch (e: CancellationException) { @@ -604,7 +775,21 @@ class DetailsViewModel( .getAppByRepoIdAsFlow(repoId) .distinctUntilChanged() .collect { app -> - _state.update { it.copy(installedApp = app) } + // Recompute merged changelog + stalled signals + // against the new installed version — if the + // user just updated externally, the installed + // tag flips and what they've "missed" changes. + val insights = computeReleaseInsights(_state.value.allReleases, app) + _state.update { + it.copy( + installedApp = app, + mergedChangelog = insights.mergedChangelog, + mergedChangelogBaseTag = insights.mergedChangelogBaseTag, + stalledStableSinceDays = insights.stalledStableSinceDays, + latestStableHasInstallableAsset = + insights.latestStableHasInstallableAsset, + ) + } } } } @@ -739,8 +924,8 @@ class DetailsViewModel( val newCategory = action.category val filtered = when (newCategory) { - ReleaseCategory.STABLE -> _state.value.allReleases.filter { !it.isPrerelease } - ReleaseCategory.PRE_RELEASE -> _state.value.allReleases.filter { it.isPrerelease } + ReleaseCategory.STABLE -> _state.value.allReleases.filter { !it.isEffectivelyPreRelease() } + ReleaseCategory.PRE_RELEASE -> _state.value.allReleases.filter { it.isEffectivelyPreRelease() } ReleaseCategory.ALL -> _state.value.allReleases } val newSelected = filtered.firstOrNull() @@ -2114,7 +2299,7 @@ class DetailsViewModel( } val selectedRelease = - allReleases.firstOrNull { !it.isPrerelease } + allReleases.firstOrNull { !it.isEffectivelyPreRelease() } ?: allReleases.firstOrNull() val (installable, primary) = recomputeAssetsForRelease(selectedRelease, installedApp) @@ -2126,6 +2311,8 @@ class DetailsViewModel( logger.debug("Loaded repo: ${repo.name}, installedApp: ${installedApp?.packageName}") + val insights = computeReleaseInsights(allReleases, installedApp) + _state.value = _state.value.copy( isLoading = false, @@ -2151,6 +2338,11 @@ class DetailsViewModel( deviceLanguageCode = translationRepository.getDeviceLanguageCode(), isComingFromUpdate = isComingFromUpdate, isLiquidGlassEnabled = liquidGlassEnabled, + stalledStableSinceDays = insights.stalledStableSinceDays, + mergedChangelog = insights.mergedChangelog, + mergedChangelogBaseTag = insights.mergedChangelogBaseTag, + latestStableHasInstallableAsset = + insights.latestStableHasInstallableAsset, ) telemetryRepository.recordRepoViewed(repo.id) @@ -2231,5 +2423,7 @@ class DetailsViewModel( private companion object { const val OBTAINIUM_REPO_ID: Long = 523534328 const val APP_MANAGER_REPO_ID: Long = 268006778 + const val STALLED_STABLE_THRESHOLD_DAYS = 180 + const val MILLIS_PER_DAY = 86_400_000L } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt index 4bd731fc5..68b675c95 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.core.domain.model.isEffectivelyPreRelease +import zed.rainxch.core.domain.model.preReleaseLabel import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.githubstore.core.presentation.res.Res import zed.rainxch.githubstore.core.presentation.res.latest_badge @@ -220,13 +222,20 @@ private fun VersionListItem( ) } } - if (release.isPrerelease) { + if (release.isEffectivelyPreRelease()) { + // Prefer the specific marker ("Beta", "RC", "Alpha"…) + // over the generic "Pre-release" pill — a stronger + // signal for users deciding whether to install. Falls + // back to the generic badge only when the API flag + // marks a release as pre-release but no recognised + // marker is in the tag or name. + val specificLabel = release.preReleaseLabel() Surface( shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.tertiaryContainer, ) { Text( - text = stringResource(Res.string.pre_release_badge), + text = specificLabel ?: stringResource(Res.string.pre_release_badge), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), color = MaterialTheme.colorScheme.onTertiaryContainer, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt new file mode 100644 index 000000000..1f422ec5c --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/ReleaseChannel.kt @@ -0,0 +1,258 @@ +package zed.rainxch.details.presentation.components.sections + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.details.presentation.DetailsAction +import zed.rainxch.details.presentation.DetailsState +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.action_switch_to_stable +import zed.rainxch.githubstore.core.presentation.res.channel_chip_include_betas +import zed.rainxch.githubstore.core.presentation.res.channel_chip_stable_only +import zed.rainxch.githubstore.core.presentation.res.merged_whats_changed_title +import zed.rainxch.githubstore.core.presentation.res.stalled_project_warning_days +import zed.rainxch.githubstore.core.presentation.res.stalled_project_warning_description +import zed.rainxch.githubstore.core.presentation.res.stalled_project_warning_months + +/** + * Release-channel UX bundle for the Details screen + * (GitHub-Store release UX #2, #3, #4, #6): + * - Inline chip to toggle per-app pre-release channel. + * - "Switch to stable vX.Y.Z" chip when user is on a pre-release + * and a stable is available. + * - Stalled-project warning card when the latest stable is old + * but pre-releases are still flowing. + * - Merged "What's changed since v…" card that concatenates + * release notes across every version the user has skipped. + * + * All four are additive — nothing renders when the app isn't + * tracked or when the corresponding signal is absent. + */ +fun LazyListScope.releaseChannel( + state: DetailsState, + onAction: (DetailsAction) -> Unit, +) { + val installedApp = state.installedApp + val showMerged = !state.mergedChangelog.isNullOrBlank() && state.mergedChangelogBaseTag != null + val showStalled = state.stalledStableSinceDays != null + val showSwitchToStable = state.canSwitchToStable + val showChannelChip = installedApp != null + + if (!showMerged && !showStalled && !showSwitchToStable && !showChannelChip) return + + item(key = "release-channel-controls") { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (showChannelChip || showSwitchToStable) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (installedApp != null) { + val includeBetas = installedApp.includePreReleases + val channelLabel = + if (includeBetas) { + stringResource(Res.string.channel_chip_include_betas) + } else { + stringResource(Res.string.channel_chip_stable_only) + } + ChannelChip( + label = channelLabel, + icon = Icons.Default.Science, + // Visually signal the "hot" channel when the user + // has opted into betas; keep it muted when they're + // on the default stable-only track. + tint = + if (includeBetas) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClick = { onAction(DetailsAction.ToggleIncludeBetas) }, + // Mirror the visible label so screen readers hear + // the current channel ("Include betas" / "Stable + // only") instead of the previous static + // "Toggle beta releases for this app" string, + // which gave no indication of which side the + // toggle is currently on. + contentDescriptionText = channelLabel, + ) + } + + if (showSwitchToStable) { + val stable = state.latestStableRelease + if (stable != null) { + ChannelChip( + label = + stringResource( + Res.string.action_switch_to_stable, + stable.tagName, + ), + icon = Icons.Default.Restore, + tint = MaterialTheme.colorScheme.primary, + onClick = { onAction(DetailsAction.SwitchToStable) }, + contentDescriptionText = null, + ) + } + } + } + } + + val stalledDays = state.stalledStableSinceDays + if (stalledDays != null) { + val days = stalledDays + val title = + if (days >= 30) { + stringResource(Res.string.stalled_project_warning_months, days / 30) + } else { + stringResource(Res.string.stalled_project_warning_days, days) + } + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(12.dp), + ) { + Icon( + imageVector = Icons.Default.WarningAmber, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.size(12.dp)) + Column { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource( + Res.string.stalled_project_warning_description, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + val mergedBaseTag = state.mergedChangelogBaseTag + if (mergedBaseTag != null && !state.mergedChangelog.isNullOrBlank()) { + val baseTag = mergedBaseTag + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.size(8.dp)) + Text( + text = stringResource( + Res.string.merged_whats_changed_title, + baseTag, + ), + style = MaterialTheme.typography.titleSmall, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = state.mergedChangelog.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun ChannelChip( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + tint: androidx.compose.ui.graphics.Color, + onClick: () -> Unit, + contentDescriptionText: String?, +) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier + .clickable(onClick = onClick) + .then( + if (contentDescriptionText != null) { + Modifier.semantics { contentDescription = contentDescriptionText } + } else { + Modifier + }, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.size(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = tint, + ) + } + } +}