diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 0f8ee30..adbd9bd 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -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 { Future 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; } } diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 1cd6f98..8ebdb8f 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -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 { 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 { ); } - /// 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 = diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 9c81e0c..27ddf4f 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -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!; - } - - return canUpgrade(installed.version, latest.version); + if (latest == null) return false; + return ref.read(packageManagerProvider.notifier).hasUpdate(identifier, latest); } - /// 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) diff --git a/lib/utils/version_utils.dart b/lib/utils/version_utils.dart deleted file mode 100644 index 01862f1..0000000 --- a/lib/utils/version_utils.dart +++ /dev/null @@ -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; -} diff --git a/spec/guidelines/INVARIANTS.md b/spec/guidelines/INVARIANTS.md index f37e4ed..acd428d 100644 --- a/spec/guidelines/INVARIANTS.md +++ b/spec/guidelines/INVARIANTS.md @@ -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.