import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:xml/xml.dart'; class UpdateService { static const String _releasesRssUrl = 'https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases.rss'; /// Check if an update is available by comparing current version with latest release Future checkForUpdates() async { try { // Get current app version final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; if (kDebugMode) { print('Current app version: $currentVersion'); print('Checking for updates at: $_releasesRssUrl'); } // Fetch latest release from Gitea RSS feed final response = await http.get( Uri.parse(_releasesRssUrl), headers: { 'Accept': 'application/rss+xml, application/xml, text/xml', 'User-Agent': 'rmtPocketWatcher/$currentVersion', }, ); if (response.statusCode == 200) { final document = XmlDocument.parse(response.body); final items = document.findAllElements('item'); if (items.isEmpty) { if (kDebugMode) { print('No releases found in RSS feed'); } return null; } // Get the latest release (first item in RSS feed) final latestItem = items.first; final title = latestItem.findElements('title').first.innerText; final link = latestItem.findElements('link').first.innerText; final description = latestItem.findElements('description').firstOrNull?.innerText ?? ''; final pubDate = latestItem.findElements('pubDate').first.innerText; // Extract version from title (assuming format like "v1.2.3" or "Release v1.2.3") final versionMatch = RegExp(r'v?(\d+\.\d+\.\d+)').firstMatch(title); if (versionMatch == null) { if (kDebugMode) { print('Could not extract version from title: $title'); } return null; } final latestVersion = versionMatch.group(1)!; if (kDebugMode) { print('Latest release version: $latestVersion'); print('Release title: $title'); } // Compare versions if (isNewerVersion(latestVersion, currentVersion)) { return UpdateInfo( currentVersion: currentVersion, latestVersion: latestVersion, releaseUrl: link, releaseName: title, releaseNotes: description, publishedAt: _parseRssDate(pubDate), assets: _generateAssetUrls(latestVersion), // Generate expected asset URLs ); } else { if (kDebugMode) { print('App is up to date'); } return null; } } else { if (kDebugMode) { print('Failed to fetch RSS feed: ${response.statusCode}'); print('Response: ${response.body}'); } return null; } } catch (e) { if (kDebugMode) { print('Error checking for updates: $e'); } return null; } } /// Extract version number from git tag (e.g., "v1.2.3" -> "1.2.3") @visibleForTesting String extractVersionFromTag(String tag) { return tag.startsWith('v') ? tag.substring(1) : tag; } /// Compare two version strings (e.g., "1.2.3" vs "1.2.2") @visibleForTesting bool isNewerVersion(String latest, String current) { final latestParts = latest.split('.').map(int.parse).toList(); final currentParts = current.split('.').map(int.parse).toList(); // Ensure both have same number of parts while (latestParts.length < currentParts.length) { latestParts.add(0); } while (currentParts.length < latestParts.length) { currentParts.add(0); } for (int i = 0; i < latestParts.length; i++) { if (latestParts[i] > currentParts[i]) { return true; } else if (latestParts[i] < currentParts[i]) { return false; } } return false; // Versions are equal } /// Parse RSS date format to DateTime DateTime _parseRssDate(String rssDate) { try { // RSS dates are typically in RFC 2822 format // Example: "Mon, 02 Jan 2006 15:04:05 MST" return DateTime.parse(rssDate); } catch (e) { if (kDebugMode) { print('Failed to parse RSS date: $rssDate, error: $e'); } return DateTime.now(); } } /// Generate expected asset URLs based on version and platform List _generateAssetUrls(String version) { final baseUrl = 'https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases/download/v$version'; return [ ReleaseAsset( name: 'rmtPocketWatcher-windows-x64.exe', downloadUrl: '$baseUrl/rmtPocketWatcher-windows-x64.exe', size: 0, // Unknown size from RSS contentType: 'application/octet-stream', ), ReleaseAsset( name: 'rmtPocketWatcher-windows-x64.msi', downloadUrl: '$baseUrl/rmtPocketWatcher-windows-x64.msi', size: 0, contentType: 'application/octet-stream', ), ReleaseAsset( name: 'rmtPocketWatcher-macos.dmg', downloadUrl: '$baseUrl/rmtPocketWatcher-macos.dmg', size: 0, contentType: 'application/octet-stream', ), ReleaseAsset( name: 'rmtPocketWatcher-linux.appimage', downloadUrl: '$baseUrl/rmtPocketWatcher-linux.appimage', size: 0, contentType: 'application/octet-stream', ), ReleaseAsset( name: 'rmtPocketWatcher-android.apk', downloadUrl: '$baseUrl/rmtPocketWatcher-android.apk', size: 0, contentType: 'application/vnd.android.package-archive', ), ]; } } class UpdateInfo { final String currentVersion; final String latestVersion; final String releaseUrl; final String releaseName; final String releaseNotes; final DateTime publishedAt; final List assets; UpdateInfo({ required this.currentVersion, required this.latestVersion, required this.releaseUrl, required this.releaseName, required this.releaseNotes, required this.publishedAt, required this.assets, }); /// Get the appropriate download asset for the current platform ReleaseAsset? getAssetForCurrentPlatform() { if (kIsWeb) return null; String platformPattern; switch (defaultTargetPlatform) { case TargetPlatform.windows: platformPattern = r'windows|win|\.exe$|\.msi$'; break; case TargetPlatform.macOS: platformPattern = r'macos|mac|darwin|\.dmg$|\.pkg$'; break; case TargetPlatform.linux: platformPattern = r'linux|\.deb$|\.rpm$|\.appimage$'; break; case TargetPlatform.android: platformPattern = r'android|\.apk$'; break; case TargetPlatform.iOS: platformPattern = r'ios|\.ipa$'; break; default: return null; } final regex = RegExp(platformPattern, caseSensitive: false); for (final asset in assets) { if (regex.hasMatch(asset.name)) { return asset; } } return null; } } class ReleaseAsset { final String name; final String downloadUrl; final int size; final String contentType; ReleaseAsset({ required this.name, required this.downloadUrl, required this.size, required this.contentType, }); String get formattedSize { if (size < 1024) return '${size}B'; if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)}KB'; if (size < 1024 * 1024 * 1024) return '${(size / (1024 * 1024)).toStringAsFixed(1)}MB'; return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB'; } }