mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
rework one-time migrations
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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<void> 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<void> 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<void> reloadUploadQueue() async {
|
||||
await _uploadQueueState.reloadQueue();
|
||||
}
|
||||
|
||||
// ---------- Suspected Location Methods ----------
|
||||
Future<void> setSuspectedLocationsEnabled(bool enabled) async {
|
||||
await _suspectedLocationState.setEnabled(enabled);
|
||||
|
||||
81
lib/migrations.dart
Normal file
81
lib/migrations.dart
Normal file
@@ -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<void> 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<void> 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<void> 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<void> 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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ class _HomeScreenState extends State<HomeScreen> 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
|
||||
|
||||
@@ -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<void> runMigration(String version, AppState appState) async {
|
||||
Future<void> 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
|
||||
|
||||
160
lib/services/nuclear_reset_service.dart
Normal file
160
lib/services/nuclear_reset_service.dart
Normal file
@@ -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<void> 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<void> _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<void> _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<void> _clearDirectory(
|
||||
Future<Directory> 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<String> 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<void> _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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
107
lib/widgets/nuclear_reset_dialog.dart
Normal file
107
lib/widgets/nuclear_reset_dialog.dart
Normal file
@@ -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<void> _copyErrorToClipboard() async {
|
||||
await NuclearResetService.copyToClipboard(errorReport);
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user