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
248 lines
7.4 KiB
Dart
248 lines
7.4 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 Map<String, String> assets;
|
|
|
|
const AppUpdateInfo({
|
|
required this.version,
|
|
required this.build,
|
|
required this.tag,
|
|
required this.url,
|
|
required this.notes,
|
|
required this.assets,
|
|
});
|
|
}
|
|
|
|
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 const String _manifestUrl =
|
|
'$_baseUrl/api/apps/qlyra/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 release = await _fetchLatestRelease();
|
|
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?) ?? _releasesPage,
|
|
notes: (release['body'] as String?)?.trim() ?? '',
|
|
assets: _parseAssets(release['assets']),
|
|
);
|
|
}
|
|
|
|
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 get _releasesPage => _manifestUrl;
|
|
|
|
static Future<Map<String, dynamic>?> _fetchLatestRelease() async {
|
|
final uri = Uri.parse(_manifestUrl);
|
|
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.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?;
|
|
if (version == null || downloadPath == null) return null;
|
|
final downloadUrl = Uri.parse(_baseUrl).resolve(downloadPath).toString();
|
|
return {
|
|
'tag_name': version,
|
|
'html_url': downloadUrl,
|
|
'body': release['notes'] as String? ?? '',
|
|
'assets': [
|
|
{
|
|
'name': 'qlyra-qlyra-universal.apk',
|
|
'browser_download_url': downloadUrl,
|
|
},
|
|
],
|
|
};
|
|
} finally {
|
|
client.close(force: true);
|
|
}
|
|
}
|
|
|
|
static Map<String, String> _parseAssets(dynamic raw) {
|
|
final result = <String, String>{};
|
|
if (raw is! List) return result;
|
|
for (final entry in raw) {
|
|
if (entry is! Map) continue;
|
|
final name = entry['name'] as String?;
|
|
final url = entry['browser_download_url'] as String?;
|
|
if (name != null && url != null) result[name] = url;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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();
|
|
}
|