From af2e969640e170d4f03c8d2a37cfb398449147f6 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 08:05:55 +0100 Subject: [PATCH] Update release checker to GitHub API --- lib/services/update_checker_service.dart | 195 +++++++++++++++++------ 1 file changed, 147 insertions(+), 48 deletions(-) diff --git a/lib/services/update_checker_service.dart b/lib/services/update_checker_service.dart index e6131fb..63896f4 100644 --- a/lib/services/update_checker_service.dart +++ b/lib/services/update_checker_service.dart @@ -5,16 +5,19 @@ import '../models/update_info.dart'; import 'build_info_service.dart'; /// Service for checking if a new app version is available -/// Compares current build's commit hash with latest manifest from server +/// Compares current build's commit hash with latest GitHub release class UpdateCheckerService { - static final UpdateCheckerService _instance = UpdateCheckerService._internal(); + static final UpdateCheckerService _instance = + UpdateCheckerService._internal(); factory UpdateCheckerService() => _instance; UpdateCheckerService._internal(); final BuildInfoService _buildInfoService = BuildInfoService(); - // Manifest URL for the latest unstable build - static const String _manifestUrl = 'https://meshcore-sar.dz0ny.dev/unstable/latest/manifest.json'; + static const String _repoOwner = 'dz0ny'; + static const String _repoName = 'meshcore-sar'; + static const String _latestReleaseUrl = + 'https://api.github.com/repos/$_repoOwner/$_repoName/releases/latest'; /// Check if an update is available /// Returns UpdateInfo with availability status and download URL if available @@ -25,61 +28,78 @@ class UpdateCheckerService { // Skip check for dev builds (local development) if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') { - debugPrint('[UpdateChecker] Skipping update check for dev/unknown build'); + debugPrint( + '[UpdateChecker] Skipping update check for dev/unknown build', + ); return UpdateInfo.noUpdate(currentCommitHash); } debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash'); - debugPrint('[UpdateChecker] Fetching latest manifest from: $_manifestUrl'); - - // Fetch manifest from server - final response = await http.get( - Uri.parse(_manifestUrl), - headers: {'Accept': 'application/json'}, - ).timeout( - const Duration(seconds: 10), - onTimeout: () { - debugPrint('[UpdateChecker] Manifest fetch timed out'); - throw Exception('Manifest fetch timed out'); - }, + debugPrint( + '[UpdateChecker] Fetching latest release from: $_latestReleaseUrl', ); + // Fetch latest release from GitHub + final response = await http + .get( + Uri.parse(_latestReleaseUrl), + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + .timeout( + const Duration(seconds: 10), + onTimeout: () { + debugPrint('[UpdateChecker] Latest release fetch timed out'); + throw Exception('Latest release fetch timed out'); + }, + ); + if (response.statusCode != 200) { - debugPrint('[UpdateChecker] Failed to fetch manifest: ${response.statusCode}'); + debugPrint( + '[UpdateChecker] Failed to fetch release: ${response.statusCode}', + ); return UpdateInfo.noUpdate(currentCommitHash); } - // Parse manifest JSON - final Map manifest = json.decode(response.body); - final latestCommitHash = manifest['commit'] as String?; - final commitShort = manifest['commit_short'] as String?; - final buildId = manifest['build_id'] as String?; - final timestamp = manifest['timestamp'] as String?; - final artifacts = manifest['artifacts'] as List?; + // Parse release JSON + final Map release = json.decode(response.body); + final tagName = release['tag_name'] as String?; + final targetCommitish = release['target_commitish'] as String?; + final publishedAt = release['published_at'] as String?; + final assets = release['assets'] as List?; - if (latestCommitHash == null || commitShort == null) { - debugPrint('[UpdateChecker] Invalid manifest: missing commit information'); + if (tagName == null) { + debugPrint('[UpdateChecker] Invalid release: missing tag_name'); return UpdateInfo.noUpdate(currentCommitHash); } - debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash'); - debugPrint('[UpdateChecker] Latest commit short: $commitShort'); + debugPrint('[UpdateChecker] Latest release tag: $tagName'); + if (targetCommitish != null && targetCommitish.isNotEmpty) { + debugPrint( + '[UpdateChecker] Release target commitish: $targetCommitish', + ); + } - // Compare commit hashes - // Current hash might be full SHA or short (7 chars) - // Latest from manifest is full SHA - final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash); + final isUpdateAvailable = await _isUpdateAvailable( + currentCommitHash: currentCommitHash, + releaseTag: tagName, + targetCommitish: targetCommitish, + ); if (!isUpdateAvailable) { - debugPrint('[UpdateChecker] No update available (same commit)'); + debugPrint('[UpdateChecker] No update available'); return UpdateInfo.noUpdate(currentCommitHash); } - // Find Android APK in artifacts - final String? apkUrl = _findAndroidApkUrl(artifacts); + // Find Android APK in release assets + final String? apkUrl = _findAndroidApkUrl(assets); if (apkUrl == null) { - debugPrint('[UpdateChecker] Update available but no APK found in artifacts'); + debugPrint( + '[UpdateChecker] Update available but no APK found in artifacts', + ); return UpdateInfo.noUpdate(currentCommitHash); } @@ -87,10 +107,10 @@ class UpdateCheckerService { return UpdateInfo.available( currentCommitHash: currentCommitHash, - latestCommitHash: commitShort, + latestCommitHash: _formatLatestVersion(targetCommitish, tagName), downloadUrl: apkUrl, - buildId: buildId, - timestamp: timestamp, + buildId: tagName, + timestamp: publishedAt, ); } catch (e) { debugPrint('[UpdateChecker] Error checking for update: $e'); @@ -118,15 +138,94 @@ class UpdateCheckerService { return false; } - /// Find Android APK URL in artifacts list - String? _findAndroidApkUrl(List? artifacts) { - if (artifacts == null || artifacts.isEmpty) return null; + Future _isUpdateAvailable({ + required String currentCommitHash, + required String releaseTag, + required String? targetCommitish, + }) async { + // Prefer direct SHA compare when release target is a hash. + if (_looksLikeSha(targetCommitish)) { + return !_compareCommitHashes(currentCommitHash, targetCommitish!); + } - // Look for .apk file in artifacts - for (final artifact in artifacts) { - if (artifact is String && artifact.toLowerCase().endsWith('.apk')) { - // Construct full URL - return 'https://meshcore-sar.dz0ny.dev/unstable/latest/$artifact'; + // Fallback: ask GitHub how current commit compares to the release tag. + final compareResult = await _compareWithReleaseTag( + currentCommitHash, + releaseTag, + ); + if (compareResult != null) { + return compareResult; + } + + // If we cannot compare, do not force update prompts. + return false; + } + + Future _compareWithReleaseTag( + String currentCommitHash, + String releaseTag, + ) async { + try { + final compareUrl = + 'https://api.github.com/repos/$_repoOwner/$_repoName/compare/$currentCommitHash...$releaseTag'; + final response = await http + .get( + Uri.parse(compareUrl), + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + debugPrint( + '[UpdateChecker] Compare API failed: ${response.statusCode}', + ); + return null; + } + + final Map comparison = json.decode(response.body); + final status = comparison['status'] as String?; + debugPrint('[UpdateChecker] Compare status: $status'); + + if (status == 'behind') return true; + if (status == 'identical' || status == 'ahead') return false; + + // "diverged" means the release and current commit differ. + if (status == 'diverged') return true; + } catch (e) { + debugPrint('[UpdateChecker] Compare API error: $e'); + } + return null; + } + + bool _looksLikeSha(String? value) { + if (value == null) return false; + final v = value.trim(); + if (v.length < 7 || v.length > 40) return false; + return RegExp(r'^[a-fA-F0-9]+$').hasMatch(v); + } + + String _formatLatestVersion(String? targetCommitish, String tagName) { + if (_looksLikeSha(targetCommitish)) { + return targetCommitish!.substring(0, 7).toLowerCase(); + } + return tagName; + } + + /// Find Android APK URL in release assets list + String? _findAndroidApkUrl(List? assets) { + if (assets == null || assets.isEmpty) return null; + + for (final asset in assets) { + if (asset is! Map) continue; + final name = (asset['name'] as String?)?.toLowerCase() ?? ''; + if (!name.endsWith('.apk')) continue; + + final browserDownloadUrl = asset['browser_download_url'] as String?; + if (browserDownloadUrl != null && browserDownloadUrl.isNotEmpty) { + return browserDownloadUrl; } }