@ -2,10 +2,14 @@ import 'dart:io';
import ' package:drift/drift.dart ' ;
import ' package:drift_flutter/drift_flutter.dart ' ;
import ' package:flutter/foundation.dart ' ;
import ' package:path/path.dart ' as p ;
import ' package:path_provider/path_provider.dart ' ;
import ' ../../utils/app_logger.dart ' ;
/ / sqlite3 uses dart: ffi , which is unavailable on web ; import the native impl
/ / only off web so ` flutter build web ` still compiles ( # 363 ) .
import ' db_snapshot_web.dart ' if ( dart . library . io ) ' db_snapshot_io.dart ' ;
part ' offband_database.g.dart ' ;
@ -37,17 +41,14 @@ class OffbandDatabase extends _$OffbandDatabase {
static QueryExecutor _defaultExecutor ( ) {
return driftDatabase (
name: _dbName ,
/ / Native default is getApplicationDocumentsDirectory ( ) , which on Windows
/ / is the user ' s Documents folder — redirected into OneDrive on most
/ / machines . A live SQLite file syncing to OneDrive risks lock contention
/ / and corruption , and it is the wrong place for app data . Use the
/ / application - support dir instead ( % APPDATA % on Windows ) , where
/ / SharedPreferences already lives . path_provider has no web
/ / implementation , so this only applies off web ; web ignores
/ / databaseDirectory and uses OPFS / IndexedDB .
native : DriftNativeOptions (
databaseDirectory: _appSupportDatabaseDirectory ,
) ,
/ / The DB directory is PINNED ( see _pinnedDatabaseDirectory ) : a constant
/ / path that never depends on the exe ' s version metadata or launch
/ / location , so builds cannot diverge onto different databases ( # 363 ) .
/ / This also keeps the file out of the Documents / OneDrive dir ( the native
/ / default ) , where syncing a live SQLite file risks lock contention and
/ / corruption . path_provider has no web implementation , so this only
/ / applies off web ; web ignores databaseDirectory and uses OPFS / IndexedDB .
native : DriftNativeOptions ( databaseDirectory: _pinnedDatabaseDirectory ) ,
web: DriftWebOptions (
sqlite3Wasm: Uri . parse ( ' sqlite3.wasm ' ) ,
/ / Filename matches the asset published by the drift release , which is
@ -59,82 +60,234 @@ class OffbandDatabase extends _$OffbandDatabase {
) ;
}
/ / / Resolves the application - support directory , and relocates a database left
/ / / in the old documents location by an earlier build .
/ / / Resolves the ONE fixed directory the database lives in , and migrates a DB
/ / / left in an older location by a previous build into it .
/ / /
/ / / The path is pinned so it never depends on the executable ' s version
/ / / metadata or launch location ( # 363 ) . ` getApplicationSupportDirectory ( ) ` on
/ / / Windows derives the folder from the exe ' s embedded Company/Product
/ / / strings , so two builds - or a future rebrand - would read DIFFERENT
/ / / databases and the history would appear to vanish . On Windows the DB is
/ / / therefore pinned to ` % APPDATA % \ Offband MeshCore ` , a constant .
/ / /
/ / / Builds before this fix opened the DB under getApplicationDocumentsDirectory
/ / / ( OneDrive on Windows ) . Existing installs already have data there , so this
/ / / moves the file - and its ` - wal ` / ` - shm ` sidecars - to the new location on
/ / / first run , once , before drift opens it . Never deletes the source without a
/ / / successful move ; a failed move leaves the old file so no data is stranded .
static Future < Directory > _appSupportDatabaseDirectory ( ) async {
final support = await getApplicationSupportDirectory ( ) ;
final newPath = p . join ( support . path , ' $ _dbName .sqlite ' ) ;
if ( ! File ( newPath ) . existsSync ( ) ) {
/ / / On first run at the pinned location , the richest ` offband_store . sqlite `
/ / / from the old locations ( the metadata - derived support dir ; the
/ / / documents / OneDrive dir ; the earlier nested support path ) is copied in - a
/ / / copy , never a move: the source is left intact so nothing is stranded if
/ / / anything goes wrong .
static Future < Directory > _pinnedDatabaseDirectory ( ) async {
final dir = await _resolvePinnedDir ( ) ;
if ( ! dir . existsSync ( ) ) dir . createSync ( recursive: true ) ;
final target = p . join ( dir . path , ' $ _dbName .sqlite ' ) ;
if ( ! File ( target ) . existsSync ( ) ) {
try {
final docs = await getApplicationDocumentsDirectory ( ) ;
final oldPath = p . join ( docs . path , ' $ _dbName .sqlite ' ) ;
if ( File ( oldPath ) . existsSync ( ) & & oldPath ! = newPath ) {
/ / Copy ALL files first , verify , and only then delete the sources .
/ / Deleting per - file mid - loop ( Gemini review ) could strand the main
/ / . sqlite in one place and its - wal in another if a later copy
/ / failed , corrupting the DB . Copy - all - then - delete - all makes a
/ / partial failure recoverable: the original stays intact and the
/ / half - written destination is cleaned up .
final suffixes = [ ' ' , ' -wal ' , ' -shm ' ] ;
final present = suffixes
. where ( ( s ) = > File ( ' $ oldPath $ s ' ) . existsSync ( ) )
. toList ( ) ;
try {
for ( final s in present ) {
File ( ' $ oldPath $ s ' ) . copySync ( ' $ newPath $ s ' ) ;
}
/ / Verify every copy exists with a matching size before deleting .
for ( final s in present ) {
final src = File ( ' $ oldPath $ s ' ) ;
final dst = File ( ' $ newPath $ s ' ) ;
if ( ! dst . existsSync ( ) | | dst . lengthSync ( ) ! = src . lengthSync ( ) ) {
throw StateError ( ' copy verification failed for $ newPath $ s ' ) ;
}
}
for ( final s in present ) {
File ( ' $ oldPath $ s ' ) . deleteSync ( ) ;
}
appLogger . info (
' Relocated drift DB out of the documents dir (OneDrive on '
' Windows) into the app-support dir: $ oldPath -> $ newPath ' ,
tag: ' Storage ' ,
) ;
} catch ( e ) {
/ / Roll back any partial destination so drift does not open a
/ / half - copied DB ; the original in the documents dir is untouched .
for ( final s in present ) {
final dst = File ( ' $ newPath $ s ' ) ;
if ( dst . existsSync ( ) ) {
try {
dst . deleteSync ( ) ;
} catch ( _ ) { }
}
}
rethrow ;
}
}
await _importRichestLegacyDatabase ( target ) ;
} catch ( e ) {
/ / Relocation is best - effort . On failure the ORIGINAL DB is left intact
/ / in the documents dir ( the destination was rolled back ) , so no data is
/ / lost - drift opens a fresh DB at the support location and the user ' s
/ / history remains recoverable from the old path . Loud , never silent .
/ / Best - effort: on failure every SOURCE is left intact and any partial
/ / destination is rolled back , so drift opens a fresh DB and the old
/ / data stays recoverable . Loud , never silent ( SAFELANE 6 ) .
appLogger . error (
' Failed to relocate the drift DB from the documents dir: $ e . The '
' original at the old path is intact; a fresh DB will open at the '
' app-support location. Recover the old DB manually if needed .' ,
' Failed to migrate an existing drift DB into the pinned directory '
' ( $ target ): $ e . Sources are intact; a fresh DB will open here and the '
' old data remains recoverable. ' ,
tag: ' Storage ' ,
) ;
}
}
return support ;
return dir ;
}
/ / / The fixed directory: ` < data root > / Offband MeshCore ` , a constant .
/ / /
/ / / Windows uses ` % APPDATA % ` . Linux is pinned too , because its support dir
/ / / derives from the executable NAME and so has the same divergence bug this
/ / / fixes ; it uses ` $XDG_DATA_HOME ` ( or ` ~ / . local / share ` ) . macOS is left on
/ / / ` getApplicationSupportDirectory ( ) ` , which is keyed by the stable bundle id .
static Future < Directory > _resolvePinnedDir ( ) async {
if ( Platform . isWindows ) {
final appData = Platform . environment [ ' APPDATA ' ] ;
if ( appData ! = null & & appData . isNotEmpty ) {
return Directory ( p . join ( appData , _pinnedFolder ) ) ;
}
}
if ( Platform . isLinux ) {
final xdg = Platform . environment [ ' XDG_DATA_HOME ' ] ;
final home = Platform . environment [ ' HOME ' ] ;
final base = ( xdg ! = null & & xdg . isNotEmpty )
? xdg
: ( home ! = null & & home . isNotEmpty
? p . join ( home , ' .local ' , ' share ' )
: null ) ;
if ( base ! = null ) return Directory ( p . join ( base , _pinnedFolder ) ) ;
}
return getApplicationSupportDirectory ( ) ;
}
static const String _pinnedFolder = ' Offband MeshCore ' ;
/ / / Snapshots the legacy DB ( chosen from the known and scanned locations ) into
/ / / [ target ] ( which must not yet exist ) , leaving the source intact . A no - op on
/ / / a fresh install where no legacy DB exists .
static Future < void > _importRichestLegacyDatabase ( String target ) async {
/ / Prefer the KNOWN canonical locations in priority order: these are where a
/ / normal prior build wrote ( the metadata - derived support dir , then the
/ / documents / OneDrive dir the first builds used ) . Using them directly avoids
/ / the size heuristic picking a larger - but - junk DB from some other build .
final known = < String > [ ] ;
try {
final support = await getApplicationSupportDirectory ( ) ;
known . add ( p . join ( support . path , ' $ _dbName .sqlite ' ) ) ;
} catch ( _ ) { }
try {
final docs = await getApplicationDocumentsDirectory ( ) ;
known . add ( p . join ( docs . path , ' $ _dbName .sqlite ' ) ) ;
} catch ( _ ) { }
final scanned = _scanAppDataForDatabases ( )
. where ( ( c ) = > c ! = target )
. toList ( ) ;
final source = chooseMigrationSource (
known . where ( ( c ) = > c ! = target ) . toList ( ) ,
scanned ,
) ;
if ( source = = null ) return ; / / fresh install , nothing to migrate
snapshotDatabase ( source , target ) ;
appLogger . info (
' Migrated drift DB into the pinned directory: $ source -> $ target '
' (source left intact) ' ,
tag: ' Storage ' ,
) ;
}
/ / / Writes a consistent snapshot of the [ source ] DB to [ target ] via SQLite
/ / / ` VACUUM INTO ` , which takes a read transaction and so produces a correct
/ / / snapshot even if the source is being written by another process ( an old
/ / / build left running ) - the failure mode a raw file copy of a live WAL
/ / / database has ( Gemini review ) .
/ / /
/ / / On ANY failure it deletes the partial output and RETHROWS - it does NOT
/ / / fall back to a file copy . VACUUM most often fails precisely because a
/ / / writer holds a lock , which is exactly when a raw copy would be unsafe , so
/ / / the safe move is to abort: the source is untouched and drift opens a fresh
/ / / DB , keeping the history recoverable .
/ / /
/ / / The source ' s main and `-wal` files are not written; SQLite may create or
/ / / touch the temporary ` - shm ` file next to the source when opening a WAL DB .
/ / / Visible for testing .
@ visibleForTesting
static void snapshotDatabase ( String source , String target ) {
try {
vacuumInto ( source , target ) ;
final out = File ( target ) ;
if ( ! out . existsSync ( ) | | out . lengthSync ( ) = = 0 ) {
throw StateError ( ' VACUUM INTO produced no output at $ target ' ) ;
}
} catch ( _ ) {
final out = File ( target ) ;
if ( out . existsSync ( ) ) {
try {
out . deleteSync ( ) ;
} catch ( _ ) { }
}
rethrow ;
}
}
/ / / Bounded scan of the app - data roots for the DB file , so one left by a build
/ / / whose folder name we do not know ( its metadata differed ) is still found .
static List < String > _scanAppDataForDatabases ( ) {
final roots = < Directory > [ ] ;
for ( final env in const [ ' APPDATA ' , ' LOCALAPPDATA ' ] ) {
final root = Platform . environment [ env ] ;
if ( root = = null | | root . isEmpty ) continue ;
final dir = Directory ( root ) ;
if ( dir . existsSync ( ) ) roots . add ( dir ) ;
}
return findDatabaseFiles ( roots , ' $ _dbName .sqlite ' ) ;
}
/ / / Depth - limited , entry - capped search of [ roots ] for files named [ fileName ] .
/ / /
/ / / No directory - name blocklist: a build ' s data can legitimately sit under a
/ / / folder named after ANY company string ( e . g . ` % APPDATA % \ Microsoft \ . . . ` for
/ / / a runner that never set custom metadata ) , so skipping by name would lose
/ / / exactly the data this migration exists to preserve . Instead the walk is
/ / / bounded by [ maxDepth ] and a hard [ maxEntries ] visit cap so a pathological
/ / / tree ( a folder with hundreds of thousands of files , a slow network drive )
/ / / cannot stall startup . Symlinks are not followed , so it cannot cycle ;
/ / / unreadable directories are skipped . Visible for testing .
@ visibleForTesting
static List < String > findDatabaseFiles (
List < Directory > roots ,
String fileName , {
int maxDepth = 2 ,
int maxEntries = 50000 ,
} ) {
final found = < String > [ ] ;
var visited = 0 ;
void walk ( Directory dir , int depth ) {
List < FileSystemEntity > entries ;
try {
entries = dir . listSync ( followLinks: false ) ;
} catch ( _ ) {
return ; / / permission denied etc .
}
for ( final e in entries ) {
if ( visited + + > = maxEntries ) return ;
if ( e is File ) {
if ( p . basename ( e . path ) = = fileName ) found . add ( e . path ) ;
} else if ( e is Directory & & depth < maxDepth ) {
walk ( e , depth + 1 ) ;
}
}
}
for ( final root in roots ) {
walk ( root , 0 ) ;
}
return found ;
}
/ / / Picks the DB to migrate: the first existing , non - empty path in
/ / / [ knownInPriority ] ( the canonical locations a normal prior build wrote to ) ,
/ / / else the largest DB in [ scanned ] ( the unknown - folder fallback ) .
/ / /
/ / / Preferring the known locations stops a larger - but - junk DB from some other
/ / / build ( a size - heuristic misfire ) from beating the real one . Visible for
/ / / testing .
@ visibleForTesting
static String ? chooseMigrationSource (
List < String > knownInPriority ,
List < String > scanned ,
) {
for ( final k in knownInPriority ) {
final f = File ( k ) ;
if ( f . existsSync ( ) & & f . lengthSync ( ) > 0 ) return k ;
}
return richestExisting ( scanned ) ;
}
/ / / Returns the existing , non - empty candidate path with the LARGEST main file ,
/ / / or null if none exist . Choosing by size guarantees a small / fresh DB never
/ / / wins over one holding real history . Visible for testing .
@ visibleForTesting
static String ? richestExisting ( List < String > candidatePaths ) {
String ? best ;
int bestSize = 0 ;
for ( final path in candidatePaths ) {
final f = File ( path ) ;
if ( ! f . existsSync ( ) ) continue ;
final size = f . lengthSync ( ) ;
if ( size > bestSize ) {
bestSize = size ;
best = path ;
}
}
return best ;
}
@ override