From 0f9974c1e939a0f1896a224248c017fc2be0e5d9 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 26 Oct 2025 17:47:37 +0100 Subject: [PATCH] feat: Implement update checker and dialog for available app updates with localization support --- .claude/settings.local.json | 3 +- .github/workflows/build-multiplatform.yml | 2 + android/app/build.gradle.kts | 10 + .../sar/meshcore_sar_app/BuildInfoChannel.kt | 35 ++++ .../sar/meshcore_sar_app/MainActivity.kt | 14 +- ios/fastlane/report.xml | 8 +- lib/l10n/app_de.arb | 8 +- lib/l10n/app_en.arb | 25 +++ lib/l10n/app_es.arb | 8 +- lib/l10n/app_fr.arb | 8 +- lib/l10n/app_hr.arb | 8 +- lib/l10n/app_it.arb | 8 +- lib/l10n/app_localizations.dart | 30 +++ lib/l10n/app_localizations_de.dart | 15 ++ lib/l10n/app_localizations_en.dart | 15 ++ lib/l10n/app_localizations_es.dart | 15 ++ lib/l10n/app_localizations_fr.dart | 15 ++ lib/l10n/app_localizations_hr.dart | 15 ++ lib/l10n/app_localizations_it.dart | 15 ++ lib/l10n/app_localizations_sl.dart | 15 ++ lib/l10n/app_sl.arb | 8 +- lib/main.dart | 38 ++++ lib/models/update_info.dart | 52 +++++ lib/services/build_info_service.dart | 54 ++++++ lib/services/update_checker_service.dart | 135 +++++++++++++ lib/widgets/update_dialog.dart | 180 ++++++++++++++++++ 26 files changed, 727 insertions(+), 12 deletions(-) create mode 100644 android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt create mode 100644 lib/models/update_info.dart create mode 100644 lib/services/build_info_service.dart create mode 100644 lib/services/update_checker_service.dart create mode 100644 lib/widgets/update_dialog.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e7fbbb4..548f410 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -35,7 +35,8 @@ "Bash(flutter test:*)", "Bash(git log:*)", "WebFetch(domain:meshcore-sar.dz0ny.dev)", - "Bash(flutter pub get:*)" + "Bash(flutter pub get:*)", + "Read(//Users/dz0ny/meshcore-sar/**)" ], "deny": [], "ask": [] diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml index 814dc39..c1cdf7e 100644 --- a/.github/workflows/build-multiplatform.yml +++ b/.github/workflows/build-multiplatform.yml @@ -115,6 +115,8 @@ jobs: run: flutter gen-l10n - name: Build APK + env: + COMMIT_HASH: ${{ github.sha }} run: flutter build apk --release - name: Upload APK diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4f04541..1d84b5a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,6 +10,11 @@ android { compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion + // Enable BuildConfig generation + buildFeatures { + buildConfig = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -31,6 +36,11 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + // Inject commit hash from environment variable (set by GitHub Actions) + // Falls back to "dev" for local development builds + val commitHash = System.getenv("COMMIT_HASH") ?: "dev" + buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"") } buildTypes { diff --git a/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt new file mode 100644 index 0000000..7ae50aa --- /dev/null +++ b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/BuildInfoChannel.kt @@ -0,0 +1,35 @@ +package com.meshcore.sar.meshcore_sar_app + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * Platform channel for exposing build information to Flutter + * Provides access to BuildConfig values that are injected at build time + */ +class BuildInfoChannel : MethodCallHandler { + companion object { + const val CHANNEL_NAME = "com.meshcore.sar/build_info" + const val METHOD_GET_COMMIT_HASH = "getCommitHash" + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + METHOD_GET_COMMIT_HASH -> { + try { + // Get commit hash from BuildConfig + // This value is injected by gradle at build time + val commitHash = BuildConfig.COMMIT_HASH + result.success(commitHash) + } catch (e: Exception) { + result.error("BUILD_INFO_ERROR", "Failed to get commit hash: ${e.message}", null) + } + } + else -> { + result.notImplemented() + } + } + } +} diff --git a/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt index 1a081df..af5ce27 100644 --- a/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/meshcore/sar/meshcore_sar_app/MainActivity.kt @@ -1,5 +1,17 @@ package com.meshcore.sar.meshcore_sar_app import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Register BuildInfoChannel to expose build information to Flutter + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + BuildInfoChannel.CHANNEL_NAME + ).setMethodCallHandler(BuildInfoChannel()) + } +} diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 8d78a28..c245ce0 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7653d58..155089b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2541,5 +2541,11 @@ "yourDrawingsCount": "Ihre Zeichnungen ({count})", "shared": "Geteilt", "line": "Linie", - "rectangle": "Rechteck" + "rectangle": "Rechteck", + + "updateAvailable": "Update Verfügbar", + "currentVersion": "Aktuell", + "latestVersion": "Neueste", + "downloadUpdate": "Herunterladen", + "updateLater": "Später" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8f41763..67f48a2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3138,5 +3138,30 @@ "rectangle": "Rectangle", "@rectangle": { "description": "Rectangle drawing type label" + }, + + "updateAvailable": "Update Available", + "@updateAvailable": { + "description": "Title for update dialog when new version is available" + }, + + "currentVersion": "Current", + "@currentVersion": { + "description": "Label for current app version" + }, + + "latestVersion": "Latest", + "@latestVersion": { + "description": "Label for latest available app version" + }, + + "downloadUpdate": "Download", + "@downloadUpdate": { + "description": "Button to download app update" + }, + + "updateLater": "Later", + "@updateLater": { + "description": "Button to dismiss update dialog" } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 22b2127..49b9e31 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2536,5 +2536,11 @@ "yourDrawingsCount": "Sus Dibujos ({count})", "shared": "Compartido", "line": "Línea", - "rectangle": "Rectángulo" + "rectangle": "Rectángulo", + + "updateAvailable": "Actualización Disponible", + "currentVersion": "Actual", + "latestVersion": "Última", + "downloadUpdate": "Descargar", + "updateLater": "Más Tarde" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8fb07ae..a98d538 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2541,5 +2541,11 @@ "yourDrawingsCount": "Vos Dessins ({count})", "shared": "Partagé", "line": "Ligne", - "rectangle": "Rectangle" + "rectangle": "Rectangle", + + "updateAvailable": "Mise à Jour Disponible", + "currentVersion": "Actuelle", + "latestVersion": "Dernière", + "downloadUpdate": "Télécharger", + "updateLater": "Plus Tard" } diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index 5b56050..b01f02e 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -949,5 +949,11 @@ "yourDrawingsCount": "Vaši Crteži ({count})", "shared": "Podijeljeno", "line": "Linija", - "rectangle": "Pravokutnik" + "rectangle": "Pravokutnik", + + "updateAvailable": "Dostupno Ažuriranje", + "currentVersion": "Trenutna", + "latestVersion": "Najnovija", + "downloadUpdate": "Preuzmi", + "updateLater": "Kasnije" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ecb909f..13c6db0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -2541,5 +2541,11 @@ "yourDrawingsCount": "I Tuoi Disegni ({count})", "shared": "Condiviso", "line": "Linea", - "rectangle": "Rettangolo" + "rectangle": "Rettangolo", + + "updateAvailable": "Aggiornamento Disponibile", + "currentVersion": "Attuale", + "latestVersion": "Ultima", + "downloadUpdate": "Scarica", + "updateLater": "Più Tardi" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ad3c8c5..655d514 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3344,6 +3344,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Rectangle'** String get rectangle; + + /// Title for update dialog when new version is available + /// + /// In en, this message translates to: + /// **'Update Available'** + String get updateAvailable; + + /// Label for current app version + /// + /// In en, this message translates to: + /// **'Current'** + String get currentVersion; + + /// Label for latest available app version + /// + /// In en, this message translates to: + /// **'Latest'** + String get latestVersion; + + /// Button to download app update + /// + /// In en, this message translates to: + /// **'Download'** + String get downloadUpdate; + + /// Button to dismiss update dialog + /// + /// In en, this message translates to: + /// **'Later'** + String get updateLater; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index fd09ae9..8484eab 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1869,4 +1869,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get rectangle => 'Rechteck'; + + @override + String get updateAvailable => 'Update Verfügbar'; + + @override + String get currentVersion => 'Aktuell'; + + @override + String get latestVersion => 'Neueste'; + + @override + String get downloadUpdate => 'Herunterladen'; + + @override + String get updateLater => 'Später'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2b108bc..dc4d49a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1849,4 +1849,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rectangle => 'Rectangle'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String get currentVersion => 'Current'; + + @override + String get latestVersion => 'Latest'; + + @override + String get downloadUpdate => 'Download'; + + @override + String get updateLater => 'Later'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c7103af..95ff69f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1871,4 +1871,19 @@ class AppLocalizationsEs extends AppLocalizations { @override String get rectangle => 'Rectángulo'; + + @override + String get updateAvailable => 'Actualización Disponible'; + + @override + String get currentVersion => 'Actual'; + + @override + String get latestVersion => 'Última'; + + @override + String get downloadUpdate => 'Descargar'; + + @override + String get updateLater => 'Más Tarde'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 38a1d2c..ba112f1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1877,4 +1877,19 @@ class AppLocalizationsFr extends AppLocalizations { @override String get rectangle => 'Rectangle'; + + @override + String get updateAvailable => 'Mise à Jour Disponible'; + + @override + String get currentVersion => 'Actuelle'; + + @override + String get latestVersion => 'Dernière'; + + @override + String get downloadUpdate => 'Télécharger'; + + @override + String get updateLater => 'Plus Tard'; } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index cbc1a95..bb46822 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -1859,4 +1859,19 @@ class AppLocalizationsHr extends AppLocalizations { @override String get rectangle => 'Pravokutnik'; + + @override + String get updateAvailable => 'Dostupno Ažuriranje'; + + @override + String get currentVersion => 'Trenutna'; + + @override + String get latestVersion => 'Najnovija'; + + @override + String get downloadUpdate => 'Preuzmi'; + + @override + String get updateLater => 'Kasnije'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index fdb2290..1b93c9c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -1868,4 +1868,19 @@ class AppLocalizationsIt extends AppLocalizations { @override String get rectangle => 'Rettangolo'; + + @override + String get updateAvailable => 'Aggiornamento Disponibile'; + + @override + String get currentVersion => 'Attuale'; + + @override + String get latestVersion => 'Ultima'; + + @override + String get downloadUpdate => 'Scarica'; + + @override + String get updateLater => 'Più Tardi'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index fe060db..76e0abe 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -1860,4 +1860,19 @@ class AppLocalizationsSl extends AppLocalizations { @override String get rectangle => 'Pravokotnik'; + + @override + String get updateAvailable => 'Na Voljo Posodobitev'; + + @override + String get currentVersion => 'Trenutna'; + + @override + String get latestVersion => 'Najnovejša'; + + @override + String get downloadUpdate => 'Prenesi'; + + @override + String get updateLater => 'Kasneje'; } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index c845feb..99585f5 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -949,5 +949,11 @@ "yourDrawingsCount": "Vaše Risbe ({count})", "shared": "Deljeno", "line": "Črta", - "rectangle": "Pravokotnik" + "rectangle": "Pravokotnik", + + "updateAvailable": "Na Voljo Posodobitev", + "currentVersion": "Trenutna", + "latestVersion": "Najnovejša", + "downloadUpdate": "Prenesi", + "updateLater": "Kasneje" } diff --git a/lib/main.dart b/lib/main.dart index 0ccbec9..bc32089 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; @@ -13,6 +14,8 @@ import 'providers/app_provider.dart'; import 'services/tile_cache_service.dart'; import 'services/notification_service.dart'; import 'services/locale_preferences.dart'; +import 'services/update_checker_service.dart'; +import 'widgets/update_dialog.dart'; import 'screens/home_screen.dart'; import 'theme/app_theme.dart'; import 'l10n/app_localizations.dart'; @@ -49,6 +52,10 @@ class _MeshCoreSarAppState extends State { // Check if we need to request location permissions await _checkLocationPermissions(); + // Check for app updates (Android only) - runs in background + // Shows dialog if update is available + _checkForUpdates(); + setState(() { _isInitialized = true; }); @@ -95,6 +102,37 @@ class _MeshCoreSarAppState extends State { }); } + /// Check for app updates on Android only + /// Shows dialog if update is available + Future _checkForUpdates() async { + // Only check for updates on Android + if (!Platform.isAndroid) { + debugPrint('[UpdateChecker] Skipping update check (not Android)'); + return; + } + + try { + debugPrint('[UpdateChecker] Starting update check...'); + final updateInfo = await UpdateCheckerService().checkForUpdate(); + + if (!updateInfo.isAvailable) { + debugPrint('[UpdateChecker] No update available'); + return; + } + + debugPrint('[UpdateChecker] Update available! Showing dialog...'); + + // Wait for widget tree to be built before showing dialog + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + UpdateDialog.show(context, updateInfo); + } + }); + } catch (e) { + debugPrint('[UpdateChecker] Error checking for updates: $e'); + } + } + @override Widget build(BuildContext context) { if (!_isInitialized) { diff --git a/lib/models/update_info.dart b/lib/models/update_info.dart new file mode 100644 index 0000000..bc3b0de --- /dev/null +++ b/lib/models/update_info.dart @@ -0,0 +1,52 @@ +/// Information about an available app update +class UpdateInfo { + final bool isAvailable; + final String currentCommitHash; + final String? latestCommitHash; + final String? downloadUrl; + final String? buildId; + final String? timestamp; + + const UpdateInfo({ + required this.isAvailable, + required this.currentCommitHash, + this.latestCommitHash, + this.downloadUrl, + this.buildId, + this.timestamp, + }); + + /// Factory constructor for when no update is available + factory UpdateInfo.noUpdate(String currentCommitHash) { + return UpdateInfo( + isAvailable: false, + currentCommitHash: currentCommitHash, + ); + } + + /// Factory constructor for when an update is available + factory UpdateInfo.available({ + required String currentCommitHash, + required String latestCommitHash, + required String downloadUrl, + String? buildId, + String? timestamp, + }) { + return UpdateInfo( + isAvailable: true, + currentCommitHash: currentCommitHash, + latestCommitHash: latestCommitHash, + downloadUrl: downloadUrl, + buildId: buildId, + timestamp: timestamp, + ); + } + + @override + String toString() { + return 'UpdateInfo(isAvailable: $isAvailable, ' + 'current: $currentCommitHash, ' + 'latest: $latestCommitHash, ' + 'downloadUrl: $downloadUrl)'; + } +} diff --git a/lib/services/build_info_service.dart b/lib/services/build_info_service.dart new file mode 100644 index 0000000..2682bdc --- /dev/null +++ b/lib/services/build_info_service.dart @@ -0,0 +1,54 @@ +import 'dart:io' show Platform; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; + +/// Service for accessing build information from native platform code +/// Currently supports Android only - returns "unknown" for other platforms +class BuildInfoService { + static final BuildInfoService _instance = BuildInfoService._internal(); + factory BuildInfoService() => _instance; + BuildInfoService._internal(); + + static const MethodChannel _channel = MethodChannel('com.meshcore.sar/build_info'); + + String? _cachedCommitHash; + + /// Get the commit hash that was embedded during build time + /// Returns "unknown" if: + /// - Not running on Android + /// - Platform channel call fails + /// - Build was not configured with COMMIT_HASH + Future getCommitHash() async { + // Return cached value if available + if (_cachedCommitHash != null) { + return _cachedCommitHash!; + } + + // Only Android has the platform channel implementation + if (!Platform.isAndroid) { + debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"'); + _cachedCommitHash = 'unknown'; + return _cachedCommitHash!; + } + + try { + final String commitHash = await _channel.invokeMethod('getCommitHash'); + _cachedCommitHash = commitHash; + debugPrint('[BuildInfoService] Commit hash: $commitHash'); + return commitHash; + } on PlatformException catch (e) { + debugPrint('[BuildInfoService] Failed to get commit hash: ${e.message}'); + _cachedCommitHash = 'unknown'; + return _cachedCommitHash!; + } catch (e) { + debugPrint('[BuildInfoService] Unexpected error getting commit hash: $e'); + _cachedCommitHash = 'unknown'; + return _cachedCommitHash!; + } + } + + /// Clear cached commit hash (useful for testing) + void clearCache() { + _cachedCommitHash = null; + } +} diff --git a/lib/services/update_checker_service.dart b/lib/services/update_checker_service.dart new file mode 100644 index 0000000..e6131fb --- /dev/null +++ b/lib/services/update_checker_service.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +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 +class UpdateCheckerService { + 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'; + + /// Check if an update is available + /// Returns UpdateInfo with availability status and download URL if available + Future checkForUpdate() async { + try { + // Get current build's commit hash + final currentCommitHash = await _buildInfoService.getCommitHash(); + + // Skip check for dev builds (local development) + if (currentCommitHash == 'dev' || currentCommitHash == 'unknown') { + 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'); + }, + ); + + if (response.statusCode != 200) { + debugPrint('[UpdateChecker] Failed to fetch manifest: ${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?; + + if (latestCommitHash == null || commitShort == null) { + debugPrint('[UpdateChecker] Invalid manifest: missing commit information'); + return UpdateInfo.noUpdate(currentCommitHash); + } + + debugPrint('[UpdateChecker] Latest commit hash: $latestCommitHash'); + debugPrint('[UpdateChecker] Latest commit short: $commitShort'); + + // Compare commit hashes + // Current hash might be full SHA or short (7 chars) + // Latest from manifest is full SHA + final isUpdateAvailable = !_compareCommitHashes(currentCommitHash, latestCommitHash); + + if (!isUpdateAvailable) { + debugPrint('[UpdateChecker] No update available (same commit)'); + return UpdateInfo.noUpdate(currentCommitHash); + } + + // Find Android APK in artifacts + final String? apkUrl = _findAndroidApkUrl(artifacts); + + if (apkUrl == null) { + debugPrint('[UpdateChecker] Update available but no APK found in artifacts'); + return UpdateInfo.noUpdate(currentCommitHash); + } + + debugPrint('[UpdateChecker] Update available! APK URL: $apkUrl'); + + return UpdateInfo.available( + currentCommitHash: currentCommitHash, + latestCommitHash: commitShort, + downloadUrl: apkUrl, + buildId: buildId, + timestamp: timestamp, + ); + } catch (e) { + debugPrint('[UpdateChecker] Error checking for update: $e'); + // Return no update on error to avoid disrupting app startup + final currentCommitHash = await _buildInfoService.getCommitHash(); + return UpdateInfo.noUpdate(currentCommitHash); + } + } + + /// Compare two commit hashes (handles both full SHA and short format) + bool _compareCommitHashes(String current, String latest) { + // Normalize to lowercase for comparison + final currentLower = current.toLowerCase(); + final latestLower = latest.toLowerCase(); + + // Direct match + if (currentLower == latestLower) return true; + + // Check if current is short form of latest + if (latestLower.startsWith(currentLower)) return true; + + // Check if latest is short form of current + if (currentLower.startsWith(latestLower)) return true; + + return false; + } + + /// Find Android APK URL in artifacts list + String? _findAndroidApkUrl(List? artifacts) { + if (artifacts == null || artifacts.isEmpty) return null; + + // 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'; + } + } + + return null; + } +} diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart new file mode 100644 index 0000000..61f3d2d --- /dev/null +++ b/lib/widgets/update_dialog.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../models/update_info.dart'; +import '../l10n/app_localizations.dart'; + +/// Dialog widget that displays when a new app version is available +/// Shows current vs latest commit hash and provides download button +class UpdateDialog extends StatelessWidget { + final UpdateInfo updateInfo; + + const UpdateDialog({ + super.key, + required this.updateInfo, + }); + + /// Show the update dialog + static Future show(BuildContext context, UpdateInfo updateInfo) { + return showDialog( + context: context, + barrierDismissible: true, + builder: (context) => UpdateDialog(updateInfo: updateInfo), + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + return AlertDialog( + icon: const Icon( + Icons.system_update, + size: 48, + color: Colors.blue, + ), + title: Text(loc.updateAvailable), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current version + _buildInfoRow( + context, + label: loc.currentVersion, + value: updateInfo.currentCommitHash, + ), + const SizedBox(height: 12), + + // Latest version + _buildInfoRow( + context, + label: loc.latestVersion, + value: updateInfo.latestCommitHash ?? 'unknown', + ), + + // Optional: Build timestamp + if (updateInfo.timestamp != null) ...[ + const SizedBox(height: 12), + _buildInfoRow( + context, + label: 'Build Time', + value: _formatTimestamp(updateInfo.timestamp!), + ), + ], + ], + ), + actions: [ + // Later button + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(loc.updateLater), + ), + + // Download button + FilledButton.icon( + onPressed: () => _launchDownloadUrl(context), + icon: const Icon(Icons.download), + label: Text(loc.downloadUpdate), + ), + ], + ); + } + + /// Build a labeled info row + Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + ), + Expanded( + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + color: Colors.black87, + ), + ), + ), + ], + ); + } + + /// Format timestamp from YYYYMMDD-HHMMSS to readable format + String _formatTimestamp(String timestamp) { + try { + // Parse YYYYMMDD-HHMMSS format + if (timestamp.length >= 15) { + final year = timestamp.substring(0, 4); + final month = timestamp.substring(4, 6); + final day = timestamp.substring(6, 8); + final hour = timestamp.substring(9, 11); + final minute = timestamp.substring(11, 13); + return '$year-$month-$day $hour:$minute UTC'; + } + return timestamp; + } catch (e) { + return timestamp; + } + } + + /// Launch download URL in browser + Future _launchDownloadUrl(BuildContext context) async { + if (updateInfo.downloadUrl == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Download URL not available'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + try { + final url = Uri.parse(updateInfo.downloadUrl!); + final canLaunch = await canLaunchUrl(url); + + if (!canLaunch) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cannot open download URL'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + + await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + + // Close dialog after launching download + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + debugPrint('[UpdateDialog] Error launching download URL: $e'); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error opening download: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } +}