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
+6 -7
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
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');
}
}
}
}
+1 -1
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
+8 -24
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
+160
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');
}
}
}
+107
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),
);
}
}