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(flutter test:*)",
|
||||||
"Bash(git log:*)",
|
"Bash(git log:*)",
|
||||||
"WebFetch(domain:meshcore-sar.dz0ny.dev)",
|
"WebFetch(domain:meshcore-sar.dz0ny.dev)",
|
||||||
"Bash(flutter pub get:*)"
|
"Bash(flutter pub get:*)",
|
||||||
|
"Read(//Users/dz0ny/meshcore-sar/**)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
2
.github/workflows/build-multiplatform.yml
vendored
2
.github/workflows/build-multiplatform.yml
vendored
@@ -115,6 +115,8 @@ jobs:
|
|||||||
run: flutter gen-l10n
|
run: flutter gen-l10n
|
||||||
|
|
||||||
- name: Build APK
|
- name: Build APK
|
||||||
|
env:
|
||||||
|
COMMIT_HASH: ${{ github.sha }}
|
||||||
run: flutter build apk --release
|
run: flutter build apk --release
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ android {
|
|||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
// Enable BuildConfig generation
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
@@ -31,6 +36,11 @@ android {
|
|||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
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 {
|
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
|
package com.meshcore.sar.meshcore_sar_app
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="128.444718">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="119.588068">
|
||||||
|
|
||||||
</testcase>
|
</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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -2541,5 +2541,11 @@
|
|||||||
"yourDrawingsCount": "Ihre Zeichnungen ({count})",
|
"yourDrawingsCount": "Ihre Zeichnungen ({count})",
|
||||||
"shared": "Geteilt",
|
"shared": "Geteilt",
|
||||||
"line": "Linie",
|
"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": "Rectangle",
|
||||||
"@rectangle": {
|
"@rectangle": {
|
||||||
"description": "Rectangle drawing type label"
|
"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})",
|
"yourDrawingsCount": "Sus Dibujos ({count})",
|
||||||
"shared": "Compartido",
|
"shared": "Compartido",
|
||||||
"line": "Línea",
|
"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})",
|
"yourDrawingsCount": "Vos Dessins ({count})",
|
||||||
"shared": "Partagé",
|
"shared": "Partagé",
|
||||||
"line": "Ligne",
|
"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})",
|
"yourDrawingsCount": "Vaši Crteži ({count})",
|
||||||
"shared": "Podijeljeno",
|
"shared": "Podijeljeno",
|
||||||
"line": "Linija",
|
"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})",
|
"yourDrawingsCount": "I Tuoi Disegni ({count})",
|
||||||
"shared": "Condiviso",
|
"shared": "Condiviso",
|
||||||
"line": "Linea",
|
"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:
|
/// In en, this message translates to:
|
||||||
/// **'Rectangle'**
|
/// **'Rectangle'**
|
||||||
String get 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
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1869,4 +1869,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get rectangle => 'Rechteck';
|
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
|
@override
|
||||||
String get rectangle => 'Rectangle';
|
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
|
@override
|
||||||
String get rectangle => 'Rectángulo';
|
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
|
@override
|
||||||
String get rectangle => 'Rectangle';
|
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
|
@override
|
||||||
String get rectangle => 'Pravokutnik';
|
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
|
@override
|
||||||
String get rectangle => 'Rettangolo';
|
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
|
@override
|
||||||
String get rectangle => 'Pravokotnik';
|
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})",
|
"yourDrawingsCount": "Vaše Risbe ({count})",
|
||||||
"shared": "Deljeno",
|
"shared": "Deljeno",
|
||||||
"line": "Črta",
|
"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/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -13,6 +14,8 @@ import 'providers/app_provider.dart';
|
|||||||
import 'services/tile_cache_service.dart';
|
import 'services/tile_cache_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.dart';
|
import 'services/locale_preferences.dart';
|
||||||
|
import 'services/update_checker_service.dart';
|
||||||
|
import 'widgets/update_dialog.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
@@ -49,6 +52,10 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Check if we need to request location permissions
|
// Check if we need to request location permissions
|
||||||
await _checkLocationPermissions();
|
await _checkLocationPermissions();
|
||||||
|
|
||||||
|
// Check for app updates (Android only) - runs in background
|
||||||
|
// Shows dialog if update is available
|
||||||
|
_checkForUpdates();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInitialized = true;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_isInitialized) {
|
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