Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 217 additions & 2 deletions lib/core/presentation/pages/startup_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite3;

import 'package:submersion/core/database/database.dart';
import 'package:submersion/core/database/database_version_exception.dart';
Expand Down Expand Up @@ -43,6 +44,9 @@ enum _StartupState {
backingUp,
migrating,
backupFailed,
recoveryRequired,
recovering,
recoveryFailed,
ready,
error,
}
Expand Down Expand Up @@ -99,6 +103,7 @@ class _StartupWrapperState extends State<StartupWrapper>
int _dbVersion = 0;
int _appVersion = 0;
BackupFailedException? _backupError;
sqlite3.SqliteException? _readonlyError;

/// Drives the dissolve of the splash layer over the mounted app beneath.
/// Forward-only; starts when _state first reaches ready.
Expand Down Expand Up @@ -199,6 +204,27 @@ class _StartupWrapperState extends State<StartupWrapper>
_appVersion = e.appVersion;
});
}
} on sqlite3.SqliteException catch (e) {
if (DatabaseService.isRecoverableReadonlyError(e)) {
debugPrint(
'Startup hit SQLITE_READONLY (code ${e.extendedResultCode}); '
'offering hot-journal recovery.',
);
if (mounted) {
setState(() {
_state = _StartupState.recoveryRequired;
_readonlyError = e;
});
}
} else {
debugPrint('FATAL: App initialization failed: $e');
if (mounted) {
setState(() {
_state = _StartupState.error;
_errorMessage = '$e';
});
}
}
} catch (e) {
debugPrint('FATAL: App initialization failed: $e');
if (mounted) {
Expand All @@ -210,6 +236,42 @@ class _StartupWrapperState extends State<StartupWrapper>
}
}

/// Attempt to recover the database from a hot-journal-readonly error by
/// reopening in read-write mode (which forces SQLite to finish the
/// rollback), then retry initialization from the top.
Future<void> _runRecovery() async {
if (!mounted) return;
setState(() {
_state = _StartupState.recovering;
// Clear any stale text from a prior failed attempt so the
// recoveryFailed UI reflects only the current reason.
_errorMessage = '';
});
try {
final dbPath = await widget.locationService.getDatabasePath();
final recovered = DatabaseService.recoverHotJournal(dbPath);
if (!recovered) {
if (mounted) {
setState(() {
_state = _StartupState.recoveryFailed;
_errorMessage =
'SQLite could not reopen the database to roll back the '
'interrupted transaction.';
});
}
return;
Comment thread
ericgriffin marked this conversation as resolved.
}
await _runInitialization();
} catch (e) {
if (mounted) {
setState(() {
_state = _StartupState.recoveryFailed;
_errorMessage = '$e';
});
}
}
}

Future<void> _initializeServices() async {
void onProgress(int currentStep, int totalSteps) {
if (mounted) {
Expand Down Expand Up @@ -390,7 +452,10 @@ class _StartupWrapperState extends State<StartupWrapper>
debugShowCheckedModeBanner: false,
home:
(_state == _StartupState.error ||
_state == _StartupState.backupFailed)
_state == _StartupState.backupFailed ||
_state == _StartupState.recoveryRequired ||
_state == _StartupState.recovering ||
_state == _StartupState.recoveryFailed)
? Scaffold(
key: const ValueKey('error'),
backgroundColor: backgroundColor,
Expand Down Expand Up @@ -497,6 +562,12 @@ class _StartupWrapperState extends State<StartupWrapper>
);
}

if (_state == _StartupState.recoveryRequired ||
_state == _StartupState.recovering ||
_state == _StartupState.recoveryFailed) {
return _buildRecoveryContent(textColor, subtitleColor);
}

if (_isVersionMismatch) {
return Padding(
padding: const EdgeInsets.all(24),
Expand Down Expand Up @@ -561,7 +632,8 @@ class _StartupWrapperState extends State<StartupWrapper>
const SizedBox(height: 16),
Text(
'Try restarting the app. If this persists, '
'reinstall or contact support.',
'contact support — your data is still on disk and does not '
'require a reinstall.',
style: TextStyle(fontSize: 14, color: subtitleColor),
textAlign: TextAlign.center,
),
Expand All @@ -571,4 +643,147 @@ class _StartupWrapperState extends State<StartupWrapper>
),
);
}

Widget _buildRecoveryContent(Color textColor, Color subtitleColor) {
if (_state == _StartupState.recovering) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(),
),
const SizedBox(height: 24),
Text(
'Recovering database...',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Rolling back the interrupted transaction. This usually '
'takes a few seconds.',
style: TextStyle(fontSize: 14, color: subtitleColor),
textAlign: TextAlign.center,
),
],
),
);
}

if (_state == _StartupState.recoveryFailed) {
final details = _errorMessage.isNotEmpty
? _errorMessage
: (_readonlyError?.toString() ?? '');
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.orange),
const SizedBox(height: 24),
Text(
'Recovery did not complete',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'The database could not be rolled back automatically. Your '
'data is still on disk; contact support before reinstalling '
'so we can help you recover it.',
style: TextStyle(fontSize: 14, color: subtitleColor),
textAlign: TextAlign.center,
),
if (details.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
details,
style: TextStyle(
fontSize: 12,
color: subtitleColor,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
Wrap(
spacing: 12,
children: [
OutlinedButton(
onPressed: _runRecovery,
child: const Text('Try again'),
),
FilledButton(onPressed: _closeApp, child: const Text('Close')),
],
),
Comment thread
ericgriffin marked this conversation as resolved.
],
),
);
}

// recoveryRequired
final code = _readonlyError?.extendedResultCode;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.build_circle_outlined, size: 64, color: Colors.blue),
const SizedBox(height: 24),
Text(
'Database needs recovery',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'A previous session was interrupted while writing to the '
'database. Your data is still on disk; we just need to finish '
'rolling back the cancelled change before the app can open.',
style: TextStyle(fontSize: 14, color: subtitleColor),
textAlign: TextAlign.center,
),
if (code != null) ...[
const SizedBox(height: 12),
Text(
'SQLite code $code',
style: TextStyle(
fontSize: 12,
color: subtitleColor,
fontFamily: 'monospace',
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _runRecovery,
child: const Text('Recover database'),
),
const SizedBox(height: 8),
TextButton(
onPressed: _closeApp,
child: const Text('Close without recovering'),
),
],
),
);
}
}
39 changes: 38 additions & 1 deletion lib/core/services/database_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,16 @@ class DatabaseService {
/// Reads the stored schema version from a database file without opening it
/// through Drift. Returns null if the file does not exist, or the integer
/// PRAGMA user_version value otherwise.
///
/// Opens in read-write mode (not read-only) so SQLite can automatically
/// roll back any hot journal left behind by a previous crash. A read-only
/// open on a db with a pending rollback throws SQLITE_READONLY_ROLLBACK
/// (extended code 776) before even the first PRAGMA can execute.
static int? getStoredSchemaVersion(String dbPath) {
final file = File(dbPath);
if (!file.existsSync()) return null;

final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readOnly);
final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readWrite);
try {
final result = db.select('PRAGMA user_version');
if (result.isEmpty) return null;
Expand All @@ -210,6 +215,38 @@ class DatabaseService {
}
}

/// Force SQLite to complete any pending hot-journal rollback on [dbPath].
///
/// Opens the file in read-write mode — the very act of opening triggers
/// SQLite's automatic recovery of a hot journal. Returns true if the file
/// opened cleanly (recovery either wasn't needed or succeeded), false if
/// the journal could not be rolled back (file still read-only, on a
/// read-only volume, etc.).
///
/// Safe to call on a file without a hot journal: it simply no-ops.
static bool recoverHotJournal(String dbPath) {
final file = File(dbPath);
if (!file.existsSync()) return true;
try {
final db = sqlite3.sqlite3.open(dbPath, mode: sqlite3.OpenMode.readWrite);
try {
db.select('PRAGMA user_version');
} finally {
db.dispose();
}
return true;
} on sqlite3.SqliteException {
return false;
}
}

/// True if [error] is a [sqlite3.SqliteException] in the SQLITE_READONLY
/// family (primary result code 8) — typically SQLITE_READONLY_ROLLBACK
/// (776) after a cancelled transaction left a hot journal behind.
static bool isRecoverableReadonlyError(Object error) {
return error is sqlite3.SqliteException && error.resultCode == 8;
}

/// Resolve the database path using location service or default
Future<String> _resolveDatabasePath() async {
if (_locationService != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import 'package:submersion/features/equipment/data/repositories/equipment_reposi
import 'package:submersion/features/equipment/data/repositories/equipment_set_repository_impl.dart';
import 'package:submersion/features/equipment/domain/entities/equipment_item.dart';
import 'package:submersion/features/equipment/domain/entities/equipment_set.dart';
import 'package:submersion/features/import_wizard/domain/models/import_cancellation_token.dart';
import 'package:submersion/features/import_wizard/domain/models/import_phase.dart';
import 'package:submersion/features/tags/data/repositories/tag_repository.dart';
import 'package:submersion/features/tags/domain/entities/tag.dart';
Expand Down Expand Up @@ -216,13 +217,18 @@ class UddfEntityImporter {
///
/// Only entities at indices present in [selections] are imported.
/// Reports progress via [onProgress] callback.
///
/// If [cancelToken] is non-null, the dive-import loop polls
/// [ImportCancellationToken.isCancelled] between each dive and returns the
/// partial result already persisted when cancellation is observed.
Future<UddfEntityImportResult> import({
required UddfImportResult data,
required UddfImportSelections selections,
required ImportRepositories repositories,
required String diverId,
bool retainSourceDiveNumbers = false,
ImportProgressCallback? onProgress,
ImportCancellationToken? cancelToken,
}) async {
final now = DateTime.now();

Expand Down Expand Up @@ -350,6 +356,7 @@ class UddfEntityImporter {
retainSourceDiveNumbers: retainSourceDiveNumbers,
now: now,
onProgress: onProgress,
cancelToken: cancelToken,
);

return UddfEntityImportResult(
Expand Down Expand Up @@ -943,6 +950,7 @@ class UddfEntityImporter {
bool retainSourceDiveNumbers = false,
required DateTime now,
ImportProgressCallback? onProgress,
ImportCancellationToken? cancelToken,
}) async {
if (selected.isEmpty) return const _DiveImportResult(0, 0);
onProgress?.call(ImportPhase.dives, 0, selected.length);
Expand All @@ -965,6 +973,8 @@ class UddfEntityImporter {
: await repos.diveRepository.getNextDiveNumber(diverId: diverId);

for (final i in sortedSelected) {
if (cancelToken?.isCancelled ?? false) break;

final diveData = items[i];

// Build profile (include setpoint/ppO2 sensor readings)
Expand Down
Loading
Loading