mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48: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/device_capabilities.dart';
|
||||||
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
||||||
import 'package:zapstore/services/package_manager/install_operation.dart';
|
import 'package:zapstore/services/package_manager/install_operation.dart';
|
||||||
import 'package:zapstore/utils/version_utils.dart';
|
|
||||||
|
|
||||||
export 'device_capabilities.dart';
|
export 'device_capabilities.dart';
|
||||||
export 'install_operation.dart';
|
export 'install_operation.dart';
|
||||||
|
|
||||||
@@ -1361,23 +1359,30 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
|||||||
|
|
||||||
Future<void> syncInstalledPackages();
|
Future<void> syncInstalledPackages();
|
||||||
|
|
||||||
bool canInstall(FileMetadata m, String version, {int? versionCode}) {
|
/// Whether [latest] is an update over the installed version of the same app.
|
||||||
final installed = state.installed[m.appIdentifier];
|
///
|
||||||
if (installed == null) return true;
|
/// Comparison uses Android versionCode only. Returns false when either
|
||||||
|
/// versionCode is unavailable or the app is not installed.
|
||||||
return canUpgrade(
|
bool hasUpdate(String appId, FileMetadata latest) {
|
||||||
installed.versionCode?.toString() ?? installed.version,
|
final installed = state.installed[appId];
|
||||||
versionCode?.toString() ?? version,
|
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) {
|
/// Whether [latest] would be a downgrade from the installed version.
|
||||||
final installed = state.installed[m.appIdentifier];
|
///
|
||||||
|
/// 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;
|
if (installed == null) return false;
|
||||||
return canUpgrade(
|
final installedCode = installed.versionCode;
|
||||||
installed.versionCode?.toString() ?? installed.version,
|
final latestCode = latest.versionCode;
|
||||||
m.versionCode?.toString() ?? m.version,
|
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/main.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/utils/version_utils.dart';
|
|
||||||
|
|
||||||
/// How often to poll for updates from remote relays
|
/// How often to poll for updates from remote relays
|
||||||
const _pollInterval = Duration(minutes: 5);
|
const _pollInterval = Duration(minutes: 5);
|
||||||
@@ -380,8 +379,9 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
|
|||||||
final pkg = installedMap[app.identifier]!;
|
final pkg = installedMap[app.identifier]!;
|
||||||
final latest = app.latestFileMetadata;
|
final latest = app.latestFileMetadata;
|
||||||
|
|
||||||
// Determine if update available using local data
|
// Determine if update available using versionCode (via PackageManager)
|
||||||
final hasUpdate = latest != null && _hasUpdate(pkg, latest);
|
final pm = ref.read(packageManagerProvider.notifier);
|
||||||
|
final hasUpdate = latest != null && pm.hasUpdate(app.identifier, latest);
|
||||||
|
|
||||||
if (hasUpdate) {
|
if (hasUpdate) {
|
||||||
if (pkg.canInstallSilently) {
|
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 =
|
final categorizedUpdatesProvider =
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/constants/app_constants.dart';
|
import 'package:zapstore/constants/app_constants.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/version_utils.dart';
|
|
||||||
|
|
||||||
export 'package:zapstore/constants/app_constants.dart';
|
export 'package:zapstore/constants/app_constants.dart';
|
||||||
|
|
||||||
@@ -41,34 +40,20 @@ extension AppExt on App {
|
|||||||
latestRelease.value?.latestAsset.value ??
|
latestRelease.value?.latestAsset.value ??
|
||||||
latestRelease.value?.latestMetadata.value;
|
latestRelease.value?.latestMetadata.value;
|
||||||
|
|
||||||
/// Whether there is an update available for the installed app
|
/// Whether there is an update available for the installed app.
|
||||||
/// Compares versionCode first when available, otherwise falls back to
|
/// Delegates to PackageManager (versionCode-only comparison).
|
||||||
/// semantic version comparison.
|
|
||||||
bool get hasUpdate {
|
bool get hasUpdate {
|
||||||
final installed = installedPackage;
|
|
||||||
final latest = latestFileMetadata;
|
final latest = latestFileMetadata;
|
||||||
if (installed == null || latest == null) return false;
|
if (latest == null) return false;
|
||||||
|
return ref.read(packageManagerProvider.notifier).hasUpdate(identifier, latest);
|
||||||
if (latest.versionCode != null && installed.versionCode != null) {
|
|
||||||
return latest.versionCode! > installed.versionCode!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return canUpgrade(installed.version, latest.version);
|
/// Whether the relay version would be a downgrade from the installed version.
|
||||||
}
|
/// Delegates to PackageManager (versionCode-only comparison).
|
||||||
|
|
||||||
/// Whether the relay version would be a downgrade from the installed version
|
|
||||||
/// Compares versionCode first when available, otherwise falls back to
|
|
||||||
/// semantic version comparison.
|
|
||||||
bool get hasDowngrade {
|
bool get hasDowngrade {
|
||||||
final installed = installedPackage;
|
|
||||||
final latest = latestFileMetadata;
|
final latest = latestFileMetadata;
|
||||||
if (installed == null || latest == null) return false;
|
if (latest == null) return false;
|
||||||
|
return ref.read(packageManagerProvider.notifier).hasDowngrade(identifier, latest);
|
||||||
if (latest.versionCode != null && installed.versionCode != null) {
|
|
||||||
return latest.versionCode! < installed.versionCode!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return canUpgrade(latest.version, installed.version);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the installed app is up to date (installed and no update)
|
/// 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).
|
- All user-visible processes must have explicit states (loading, empty, success, error).
|
||||||
- Silent failures are unacceptable.
|
- 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
|
## Widget State Management
|
||||||
|
|
||||||
- Use Flutter Hooks (`HookWidget`, `HookConsumerWidget`) instead of `StatefulWidget` for local widget state.
|
- Use Flutter Hooks (`HookWidget`, `HookConsumerWidget`) instead of `StatefulWidget` for local widget state.
|
||||||
|
|||||||
Reference in New Issue
Block a user