diff --git a/assets/changelog.json b/assets/changelog.json index 10f7186..27fd285 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -2,7 +2,8 @@ "1.6.3": { "content": [ "• Fixed navigation sheet button flow - route to/from buttons no longer reappear after selecting second location", - "• Added cancel button when selecting second route point for easier exit from route planning" + "• Added cancel button when selecting second route point for easier exit from route planning", + "• Removed placeholder FOV values from built-in device profiles - improves data quality for submissions" ] }, "1.6.2": { diff --git a/lib/app_state.dart b/lib/app_state.dart index 4b805c2..f5e4140 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -633,13 +633,7 @@ class AppState extends ChangeNotifier { await _settingsState.setNetworkStatusIndicatorEnabled(enabled); } - /// Migrate upload queue to new two-stage changeset system (v1.5.3) - Future migrateUploadQueueToTwoStageSystem() async { - // Migration is handled automatically in PendingUpload.fromJson via _migrateFromLegacyFields - // This method triggers a queue reload to apply migrations - await _uploadQueueState.reloadQueue(); - debugPrint('[AppState] Upload queue migration completed'); - } + /// Set suspected location minimum distance from real nodes Future setSuspectedLocationMinDistance(int distance) async { @@ -665,6 +659,11 @@ class AppState extends ChangeNotifier { _startUploader(); // resume uploader if not busy } + /// Reload upload queue from storage (for migration purposes) + Future reloadUploadQueue() async { + await _uploadQueueState.reloadQueue(); + } + // ---------- Suspected Location Methods ---------- Future setSuspectedLocationsEnabled(bool enabled) async { await _suspectedLocationState.setEnabled(enabled); diff --git a/lib/migrations.dart b/lib/migrations.dart new file mode 100644 index 0000000..e4d96c6 --- /dev/null +++ b/lib/migrations.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'app_state.dart'; +import 'services/profile_service.dart'; +import 'widgets/nuclear_reset_dialog.dart'; + +/// One-time migrations that run when users upgrade to specific versions. +/// Each migration function is named after the version where it should run. +class OneTimeMigrations { + /// Enable network status indicator for all existing users (v1.3.1) + static Future migrate_1_3_1(AppState appState) async { + await appState.setNetworkStatusIndicatorEnabled(true); + debugPrint('[Migration] 1.3.1 completed: enabled network status indicator'); + } + + /// Migrate upload queue to new two-stage changeset system (v1.5.3) + static Future migrate_1_5_3(AppState appState) async { + // Migration is handled automatically in PendingUpload.fromJson via _migrateFromLegacyFields + // This triggers a queue reload to apply migrations + await appState.reloadUploadQueue(); + debugPrint('[Migration] 1.5.3 completed: migrated upload queue to two-stage system'); + } + + /// Clear FOV values from built-in profiles only (v1.6.3) + static Future migrate_1_6_3(AppState appState) async { + // Load all custom profiles from storage (includes any customized built-in profiles) + final profiles = await ProfileService().load(); + + // Find profiles with built-in IDs and clear their FOV values + final updatedProfiles = profiles.map((profile) { + if (profile.id.startsWith('builtin-') && profile.fov != null) { + debugPrint('[Migration] Clearing FOV from profile: ${profile.id}'); + return profile.copyWith(fov: null); + } + return profile; + }).toList(); + + // Save updated profiles back to storage + await ProfileService().save(updatedProfiles); + + debugPrint('[Migration] 1.6.3 completed: cleared FOV values from built-in profiles'); + } + + /// Get the migration function for a specific version + static Future Function(AppState)? getMigrationForVersion(String version) { + switch (version) { + case '1.3.1': + return migrate_1_3_1; + case '1.5.3': + return migrate_1_5_3; + case '1.6.3': + return migrate_1_6_3; + default: + return null; + } + } + + /// Run migration for a specific version with nuclear reset on failure + static Future runMigration(String version, AppState appState, BuildContext? context) async { + try { + final migration = getMigrationForVersion(version); + if (migration != null) { + await migration(appState); + } else { + debugPrint('[Migration] Unknown migration version: $version'); + } + } catch (error, stackTrace) { + debugPrint('[Migration] CRITICAL: Migration $version failed: $error'); + debugPrint('[Migration] Stack trace: $stackTrace'); + + // Nuclear option: clear everything and show non-dismissible error dialog + if (context != null) { + NuclearResetDialog.show(context, error, stackTrace); + } else { + // If no context available, just log and hope for the best + debugPrint('[Migration] No context available for error dialog, migration failure unhandled'); + } + } + } +} \ No newline at end of file diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0973c11..91a32bb 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -160,7 +160,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { // Run any needed migrations first final versionsNeedingMigration = await ChangelogService().getVersionsNeedingMigration(); for (final version in versionsNeedingMigration) { - await ChangelogService().runMigration(version, appState); + await ChangelogService().runMigration(version, appState, context); } // Determine what popup to show diff --git a/lib/services/changelog_service.dart b/lib/services/changelog_service.dart index e8a4a05..8f83bc9 100644 --- a/lib/services/changelog_service.dart +++ b/lib/services/changelog_service.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'version_service.dart'; import '../app_state.dart'; +import '../migrations.dart'; /// Service for managing changelog data and first launch detection class ChangelogService { @@ -207,6 +209,10 @@ class ChangelogService { versionsNeedingMigration.add('1.5.3'); } + if (needsMigration(lastSeenVersion, currentVersion, '1.6.3')) { + versionsNeedingMigration.add('1.6.3'); + } + // Future versions can be added here // if (needsMigration(lastSeenVersion, currentVersion, '2.0.0')) { // versionsNeedingMigration.add('2.0.0'); @@ -262,31 +268,9 @@ class ChangelogService { bool get isInitialized => _initialized; /// Run a specific migration by version number - Future runMigration(String version, AppState appState) async { + Future runMigration(String version, AppState appState, BuildContext? context) async { debugPrint('[ChangelogService] Running $version migration'); - - switch (version) { - case '1.3.1': - // Enable network status indicator for all existing users - await appState.setNetworkStatusIndicatorEnabled(true); - debugPrint('[ChangelogService] 1.3.1 migration completed: enabled network status indicator'); - break; - - case '1.5.3': - // Migrate upload queue to new two-stage changeset system - await appState.migrateUploadQueueToTwoStageSystem(); - debugPrint('[ChangelogService] 1.5.3 migration completed: migrated upload queue to two-stage system'); - break; - - // Future migrations can be added here - // case '2.0.0': - // await appState.doSomethingNew(); - // debugPrint('[ChangelogService] 2.0.0 migration completed'); - // break; - - default: - debugPrint('[ChangelogService] Unknown migration version: $version'); - } + await OneTimeMigrations.runMigration(version, appState, context); } /// Check if a migration should run diff --git a/lib/services/nuclear_reset_service.dart b/lib/services/nuclear_reset_service.dart new file mode 100644 index 0000000..6c98875 --- /dev/null +++ b/lib/services/nuclear_reset_service.dart @@ -0,0 +1,160 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'version_service.dart'; + +/// Nuclear reset service - clears ALL app data when migrations fail. +/// This is the "big hammer" approach for when something goes seriously wrong. +class NuclearResetService { + static final NuclearResetService _instance = NuclearResetService._(); + factory NuclearResetService() => _instance; + NuclearResetService._(); + + /// Completely clear all app data - SharedPreferences, files, caches, everything. + /// After this, the app should behave exactly like a fresh install. + static Future clearEverything() async { + try { + debugPrint('[NuclearReset] Starting complete app data wipe...'); + + // Clear ALL SharedPreferences + await _clearSharedPreferences(); + + // Clear ALL files in app directories + await _clearFileSystem(); + + debugPrint('[NuclearReset] Complete app data wipe finished'); + } catch (e) { + // Even the nuclear option can fail, but we can't do anything about it + debugPrint('[NuclearReset] Error during nuclear reset: $e'); + } + } + + /// Clear all SharedPreferences data + static Future _clearSharedPreferences() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + debugPrint('[NuclearReset] Cleared SharedPreferences'); + } catch (e) { + debugPrint('[NuclearReset] Failed to clear SharedPreferences: $e'); + } + } + + /// Clear all files and directories in app storage + static Future _clearFileSystem() async { + try { + // Clear Documents directory (offline areas, etc.) + await _clearDirectory(() => getApplicationDocumentsDirectory(), 'Documents'); + + // Clear Cache directory (tile cache, etc.) + await _clearDirectory(() => getTemporaryDirectory(), 'Cache'); + + // Clear Support directory if it exists (iOS/macOS) + if (!kIsWeb && (Platform.isIOS || Platform.isMacOS)) { + await _clearDirectory(() => getApplicationSupportDirectory(), 'Support'); + } + + } catch (e) { + debugPrint('[NuclearReset] Failed to clear file system: $e'); + } + } + + /// Clear a specific directory, with error handling + static Future _clearDirectory( + Future Function() getDirFunc, + String dirName, + ) async { + try { + final dir = await getDirFunc(); + if (dir.existsSync()) { + await dir.delete(recursive: true); + debugPrint('[NuclearReset] Cleared $dirName directory'); + } + } catch (e) { + debugPrint('[NuclearReset] Failed to clear $dirName directory: $e'); + } + } + + /// Generate error report information (safely, with fallbacks) + static Future generateErrorReport(Object error, StackTrace? stackTrace) async { + final buffer = StringBuffer(); + + // Basic error information (always include this) + buffer.writeln('MIGRATION FAILURE ERROR REPORT'); + buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); + buffer.writeln(''); + buffer.writeln('Error: $error'); + + if (stackTrace != null) { + buffer.writeln(''); + buffer.writeln('Stack trace:'); + buffer.writeln(stackTrace.toString()); + } + + // Try to add enrichment data, but don't fail if it doesn't work + await _addEnrichmentData(buffer); + + return buffer.toString(); + } + + /// Add device/app information to error report (with extensive error handling) + static Future _addEnrichmentData(StringBuffer buffer) async { + try { + buffer.writeln(''); + buffer.writeln('--- System Information ---'); + + // App version (should always work) + try { + buffer.writeln('App Version: ${VersionService().version}'); + } catch (e) { + buffer.writeln('App Version: [Failed to get version: $e]'); + } + + // Platform information + try { + if (!kIsWeb) { + buffer.writeln('Platform: ${Platform.operatingSystem}'); + buffer.writeln('OS Version: ${Platform.operatingSystemVersion}'); + } else { + buffer.writeln('Platform: Web'); + } + } catch (e) { + buffer.writeln('Platform: [Failed to get platform info: $e]'); + } + + // Flutter/Dart information + try { + buffer.writeln('Flutter Mode: ${kDebugMode ? 'Debug' : kProfileMode ? 'Profile' : 'Release'}'); + } catch (e) { + buffer.writeln('Flutter Mode: [Failed to get mode: $e]'); + } + + // Previous version (if available) + try { + final prefs = await SharedPreferences.getInstance(); + final lastVersion = prefs.getString('last_seen_version'); + buffer.writeln('Previous Version: ${lastVersion ?? 'Unknown (fresh install?)'}'); + } catch (e) { + buffer.writeln('Previous Version: [Failed to get: $e]'); + } + + } catch (e) { + // If enrichment completely fails, just note it + buffer.writeln(''); + buffer.writeln('--- System Information ---'); + buffer.writeln('[Failed to gather system information: $e]'); + } + } + + /// Copy text to clipboard (safely) + static Future copyToClipboard(String text) async { + try { + await Clipboard.setData(ClipboardData(text: text)); + debugPrint('[NuclearReset] Copied error report to clipboard'); + } catch (e) { + debugPrint('[NuclearReset] Failed to copy to clipboard: $e'); + } + } +} \ No newline at end of file diff --git a/lib/widgets/nuclear_reset_dialog.dart b/lib/widgets/nuclear_reset_dialog.dart new file mode 100644 index 0000000..ad10d34 --- /dev/null +++ b/lib/widgets/nuclear_reset_dialog.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../services/nuclear_reset_service.dart'; + +/// Non-dismissible error dialog shown when migrations fail and nuclear reset is triggered. +/// Forces user to restart the app by making it impossible to close this dialog. +class NuclearResetDialog extends StatelessWidget { + final String errorReport; + + const NuclearResetDialog({ + Key? key, + required this.errorReport, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return WillPopScope( + // Prevent back button from closing dialog + onWillPop: () async => false, + child: AlertDialog( + title: const Row( + children: [ + Icon(Icons.warning, color: Colors.red), + SizedBox(width: 8), + Text('Migration Error'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Unfortunately we encountered an issue during the app update and had to clear your settings and data.', + style: TextStyle(fontWeight: FontWeight.w500), + ), + SizedBox(height: 12), + Text( + 'You will need to:', + style: TextStyle(fontWeight: FontWeight.w500), + ), + SizedBox(height: 4), + Text('• Log back into OpenStreetMap'), + Text('• Recreate any custom profiles'), + Text('• Re-download any offline areas'), + SizedBox(height: 12), + Text( + 'Please close and restart the app to continue.', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + actions: [ + TextButton.icon( + onPressed: () => _copyErrorToClipboard(), + icon: const Icon(Icons.copy), + label: const Text('Copy Error'), + ), + TextButton.icon( + onPressed: () => _sendErrorToSupport(), + icon: const Icon(Icons.email), + label: const Text('Send to Support'), + ), + ], + // No dismiss button - forces user to restart app + ), + ); + } + + Future _copyErrorToClipboard() async { + await NuclearResetService.copyToClipboard(errorReport); + } + + Future _sendErrorToSupport() async { + const supportEmail = 'app@deflock.me'; + const subject = 'DeFlock App Migration Error Report'; + + // Create mailto URL with pre-filled error report + final body = Uri.encodeComponent(errorReport); + final mailtoUrl = 'mailto:$supportEmail?subject=${Uri.encodeComponent(subject)}&body=$body'; + + try { + final uri = Uri.parse(mailtoUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } catch (e) { + // If email fails, just copy to clipboard as fallback + await _copyErrorToClipboard(); + } + } + + /// Show the nuclear reset dialog (non-dismissible) + static Future show(BuildContext context, Object error, StackTrace? stackTrace) async { + // Generate error report + final errorReport = await NuclearResetService.generateErrorReport(error, stackTrace); + + // Clear all app data + await NuclearResetService.clearEverything(); + + // Show non-dismissible dialog + await showDialog( + context: context, + barrierDismissible: false, // Prevent tap-outside to dismiss + builder: (context) => NuclearResetDialog(errorReport: errorReport), + ); + } +} \ No newline at end of file