Files
Qlyra/lib/core/utils/update_checker.dart
T
sevenhill 50816588e4
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Update Qlyra application
2026-08-30 22:53:15 +03:00

277 lines
8.3 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppUpdateInfo {
final String version;
final int? build;
final String tag;
final String url;
final String notes;
final List<AppUpdateArtifact> artifacts;
const AppUpdateInfo({
required this.version,
required this.build,
required this.tag,
required this.url,
required this.notes,
required this.artifacts,
});
}
class AppUpdateArtifact {
final String name;
final String url;
final String flavor;
final String abi;
final int sizeBytes;
final String sha256;
const AppUpdateArtifact({
required this.name,
required this.url,
required this.flavor,
required this.abi,
required this.sizeBytes,
required this.sha256,
});
}
enum UpdateCheckStatus { updateAvailable, upToDate, failed }
class UpdateCheckResult {
final UpdateCheckStatus status;
final AppUpdateInfo? update;
const UpdateCheckResult._(this.status, [this.update]);
const UpdateCheckResult.updateAvailable(AppUpdateInfo update)
: this._(UpdateCheckStatus.updateAvailable, update);
const UpdateCheckResult.upToDate() : this._(UpdateCheckStatus.upToDate);
const UpdateCheckResult.failed() : this._(UpdateCheckStatus.failed);
}
abstract class UpdateChecker {
static const String _userAgent = 'QlyraUpdateChecker';
static const String _baseUrl = 'https://argus.kusoft.xyz';
static String _manifestUrl(String slug) =>
'$_baseUrl/api/apps/$slug/manifest?platform=android&channel=stable';
static const String _lastCheckKey = 'update_last_check_ms';
static const String _skippedTagKey = 'update_skipped_tag';
static const Duration _checkInterval = Duration(hours: 6);
static const Duration _timeout = Duration(seconds: 15);
static Future<AppUpdateInfo?> fetchLatest() async {
final info = await PackageInfo.fromPlatform();
final currentBase = info.version;
final currentBuild = _normalizeBuild(int.tryParse(info.buildNumber));
final flavor = manifestSlugForPackageName(info.packageName);
final release = await _fetchLatestRelease(flavor);
if (release == null) return null;
final tag = (release['tag_name'] as String?)?.trim();
if (tag == null || tag.isEmpty) return null;
final remoteBase = _baseVersion(tag);
final remoteBuild = _buildNumber(tag);
if (!_isNewer(
currentBase: currentBase,
currentBuild: currentBuild,
remoteBase: remoteBase,
remoteBuild: remoteBuild,
)) {
return null;
}
return AppUpdateInfo(
version: remoteBase,
build: remoteBuild,
tag: tag,
url: (release['html_url'] as String?) ?? _manifestUrl(flavor),
notes: (release['body'] as String?)?.trim() ?? '',
artifacts: (release['artifacts'] as List).cast<AppUpdateArtifact>(),
);
}
static Future<AppUpdateInfo?> check({bool force = false}) async {
final prefs = await SharedPreferences.getInstance();
if (!force) {
final last = prefs.getInt(_lastCheckKey);
if (last != null) {
final elapsed = DateTime.now().millisecondsSinceEpoch - last;
if (elapsed >= 0 && elapsed < _checkInterval.inMilliseconds) {
return null;
}
}
}
AppUpdateInfo? update;
try {
update = await fetchLatest();
} catch (_) {
return null;
}
await prefs.setInt(_lastCheckKey, DateTime.now().millisecondsSinceEpoch);
if (update == null) return null;
if (!force && prefs.getString(_skippedTagKey) == update.tag) {
return null;
}
return update;
}
/// Runs a user-initiated check without applying the automatic-check interval
/// or the "skip this version" preference.
static Future<UpdateCheckResult> checkNow() async {
try {
final update = await fetchLatest();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_lastCheckKey, DateTime.now().millisecondsSinceEpoch);
return update == null
? const UpdateCheckResult.upToDate()
: UpdateCheckResult.updateAvailable(update);
} catch (_) {
return const UpdateCheckResult.failed();
}
}
static Future<void> skip(String tag) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_skippedTagKey, tag);
}
static String manifestSlugForPackageName(String packageName) =>
packageName == 'ru.oneme.app' ? 'oneme' : 'qlyra';
static Future<Map<String, dynamic>?> _fetchLatestRelease(
String flavor,
) async {
final uri = Uri.parse(_manifestUrl(flavor));
final client = HttpClient()..connectionTimeout = _timeout;
try {
final req = await client.getUrl(uri);
req.headers
..set(HttpHeaders.userAgentHeader, _userAgent)
..set(HttpHeaders.acceptHeader, 'application/json');
final resp = await req.close().timeout(_timeout);
if (resp.statusCode == HttpStatus.notFound) {
await resp.drain<void>();
return null;
}
if (resp.statusCode != HttpStatus.ok) {
await resp.drain<void>();
throw HttpException('Argus returned HTTP ${resp.statusCode}', uri: uri);
}
final body = await resp
.transform(const Utf8Decoder())
.join()
.timeout(_timeout);
final decoded = jsonDecode(body);
if (decoded is! Map || decoded['release'] is! Map) {
throw const FormatException('Invalid Argus manifest response');
}
final release = (decoded['release'] as Map).cast<String, dynamic>();
final version = release['version'] as String?;
final downloadPath = release['downloadPath'] as String?;
final originalFileName = release['originalFileName'] as String?;
final packageSizeBytes = release['packageSizeBytes'] as int?;
final digest = (release['sha256'] as String?)?.toLowerCase();
if (version == null ||
downloadPath == null ||
originalFileName == null ||
packageSizeBytes == null ||
packageSizeBytes <= 0 ||
digest == null ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(digest)) {
throw const FormatException('Incomplete Argus release metadata');
}
final downloadUrl = Uri.parse(_baseUrl).resolve(downloadPath).toString();
return {
'tag_name': version,
'html_url': downloadUrl,
'body': release['notes'] as String? ?? '',
'artifacts': [
AppUpdateArtifact(
name: originalFileName,
url: downloadUrl,
flavor: flavor,
abi: 'universal',
sizeBytes: packageSizeBytes,
sha256: digest,
),
],
};
} finally {
client.close(force: true);
}
}
static String _baseVersion(String tag) {
var s = tag.trim();
if (s.startsWith('v') || s.startsWith('V')) s = s.substring(1);
final dash = s.indexOf('-');
if (dash >= 0) s = s.substring(0, dash);
final plus = s.indexOf('+');
if (plus >= 0) s = s.substring(0, plus);
return s;
}
static const int _abiVersionCodeMultiplier = 1000;
static int? _normalizeBuild(int? build) {
if (build == null) return null;
if (build >= _abiVersionCodeMultiplier) {
return build % _abiVersionCodeMultiplier;
}
return build;
}
static int? _buildNumber(String tag) {
final matches = RegExp(r'\d+').allMatches(tag).toList();
if (matches.isEmpty) return null;
return int.tryParse(matches.last.group(0)!);
}
static bool _isNewer({
required String currentBase,
required int? currentBuild,
required String remoteBase,
required int? remoteBuild,
}) {
final cmp = _compareSemver(remoteBase, currentBase);
if (cmp > 0) return true;
if (cmp < 0) return false;
if (remoteBuild != null && currentBuild != null) {
return remoteBuild > currentBuild;
}
return false;
}
static int _compareSemver(String a, String b) {
final pa = _parts(a);
final pb = _parts(b);
final len = pa.length > pb.length ? pa.length : pb.length;
for (var i = 0; i < len; i++) {
final va = i < pa.length ? pa[i] : 0;
final vb = i < pb.length ? pb[i] : 0;
if (va != vb) return va > vb ? 1 : -1;
}
return 0;
}
static List<int> _parts(String v) =>
v.split('.').map((p) => int.tryParse(p.trim()) ?? 0).toList();
}