mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Improve version comparison
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user