Update release checker to GitHub API

This commit is contained in:
Janez T
2026-03-05 08:05:55 +01:00
parent b12567f613
commit af2e969640

View File

@@ -5,16 +5,19 @@ import '../models/update_info.dart';
import 'build_info_service.dart'; import 'build_info_service.dart';
/// Service for checking if a new app version is available /// 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 { class UpdateCheckerService {
static final UpdateCheckerService _instance = UpdateCheckerService._internal(); static final UpdateCheckerService _instance =
UpdateCheckerService._internal();
factory UpdateCheckerService() => _instance; factory UpdateCheckerService() => _instance;
UpdateCheckerService._internal(); UpdateCheckerService._internal();
final BuildInfoService _buildInfoService = BuildInfoService(); final BuildInfoService _buildInfoService = BuildInfoService();
// Manifest URL for the latest unstable build static const String _repoOwner = 'dz0ny';
static const String _manifestUrl = 'https://meshcore-sar.dz0ny.dev/unstable/latest/manifest.json'; 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 /// Check if an update is available
/// Returns UpdateInfo with availability status and download URL if available /// Returns UpdateInfo with availability status and download URL if available
@@ -25,61 +28,78 @@ class UpdateCheckerService {
// Skip check for dev builds (local development) // Skip check for dev builds (local development)
if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') { 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); return UpdateInfo.noUpdate(currentCommitHash);
} }
debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash'); debugPrint('[UpdateChecker] Current commit hash: $currentCommitHash');
debugPrint('[UpdateChecker] Fetching latest manifest from: $_manifestUrl'); debugPrint(
'[UpdateChecker] Fetching latest release from: $_latestReleaseUrl',
);
// Fetch manifest from server // Fetch latest release from GitHub
final response = await http.get( final response = await http
Uri.parse(_manifestUrl), .get(
headers: {'Accept': 'application/json'}, Uri.parse(_latestReleaseUrl),
).timeout( headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
)
.timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
onTimeout: () { onTimeout: () {
debugPrint('[UpdateChecker] Manifest fetch timed out'); debugPrint('[UpdateChecker] Latest release fetch timed out');
throw Exception('Manifest fetch timed out'); throw Exception('Latest release fetch timed out');
}, },
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
debugPrint('[UpdateChecker] Failed to fetch manifest: ${response.statusCode}'); debugPrint(
'[UpdateChecker] Failed to fetch release: ${response.statusCode}',
);
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
// Parse manifest JSON // Parse release JSON
final Map<String, dynamic> manifest = json.decode(response.body); final Map<String, dynamic> release = json.decode(response.body);
final latestCommitHash = manifest['commit'] as String?; final tagName = release['tag_name'] as String?;
final commitShort = manifest['commit_short'] as String?; final targetCommitish = release['target_commitish'] as String?;
final buildId = manifest['build_id'] as String?; final publishedAt = release['published_at'] as String?;
final timestamp = manifest['timestamp'] as String?; final assets = release['assets'] as List<dynamic>?;
final artifacts = manifest['artifacts'] as List<dynamic>?;
if (latestCommitHash == null || commitShort == null) { if (tagName == null) {
debugPrint('[UpdateChecker] Invalid manifest: missing commit information'); debugPrint('[UpdateChecker] Invalid release: missing tag_name');
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash'); debugPrint('[UpdateChecker] Latest release tag: $tagName');
debugPrint('[UpdateChecker] Latest commit short: $commitShort'); if (targetCommitish != null && targetCommitish.isNotEmpty) {
debugPrint(
'[UpdateChecker] Release target commitish: $targetCommitish',
);
}
// Compare commit hashes final isUpdateAvailable = await _isUpdateAvailable(
// Current hash might be full SHA or short (7 chars) currentCommitHash: currentCommitHash,
// Latest from manifest is full SHA releaseTag: tagName,
final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash); targetCommitish: targetCommitish,
);
if (!isUpdateAvailable) { if (!isUpdateAvailable) {
debugPrint('[UpdateChecker] No update available (same commit)'); debugPrint('[UpdateChecker] No update available');
return UpdateInfo.noUpdate(currentCommitHash); return UpdateInfo.noUpdate(currentCommitHash);
} }
// Find Android APK in artifacts // Find Android APK in release assets
final String? apkUrl = _findAndroidApkUrl(artifacts); final String? apkUrl = _findAndroidApkUrl(assets);
if (apkUrl == null) { 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); return UpdateInfo.noUpdate(currentCommitHash);
} }
@@ -87,10 +107,10 @@ class UpdateCheckerService {
return UpdateInfo.available( return UpdateInfo.available(
currentCommitHash: currentCommitHash, currentCommitHash: currentCommitHash,
latestCommitHash: commitShort, latestCommitHash: _formatLatestVersion(targetCommitish, tagName),
downloadUrl: apkUrl, downloadUrl: apkUrl,
buildId: buildId, buildId: tagName,
timestamp: timestamp, timestamp: publishedAt,
); );
} catch (e) { } catch (e) {
debugPrint('[UpdateChecker] Error checking for update: $e'); debugPrint('[UpdateChecker] Error checking for update: $e');
@@ -118,15 +138,94 @@ class UpdateCheckerService {
return false; return false;
} }
/// Find Android APK URL in artifacts list Future<bool> _isUpdateAvailable({
String? _findAndroidApkUrl(List<dynamic>? artifacts) { required String currentCommitHash,
if (artifacts == null || artifacts.isEmpty) return null; 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 // Fallback: ask GitHub how current commit compares to the release tag.
for (final artifact in artifacts) { final compareResult = await _compareWithReleaseTag(
if (artifact is String && artifact.toLowerCase().endsWith('.apk')) { currentCommitHash,
// Construct full URL releaseTag,
return 'https://meshcore-sar.dz0ny.dev/unstable/latest/$artifact'; );
if (compareResult != null) {
return compareResult;
}
// If we cannot compare, do not force update prompts.
return false;
}
Future<bool?> _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<String, dynamic> 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<dynamic>? assets) {
if (assets == null || assets.isEmpty) return null;
for (final asset in assets) {
if (asset is! Map<String, dynamic>) 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;
} }
} }