Improve version comparison

This commit is contained in:
franzap
2026-02-06 13:48:43 -03:00
parent 38dcc3981f
commit dced962101
5 changed files with 37 additions and 251 deletions
@@ -9,8 +9,6 @@ import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/device_capabilities.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/install_operation.dart';
import 'package:zapstore/utils/version_utils.dart';
export 'device_capabilities.dart';
export 'install_operation.dart';
@@ -1361,23 +1359,30 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
Future<void> syncInstalledPackages();
bool canInstall(FileMetadata m, String version, {int? versionCode}) {
final installed = state.installed[m.appIdentifier];
if (installed == null) return true;
return canUpgrade(
installed.versionCode?.toString() ?? installed.version,
versionCode?.toString() ?? version,
);
/// Whether [latest] is an update over the installed version of the same app.
///
/// Comparison uses Android versionCode only. Returns false when either
/// versionCode is unavailable or the app is not installed.
bool hasUpdate(String appId, FileMetadata latest) {
final installed = state.installed[appId];
if (installed == null) return false;
final installedCode = installed.versionCode;
final latestCode = latest.versionCode;
if (installedCode == null || latestCode == null) return false;
return latestCode > installedCode;
}
bool canUpdate(FileMetadata m) {
final installed = state.installed[m.appIdentifier];
/// Whether [latest] would be a downgrade from the installed version.
///
/// Comparison uses Android versionCode only. Returns false when either
/// versionCode is unavailable or the app is not installed.
bool hasDowngrade(String appId, FileMetadata latest) {
final installed = state.installed[appId];
if (installed == null) return false;
return canUpgrade(
installed.versionCode?.toString() ?? installed.version,
m.versionCode?.toString() ?? m.version,
);
final installedCode = installed.versionCode;
final latestCode = latest.versionCode;
if (installedCode == null || latestCode == null) return false;
return latestCode < installedCode;
}
}
+3 -10
View File
@@ -5,7 +5,6 @@ import 'package:models/models.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/version_utils.dart';
/// How often to poll for updates from remote relays
const _pollInterval = Duration(minutes: 5);
@@ -380,8 +379,9 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
final pkg = installedMap[app.identifier]!;
final latest = app.latestFileMetadata;
// Determine if update available using local data
final hasUpdate = latest != null && _hasUpdate(pkg, latest);
// Determine if update available using versionCode (via PackageManager)
final pm = ref.read(packageManagerProvider.notifier);
final hasUpdate = latest != null && pm.hasUpdate(app.identifier, latest);
if (hasUpdate) {
if (pkg.canInstallSilently) {
@@ -424,13 +424,6 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
);
}
/// Check if an update is available by comparing versions
bool _hasUpdate(PackageInfo installed, FileMetadata latest) {
if (latest.versionCode != null && installed.versionCode != null) {
return latest.versionCode! > installed.versionCode!;
}
return canUpgrade(installed.version, latest.version);
}
}
final categorizedUpdatesProvider =
+8 -23
View File
@@ -3,7 +3,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/version_utils.dart';
export 'package:zapstore/constants/app_constants.dart';
@@ -41,34 +40,20 @@ extension AppExt on App {
latestRelease.value?.latestAsset.value ??
latestRelease.value?.latestMetadata.value;
/// Whether there is an update available for the installed app
/// Compares versionCode first when available, otherwise falls back to
/// semantic version comparison.
/// Whether there is an update available for the installed app.
/// Delegates to PackageManager (versionCode-only comparison).
bool get hasUpdate {
final installed = installedPackage;
final latest = latestFileMetadata;
if (installed == null || latest == null) return false;
if (latest.versionCode != null && installed.versionCode != null) {
return latest.versionCode! > installed.versionCode!;
if (latest == null) return false;
return ref.read(packageManagerProvider.notifier).hasUpdate(identifier, latest);
}
return canUpgrade(installed.version, latest.version);
}
/// Whether the relay version would be a downgrade from the installed version
/// Compares versionCode first when available, otherwise falls back to
/// semantic version comparison.
/// Whether the relay version would be a downgrade from the installed version.
/// Delegates to PackageManager (versionCode-only comparison).
bool get hasDowngrade {
final installed = installedPackage;
final latest = latestFileMetadata;
if (installed == null || latest == null) return false;
if (latest.versionCode != null && installed.versionCode != null) {
return latest.versionCode! < installed.versionCode!;
}
return canUpgrade(latest.version, installed.version);
if (latest == null) return false;
return ref.read(packageManagerProvider.notifier).hasDowngrade(identifier, latest);
}
/// Whether the installed app is up to date (installed and no update)
-202
View File
@@ -1,202 +0,0 @@
// Version comparison
// SPDX-License-Identifier: MIT
/// Version comparison helper.
///
/// canUpgrade(installed, current) → true if `current` is newer than `installed`.
///
/// The rules implemented are a pragmatic superset of Semantic Versioning:
///
/// • Optional `v`/`V` prefix is ignored (e.g. `v1.2.3`).
/// • The core version is an arbitrary number of dot-separated numeric parts,
/// compared *numerically*, not lexicographically (1.2.10 > 1.2.3).
/// • A version containing a pre-release label (`-alpha`, `-beta.1`, `-rc`,
/// etc.) is considered *older* than the same version without one
/// (1.0.0 < 1.0.0-rc).
/// • When both versions have pre-release labels, they are compared component
/// by component, splitting on `.`:
/// – purely numeric identifiers are compared numerically;
/// – non-numeric identifiers are compared lexically (ASCII order);
/// – numeric identifiers have *lower* precedence than non-numeric ones
/// (SemVer rule 11).
/// • Build metadata introduced by `+` (e.g. `1.0.0+20180101`) never affects
/// ordering and is ignored.
///
/// The implementation is completely self-contained – no external packages.
bool canUpgrade(String installed, String current) {
return _Version.parse(current).compareTo(_Version.parse(installed)) > 0;
}
/* -------------------------------------------------------------------------- */
/* Internal implementation */
/* -------------------------------------------------------------------------- */
class _Version implements Comparable<_Version> {
_Version(this.parts, this.preRelease);
/// Dot-separated version components (numeric or string).
final List<_VersionPart> parts;
/// Pre-release identifiers, possibly empty.
final List<_Identifier> preRelease;
/* ------------------------------- parsing -------------------------------- */
static final _coreRegex = RegExp(r'^v?', caseSensitive: false);
static _Version parse(String input) {
// 1. strip leading 'v' or 'V'
input = input.replaceFirst(_coreRegex, '');
// 2. drop build metadata (`+...`)
final plus = input.indexOf('+');
if (plus != -1) input = input.substring(0, plus);
// 3. split pre-release (`-...`)
String core;
String? pre;
final dash = input.indexOf('-');
if (dash == -1) {
core = input;
pre = null;
} else {
core = input.substring(0, dash);
pre = input.substring(dash + 1);
}
final parts = _parseVersionParts(core);
final preParts = <_Identifier>[];
if (pre != null) {
for (final id in pre.split('.')) {
preParts.add(_Identifier(id));
}
}
return _Version(parts, preParts);
}
static List<_VersionPart> _parseVersionParts(String core) {
return core
.split('.')
.map((part) => _VersionPart(part))
.toList(growable: false);
}
/* ----------------------------- comparison ------------------------------- */
@override
int compareTo(_Version other) {
// 1. compare version parts
final maxLen = parts.length > other.parts.length
? parts.length
: other.parts.length;
for (var i = 0; i < maxLen; i++) {
final a = i < parts.length ? parts[i] : _VersionPart.zero;
final b = i < other.parts.length ? other.parts[i] : _VersionPart.zero;
final cmp = a.compareTo(b);
if (cmp != 0) return cmp;
}
// 2. handle pre-release vs stable
final aHasPre = preRelease.isNotEmpty;
final bHasPre = other.preRelease.isNotEmpty;
if (aHasPre && !bHasPre) return -1; // pre-release < stable
if (!aHasPre && bHasPre) return 1; // stable > pre-release
// 3. both stable or both prerelease
final maxPre = preRelease.length > other.preRelease.length
? preRelease.length
: other.preRelease.length;
for (var i = 0; i < maxPre; i++) {
final aId = i < preRelease.length ? preRelease[i] : _Identifier.empty;
final bId = i < other.preRelease.length
? other.preRelease[i]
: _Identifier.empty;
final cmp = aId.compareTo(bId);
if (cmp != 0) return cmp;
}
// versions are identical
return 0;
}
@override
String toString() {
final core = parts.join('.');
if (preRelease.isEmpty) return core;
return '$core-${preRelease.join('.')}';
}
}
/* -------------------------------------------------------------------------- */
class _VersionPart implements Comparable<_VersionPart> {
_VersionPart(String raw) : isNumeric = _numeric.hasMatch(raw), value = raw;
static final _numeric = RegExp(r'^[0-9]+$');
/// Special value for absent parts when lengths differ.
static final zero = _VersionPart('0');
final bool isNumeric;
final String value;
@override
int compareTo(_VersionPart other) {
// If both are numeric, compare numerically
if (isNumeric && other.isNumeric) {
return int.parse(value).compareTo(int.parse(other.value));
}
// If both are non-numeric, compare lexically
if (!isNumeric && !other.isNumeric) {
return value.compareTo(other.value);
}
// Mixed: numeric parts are considered "less than" non-numeric parts
// This means "1.0.0" < "android.1.0"
return isNumeric ? -1 : 1;
}
@override
String toString() => value;
}
class _Identifier implements Comparable<_Identifier> {
_Identifier(String raw) : isNumeric = _numeric.hasMatch(raw), value = raw;
static final _numeric = RegExp(r'^[0-9]+$');
/// Special value for absent identifiers when lengths differ.
static final empty = _Identifier('').._isEmpty = true;
final bool isNumeric;
final String value;
bool _isEmpty = false;
@override
int compareTo(_Identifier other) {
// Empty identifiers are considered lower
if (_isEmpty && other._isEmpty) return 0;
if (_isEmpty) return -1;
if (other._isEmpty) return 1;
// Numeric vs non-numeric
if (isNumeric && other.isNumeric) {
// numeric compare
return int.parse(value).compareTo(int.parse(other.value));
}
if (isNumeric != other.isNumeric) {
// numeric identifiers have lower precedence than non-numeric
return isNumeric ? -1 : 1;
}
// Both non-numeric: lexicographic ASCII
return value.compareTo(other.value);
}
@override
String toString() => value;
}
+5
View File
@@ -45,6 +45,11 @@ If any invariant is violated, the implementation is incorrect.
- All user-visible processes must have explicit states (loading, empty, success, error).
- Silent failures are unacceptable.
## Update & Downgrade Detection
- Update availability MUST be determined solely by comparing Android `versionCode` integers — never by comparing version name strings.
- If either the installed or the available `versionCode` is missing, the app MUST NOT be considered as having an update or downgrade.
## Widget State Management
- Use Flutter Hooks (`HookWidget`, `HookConsumerWidget`) instead of `StatefulWidget` for local widget state.