Skip to content
Merged
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
621 changes: 621 additions & 0 deletions core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/11.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import zed.rainxch.core.data.local.db.migrations.MIGRATION_6_7
import zed.rainxch.core.data.local.db.migrations.MIGRATION_7_8
import zed.rainxch.core.data.local.db.migrations.MIGRATION_8_9
import zed.rainxch.core.data.local.db.migrations.MIGRATION_9_10
import zed.rainxch.core.data.local.db.migrations.MIGRATION_10_11

fun initDatabase(context: Context): AppDatabase {
val appContext = context.applicationContext
Expand All @@ -31,5 +32,6 @@ fun initDatabase(context: Context): AppDatabase {
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11,
).build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package zed.rainxch.core.data.local.db.migrations

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

/**
* Adds per-app preferred-variant tracking to the installed_apps table:
* - preferredAssetVariant: stable identifier (e.g. "arm64-v8a") for the
* asset the user wants to install. Survives version bumps because it's
* derived from the part of the filename that doesn't change.
* - preferredVariantStale: flipped to true by checkForUpdates when the
* persisted variant cannot be matched in a fresh release; the UI then
* prompts the user to pick again.
*
* Both columns default to safe "no preference" values so existing rows
* keep their current auto-pick behaviour.
*/
val MIGRATION_10_11 =
object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE installed_apps ADD COLUMN preferredAssetVariant TEXT")
db.execSQL(
"ALTER TABLE installed_apps ADD COLUMN preferredVariantStale INTEGER NOT NULL DEFAULT 0",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import zed.rainxch.core.data.local.db.entities.UpdateHistoryEntity
SeenRepoEntity::class,
SearchHistoryEntity::class,
],
version = 10,
version = 11,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,42 @@ interface InstalledAppDao {
fallback: Boolean,
)

/**
* Sets the user's preferred asset variant. Always clears the
* "stale" flag in the same write because the user has just made an
* explicit choice — whatever was stored before is no longer stale,
* even if the new variant is the same value.
*/
@Query(
"""
UPDATE installed_apps
SET preferredAssetVariant = :variant,
preferredVariantStale = 0
WHERE packageName = :packageName
""",
)
suspend fun updatePreferredVariant(
packageName: String,
variant: String?,
)

/**
* Sets `preferredVariantStale` for [packageName]. Used by
* `checkForUpdates` when the persisted variant cannot be matched
* against the assets in a fresh release.
*/
@Query(
"""
UPDATE installed_apps
SET preferredVariantStale = :stale
WHERE packageName = :packageName
""",
)
suspend fun updateVariantStaleness(
packageName: String,
stale: Boolean,
)

@Query("UPDATE installed_apps SET lastCheckedAt = :timestamp WHERE packageName = :packageName")
suspend fun updateLastChecked(
packageName: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package zed.rainxch.core.data.local.db.entities

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import zed.rainxch.core.domain.model.InstallSource
Expand Down Expand Up @@ -50,6 +51,36 @@ data class InstalledAppEntity(
* When true, the update checker walks backward through past releases until
* it finds one whose assets match [assetFilterRegex]. Required for
* monorepos where the latest release is for a *different* app.
*
* `@ColumnInfo(defaultValue = "0")` matches `MIGRATION_9_10`'s
* `DEFAULT 0` clause so Room's schema validator doesn't flag the
* column on freshly-built databases.
*/
@ColumnInfo(defaultValue = "0")
val fallbackToOlderReleases: Boolean = false,
/**
* Stable identifier for the asset variant the user wants to track —
* for example `arm64-v8a`, `universal`, or `x86_64`. Derived from the
* picked asset filename by stripping the version segment, so it
* survives release-to-release version bumps.
*
* `null` means "use the platform installer's automatic picker"
* (today's behaviour). When non-null, [checkForUpdates] resolves the
* matching asset on every check; if no asset in the new release
* matches the variant, [preferredVariantStale] is flipped to true.
*/
val preferredAssetVariant: String? = null,
/**
* Set to true by the update checker when the persisted
* [preferredAssetVariant] cannot be found in the latest release's
* assets — typically because the maintainer renamed or restructured
* the artefacts. The UI surfaces this with a "variant changed —
* pick again" prompt and clears it once the user picks a new variant.
*
* `@ColumnInfo(defaultValue = "0")` matches `MIGRATION_10_11`'s
* `DEFAULT 0` clause so Room's schema validator doesn't flag a
* mismatch between the migrated table and the freshly-created one.
*/
@ColumnInfo(defaultValue = "0")
val preferredVariantStale: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ fun InstalledApp.toEntity(): InstalledAppEntity =
includePreReleases = includePreReleases,
assetFilterRegex = assetFilterRegex,
fallbackToOlderReleases = fallbackToOlderReleases,
preferredAssetVariant = preferredAssetVariant,
preferredVariantStale = preferredVariantStale,
)

fun InstalledAppEntity.toDomain(): InstalledApp =
Expand Down Expand Up @@ -79,4 +81,6 @@ fun InstalledAppEntity.toDomain(): InstalledApp =
includePreReleases = includePreReleases,
assetFilterRegex = assetFilterRegex,
fallbackToOlderReleases = fallbackToOlderReleases,
preferredAssetVariant = preferredAssetVariant,
preferredVariantStale = preferredVariantStale,
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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.util.AssetFilter
import zed.rainxch.core.domain.util.AssetVariant

class InstalledAppsRepositoryImpl(
private val database: AppDatabase,
Expand Down Expand Up @@ -132,18 +133,28 @@ class InstalledAppsRepositoryImpl(
* Result of [resolveTrackedRelease] — a candidate release plus the asset
* the installer should download for it. `null` when no release in the
* window contains a usable asset (after filter + arch matching).
*
* [variantWasLost] is true when the user has a [InstalledApp.preferredAssetVariant]
* set but none of this release's assets matched it. The caller flips
* `preferredVariantStale` based on this so the UI can prompt the user
* to pick a new variant.
*/
private data class ResolvedRelease(
val release: GithubRelease,
val primaryAsset: GithubAsset,
val variantWasLost: Boolean,
)

/**
* Walks [releases] (already in newest-first order) and returns the first
* release whose installable asset list — after applying [filter] — yields
* a primary asset for the current architecture. When [filter] is null,
* only the first release in the window is considered: this preserves the
* pre-existing behaviour for apps that don't track a monorepo.
* a usable asset. The picker tries, in order:
* 1. The user's [preferredVariant] (if set)
* 2. The platform installer's architecture-aware auto-pick
*
* When [filter] is null, only the first release in the window is
* considered: this preserves the pre-existing behaviour for apps that
* don't track a monorepo.
*
* When [filter] is non-null and [fallbackToOlderReleases] is false, the
* walker still only inspects the first release. The semantics are:
Expand All @@ -155,6 +166,7 @@ class InstalledAppsRepositoryImpl(
releases: List<GithubRelease>,
filter: AssetFilter?,
fallbackToOlderReleases: Boolean,
preferredVariant: String?,
): ResolvedRelease? {
if (releases.isEmpty()) return null

Expand All @@ -173,8 +185,20 @@ class InstalledAppsRepositoryImpl(
else installableForPlatform.filter { filter.matches(it.name) }

if (installableForApp.isEmpty()) continue
val primary = installer.choosePrimaryAsset(installableForApp) ?: continue
return ResolvedRelease(release, primary)

// Variant resolution: try the user's pinned variant first.
// Falling back to the auto-picker is intentional — we'd
// rather hand the user a working install than block updates,
// and the caller will mark `variantWasLost` so the UI can
// surface the discrepancy.
val variantMatch =
AssetVariant.resolvePreferredAsset(installableForApp, preferredVariant)
val primary = variantMatch
?: installer.choosePrimaryAsset(installableForApp)
?: continue
val variantWasLost = preferredVariant != null && variantMatch == null

return ResolvedRelease(release, primary, variantWasLost)
}

return null
Expand Down Expand Up @@ -216,6 +240,7 @@ class InstalledAppsRepositoryImpl(
releases = releases,
filter = compiledFilter,
fallbackToOlderReleases = app.fallbackToOlderReleases,
preferredVariant = app.preferredAssetVariant,
)

if (resolved == null) {
Expand All @@ -230,7 +255,7 @@ class InstalledAppsRepositoryImpl(
return false
}

val (matchedRelease, primaryAsset) = resolved
val (matchedRelease, primaryAsset, variantWasLost) = resolved
val normalizedInstalledTag = normalizeVersion(app.installedVersion)
val normalizedLatestTag = normalizeVersion(matchedRelease.tagName)

Expand All @@ -246,7 +271,7 @@ class InstalledAppsRepositoryImpl(
"installedTag=${app.installedVersion}, " +
"matchedTag=${matchedRelease.tagName}, " +
"matchedAsset=${primaryAsset.name}, " +
"isUpdate=$isUpdateAvailable"
"isUpdate=$isUpdateAvailable, variantLost=$variantWasLost"
}

installedAppsDao.updateVersionInfo(
Expand All @@ -263,6 +288,14 @@ class InstalledAppsRepositoryImpl(
latestReleasePublishedAt = matchedRelease.publishedAt,
)

// Sync the staleness flag with what the resolver actually
// observed: flip on when the user's pinned variant has
// disappeared from the latest matching release, flip off
// (and only when previously set) when it's back in business.
if (variantWasLost != app.preferredVariantStale) {
installedAppsDao.updateVariantStaleness(packageName, variantWasLost)
}

return isUpdateAvailable
} catch (e: Exception) {
Logger.e { "Failed to check updates for $packageName: ${e.message}" }
Expand Down Expand Up @@ -382,6 +415,31 @@ class InstalledAppsRepositoryImpl(
}
}

override suspend fun setPreferredVariant(
packageName: String,
variant: String?,
) {
val normalized = variant?.trim()?.takeIf { it.isNotEmpty() }
installedAppsDao.updatePreferredVariant(
packageName = packageName,
variant = normalized,
)

// Re-run the update check so cached `latestAsset*` columns point
// at the variant the user just chose. Failures here are
// non-fatal: persistence is the authoritative step.
try {
checkForUpdates(packageName)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Logger.w {
"Saved new variant for $packageName but immediate " +
"re-check failed: ${e.message}"
}
}
}

override suspend fun previewMatchingAssets(
owner: String,
repo: String,
Expand Down Expand Up @@ -428,7 +486,49 @@ class InstalledAppsRepositoryImpl(
)
}

private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim()
/**
* 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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,22 @@ data class ExportedApp(
// old v1 JSON files decoding without changes.
val assetFilterRegex: String? = null,
val fallbackToOlderReleases: Boolean = false,
// Preferred-variant tracking (added in export schema v3). Defaults
// keep older exports decoding without changes.
val preferredAssetVariant: String? = null,
)

@Serializable
data class ExportedAppList(
/**
* Export schema version. Bumped to 2 when monorepo fields were added
* to [ExportedApp]. Older v1 exports still decode correctly because
* the new fields have safe defaults.
* Export schema version.
* - v2: added [ExportedApp.assetFilterRegex] / [ExportedApp.fallbackToOlderReleases]
* - v3: added [ExportedApp.preferredAssetVariant]
*
* All older versions still decode correctly because the new fields
* have safe defaults.
*/
val version: Int = 2,
val version: Int = 3,
val exportedAt: Long = 0L,
val apps: List<ExportedApp> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,19 @@ data class InstalledApp(
* where the latest release belongs to a sibling app.
*/
val fallbackToOlderReleases: Boolean = false,
/**
* Stable identifier for the asset variant (e.g. `arm64-v8a`,
* `universal`) that the user has chosen to track. Derived from the
* picked asset filename's tail (everything after the version) so it
* survives version bumps. `null` means "auto-pick by architecture".
*/
val preferredAssetVariant: String? = null,
/**
* Set when the update checker can't find an asset matching
* [preferredAssetVariant] in a fresh release — typically because the
* maintainer renamed or restructured the artefacts. The UI shows a
* "variant changed" prompt; the flag is cleared once the user picks
* a new variant.
*/
val preferredVariantStale: Boolean = false,
)
Loading