rework one-time migrations

This commit is contained in:
stopflock
2025-12-06 14:48:21 -06:00
parent adbe8c340c
commit b02623deac
7 changed files with 365 additions and 33 deletions

View File

@@ -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": {

View File

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

View File

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

View File

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

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

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