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';
/// 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<String, dynamic> 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<dynamic>?;
// Parse release JSON
final Map<String, dynamic> 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<dynamic>?;
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<dynamic>? artifacts) {
if (artifacts == null || artifacts.isEmpty) return null;
Future<bool> _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<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;
}
}