feat: Implement update checker and dialog for available app updates with localization support

This commit is contained in:
Janez T
2025-10-26 17:47:37 +01:00
parent 0eae963efc
commit 0f9974c1e9
26 changed files with 727 additions and 12 deletions

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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';
}

View File

@@ -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"
}

View File

@@ -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) {

View 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)';
}
}

View 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;
}
}

View 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;
}
}

View 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,
),
);
}
}
}
}