mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Implement update checker and dialog for available app updates with localization support
This commit is contained in:
@@ -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": []
|
||||
|
||||
2
.github/workflows/build-multiplatform.yml
vendored
2
.github/workflows/build-multiplatform.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000225">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000194">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.103503">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.915439">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="128.444718">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="119.588068">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="479.819383">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="295.803061">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<MeshCoreSarApp> {
|
||||
// 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<MeshCoreSarApp> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Check for app updates on Android only
|
||||
/// Shows dialog if update is available
|
||||
Future<void> _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) {
|
||||
|
||||
52
lib/models/update_info.dart
Normal file
52
lib/models/update_info.dart
Normal file
@@ -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)';
|
||||
}
|
||||
}
|
||||
54
lib/services/build_info_service.dart
Normal file
54
lib/services/build_info_service.dart
Normal file
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
135
lib/services/update_checker_service.dart
Normal file
135
lib/services/update_checker_service.dart
Normal file
@@ -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<UpdateInfo> 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<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>?;
|
||||
|
||||
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<dynamic>? 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;
|
||||
}
|
||||
}
|
||||
180
lib/widgets/update_dialog.dart
Normal file
180
lib/widgets/update_dialog.dart
Normal file
@@ -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<void> 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<void> _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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user