Merge pull request #348 from OffbandMesh/integration/290-306-335
Storage overhaul + nav/freeze/erosion fixes (drift migration, #290/#306/#333/#335/#343)pull/354/head
commit
7482f4166a
@ -0,0 +1,244 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../prefs_manager.dart';
|
||||||
|
import 'offband_database.dart';
|
||||||
|
|
||||||
|
/// Bulk-data store backed by drift, replacing SharedPreferences for anything
|
||||||
|
/// large (#335).
|
||||||
|
///
|
||||||
|
/// Why this exists: SharedPreferences is a settings store. On Windows every
|
||||||
|
/// mutation re-encodes and rewrites the WHOLE file synchronously, and on web it
|
||||||
|
/// is backed by `localStorage`, which is capped at 5 MiB per origin and is also
|
||||||
|
/// synchronous. This install holds ~5.2 MB of bulk data, so the web build
|
||||||
|
/// cannot function at all today and Windows stalls for tens of seconds (#306).
|
||||||
|
///
|
||||||
|
/// Each key becomes one row, so a write touches one row rather than the entire
|
||||||
|
/// store.
|
||||||
|
class BlobStore {
|
||||||
|
BlobStore(this._db);
|
||||||
|
|
||||||
|
final OffbandDatabase _db;
|
||||||
|
|
||||||
|
static OffbandDatabase? _sharedDb;
|
||||||
|
static BlobStore? _override;
|
||||||
|
|
||||||
|
/// Process-wide instance. The database must be opened once; opening it twice
|
||||||
|
/// is an error on the web backends.
|
||||||
|
static BlobStore get instance =>
|
||||||
|
_override ?? BlobStore(_sharedDb ??= OffbandDatabase());
|
||||||
|
|
||||||
|
/// Test seam: point the singleton at an in-memory database.
|
||||||
|
@visibleForTesting
|
||||||
|
static void overrideForTest(BlobStore store) => _override = store;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void clearTestOverride() => _override = null;
|
||||||
|
|
||||||
|
/// Key families that hold bulk data. Everything else stays in
|
||||||
|
/// SharedPreferences, which is what it is for.
|
||||||
|
static const List<String> migratedPrefixes = [
|
||||||
|
'channel_messages_',
|
||||||
|
'messages_',
|
||||||
|
'contacts',
|
||||||
|
'discovered_contacts',
|
||||||
|
];
|
||||||
|
|
||||||
|
static bool isBulkKey(String key) => migratedPrefixes.any(key.startsWith);
|
||||||
|
|
||||||
|
/// Per-key operation chain. Merge-on-save and the legacy-key migration are
|
||||||
|
/// read-modify-write sequences with an await gap; two of them racing on the
|
||||||
|
/// same key would let the second clobber the first and silently lose
|
||||||
|
/// messages (Gemini review, 2026-07-20). Every RMW on a key runs through
|
||||||
|
/// [synchronized], which serialises operations per key while leaving
|
||||||
|
/// different keys concurrent.
|
||||||
|
final Map<String, Future<void>> _keyChains = {};
|
||||||
|
|
||||||
|
/// Serialises [action] against other synchronized actions on the same [key].
|
||||||
|
Future<T> synchronized<T>(String key, Future<T> Function() action) {
|
||||||
|
final prior = _keyChains[key] ?? Future<void>.value();
|
||||||
|
final result = prior.then((_) => action());
|
||||||
|
// Next op waits for this one; swallow errors so one failure does not wedge
|
||||||
|
// the chain for the key.
|
||||||
|
_keyChains[key] = result.then((_) {}, onError: (_) {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a bulk key, falling back to SharedPreferences if drift does not
|
||||||
|
/// have it.
|
||||||
|
///
|
||||||
|
/// Belt and braces for the switchover: if migration ever missed a key, the
|
||||||
|
/// data must still be reachable rather than silently reading as empty, which
|
||||||
|
/// is exactly how #333 presented. The fallback is LOUD, because a fallback
|
||||||
|
/// that fires in normal operation means the migration is incomplete and
|
||||||
|
/// somebody needs to know.
|
||||||
|
Future<String?> readWithPrefsFallback(String key) async {
|
||||||
|
final fromDrift = await read(key);
|
||||||
|
if (fromDrift != null) return fromDrift;
|
||||||
|
|
||||||
|
final raw = PrefsManager.instance.get(key);
|
||||||
|
if (raw is! String || raw.isEmpty) return null;
|
||||||
|
|
||||||
|
appLogger.warn(
|
||||||
|
'Blob read for $key fell back to SharedPreferences: it is NOT in drift. '
|
||||||
|
'Migration is incomplete for this key; serving the prefs copy.',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> read(String key) async {
|
||||||
|
final row = await (_db.select(
|
||||||
|
_db.storedBlobs,
|
||||||
|
)..where((t) => t.key.equals(key))).getSingleOrNull();
|
||||||
|
return row?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> write(String key, String value) async {
|
||||||
|
await _db
|
||||||
|
.into(_db.storedBlobs)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
StoredBlobsCompanion.insert(key: key, value: value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys beginning with [prefix], across BOTH backends.
|
||||||
|
///
|
||||||
|
/// Callers that clear a family of keys must see prefs-resident keys too, or
|
||||||
|
/// a pre-migration copy survives the clear and reappears through the read
|
||||||
|
/// fallback.
|
||||||
|
Future<List<String>> keysWithPrefix(String prefix) async {
|
||||||
|
// Filtered in Dart rather than SQL: the table holds tens of rows, so the
|
||||||
|
// cost is irrelevant and it avoids depending on a version-specific LIKE
|
||||||
|
// API for a pinned dependency.
|
||||||
|
final rows = await _db.select(_db.storedBlobs).get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rows.map((r) => r.key).where((k) => k.startsWith(prefix)),
|
||||||
|
...PrefsManager.instance.getKeys().where((k) => k.startsWith(prefix)),
|
||||||
|
}.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a key from BOTH backends, so a clear cannot be undone by a
|
||||||
|
/// leftover prefs copy surfacing through the fallback.
|
||||||
|
Future<void> deleteEverywhere(String key) async {
|
||||||
|
await delete(key);
|
||||||
|
await PrefsManager.instance.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(String key) async {
|
||||||
|
await (_db.delete(_db.storedBlobs)..where((t) => t.key.equals(key))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves bulk keys out of SharedPreferences into drift.
|
||||||
|
///
|
||||||
|
/// Ordering is deliberate and non-negotiable: **write, verify by reading
|
||||||
|
/// back, and only then remove the source.** #333 was caused by a storage path
|
||||||
|
/// that chose a key silently and made 566 real messages read as empty; a
|
||||||
|
/// migration that deleted before verifying could do that permanently rather
|
||||||
|
/// than cosmetically.
|
||||||
|
///
|
||||||
|
/// Idempotent: keys already migrated are skipped, so re-running is a no-op
|
||||||
|
/// rather than a duplicate or an overwrite of newer data.
|
||||||
|
///
|
||||||
|
/// Every outcome is logged (SAFELANE 6). A failure never silently drops a
|
||||||
|
/// key: the source is left intact and the error surfaces.
|
||||||
|
Future<MigrationReport> migrateFromPrefs() async {
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
final report = MigrationReport();
|
||||||
|
|
||||||
|
final bulkKeys = prefs.getKeys().where(isBulkKey).toList();
|
||||||
|
if (bulkKeys.isEmpty) {
|
||||||
|
appLogger.info(
|
||||||
|
'Blob migration: nothing to migrate (already done or fresh install)',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.info(
|
||||||
|
'Blob migration: ${bulkKeys.length} key(s) to move out of prefs',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final key in bulkKeys) {
|
||||||
|
try {
|
||||||
|
// Type-check rather than calling getString directly: getString THROWS
|
||||||
|
// on a non-string value, which would be counted as a migration failure
|
||||||
|
// and cry wolf. A non-string under a bulk prefix is simply not bulk
|
||||||
|
// data.
|
||||||
|
final raw = prefs.get(key);
|
||||||
|
if (raw is! String || raw.isEmpty) {
|
||||||
|
report.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final source = raw;
|
||||||
|
|
||||||
|
if (await read(key) != null) {
|
||||||
|
// Already migrated on a previous run. Leave the prefs copy for the
|
||||||
|
// sweep below rather than assuming; the read-back proved the data is
|
||||||
|
// present in drift.
|
||||||
|
report.alreadyPresent++;
|
||||||
|
await prefs.remove(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await write(key, source);
|
||||||
|
|
||||||
|
// Verify BEFORE removing the source. Length is compared rather than
|
||||||
|
// full equality to keep a multi-MB comparison cheap while still
|
||||||
|
// catching truncation, which is the realistic corruption here.
|
||||||
|
final readBack = await read(key);
|
||||||
|
if (readBack == null || readBack.length != source.length) {
|
||||||
|
report.failed++;
|
||||||
|
appLogger.error(
|
||||||
|
'Blob migration FAILED for $key: wrote ${source.length} chars, '
|
||||||
|
'read back ${readBack?.length ?? "null"}. Source left intact.',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prefs.remove(key);
|
||||||
|
report.migrated++;
|
||||||
|
report.bytes += source.length;
|
||||||
|
} catch (e) {
|
||||||
|
report.failed++;
|
||||||
|
appLogger.error(
|
||||||
|
'Blob migration FAILED for $key: $e. Source left intact.',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final level = report.failed > 0 ? 'WITH FAILURES' : 'ok';
|
||||||
|
appLogger.info(
|
||||||
|
'Blob migration complete ($level): ${report.migrated} moved, '
|
||||||
|
'${report.alreadyPresent} already present, ${report.skipped} skipped, '
|
||||||
|
'${report.failed} failed, '
|
||||||
|
'${(report.bytes / 1024 / 1024).toStringAsFixed(2)} MB',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
if (report.failed > 0) {
|
||||||
|
appLogger.error(
|
||||||
|
'Blob migration left ${report.failed} key(s) in SharedPreferences. '
|
||||||
|
'No data was lost, but those keys still carry the old cost.',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable tally of a migration run; surfaced in the log and used by tests.
|
||||||
|
class MigrationReport {
|
||||||
|
int migrated = 0;
|
||||||
|
int alreadyPresent = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
int failed = 0;
|
||||||
|
int bytes = 0;
|
||||||
|
|
||||||
|
bool get hadFailures => failed > 0;
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift_flutter/drift_flutter.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../utils/app_logger.dart';
|
||||||
|
|
||||||
|
part 'offband_database.g.dart';
|
||||||
|
|
||||||
|
/// Bulk data store (#335). Replaces SharedPreferences for message history,
|
||||||
|
/// contacts and discovered contacts.
|
||||||
|
///
|
||||||
|
/// SharedPreferences is a settings store, and using it for bulk data is what
|
||||||
|
/// causes #306: on Windows every mutation re-encodes and rewrites the entire
|
||||||
|
/// file synchronously, and on web it is backed by `localStorage`, which is
|
||||||
|
/// capped at 5 MiB per origin and is also synchronous. This install already
|
||||||
|
/// holds ~7 MB, so the web build cannot function at all today.
|
||||||
|
///
|
||||||
|
/// drift is used because it is the only option verified to cover all six
|
||||||
|
/// targets: native SQLite on Android, iOS, macOS, Windows and Linux, and
|
||||||
|
/// SQLite compiled to WebAssembly on web, where it stores through OPFS or
|
||||||
|
/// IndexedDB and runs in a worker rather than on the UI thread.
|
||||||
|
///
|
||||||
|
/// Settings stay in SharedPreferences. Only bulk data moves here.
|
||||||
|
@DriftDatabase(tables: [StoredBlobs])
|
||||||
|
class OffbandDatabase extends _$OffbandDatabase {
|
||||||
|
OffbandDatabase([QueryExecutor? executor])
|
||||||
|
: super(executor ?? _defaultExecutor());
|
||||||
|
|
||||||
|
static const String _dbName = 'offband_store';
|
||||||
|
|
||||||
|
/// `drift_flutter` selects the platform backend: native SQLite on desktop
|
||||||
|
/// and mobile, WASM on web. `web:` names the assets that must be shipped for
|
||||||
|
/// the web build (`sqlite3.wasm`, `drift_worker.js`).
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
web: DriftWebOptions(
|
||||||
|
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
|
||||||
|
// Filename matches the asset published by the drift release, which is
|
||||||
|
// `drift_worker.js` — not the `drift_worker.dart.js` the older docs
|
||||||
|
// name. Both assets are taken from the SAME drift release so they are
|
||||||
|
// built against each other.
|
||||||
|
driftWorker: Uri.parse('drift_worker.js'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the application-support directory, and relocates a database left
|
||||||
|
/// in the old documents location by an earlier build.
|
||||||
|
///
|
||||||
|
/// 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()) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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.
|
||||||
|
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.',
|
||||||
|
tag: 'Storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return support;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
|
||||||
|
/// Step-1 gate for #335: proves the database opens, writes and reads back on
|
||||||
|
/// the current platform. Deliberately trivial - no user data is involved, so
|
||||||
|
/// this can be run on every target before any migration is written.
|
||||||
|
Future<bool> verifyReadWrite() async {
|
||||||
|
const probeKey = '__offband_probe__';
|
||||||
|
final probeValue = 'ok-${DateTime.now().microsecondsSinceEpoch}';
|
||||||
|
|
||||||
|
await into(storedBlobs).insertOnConflictUpdate(
|
||||||
|
StoredBlobsCompanion.insert(key: probeKey, value: probeValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
final row = await (select(
|
||||||
|
storedBlobs,
|
||||||
|
)..where((t) => t.key.equals(probeKey))).getSingleOrNull();
|
||||||
|
|
||||||
|
await (delete(storedBlobs)..where((t) => t.key.equals(probeKey))).go();
|
||||||
|
|
||||||
|
return row?.value == probeValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per stored blob, replacing one SharedPreferences key each.
|
||||||
|
///
|
||||||
|
/// Deliberately key/value for the first migration: it maps 1:1 onto the
|
||||||
|
/// existing store interfaces, so callers do not change and the migration is
|
||||||
|
/// verifiable row-for-row against the old keys. Relational tables for messages
|
||||||
|
/// and contacts are a later step, once this is proven and querying is wanted.
|
||||||
|
class StoredBlobs extends Table {
|
||||||
|
TextColumn get key => text()();
|
||||||
|
TextColumn get value => text()();
|
||||||
|
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {key};
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'platform_info.dart';
|
||||||
|
|
||||||
|
/// Sends the app to the background without tearing it down.
|
||||||
|
///
|
||||||
|
/// `SystemNavigator.pop()` is NOT this: on Android it calls `finish()` on the
|
||||||
|
/// activity, which destroys the Flutter engine and drops the radio connection,
|
||||||
|
/// so reopening the app finds itself disconnected. `moveTaskToBack` leaves the
|
||||||
|
/// activity alive and simply hands focus back to whatever was in front before.
|
||||||
|
class AppBackgrounder {
|
||||||
|
static const MethodChannel _channel = MethodChannel(
|
||||||
|
'meshcore_open/app_lifecycle',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns true when the app was actually backgrounded.
|
||||||
|
///
|
||||||
|
/// Android only. Desktop windows are closed by their own chrome and iOS
|
||||||
|
/// forbids programmatic backgrounding, so elsewhere this is a no-op and the
|
||||||
|
/// caller should leave the press unhandled rather than act on it.
|
||||||
|
static Future<bool> moveToBackground() async {
|
||||||
|
if (!PlatformInfo.isAndroid) return false;
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod<bool>('moveTaskToBack') ?? false;
|
||||||
|
} on PlatformException catch (_) {
|
||||||
|
// Surface nothing to the user: the fallback is simply that back does
|
||||||
|
// nothing at the root, which is the pre-existing behaviour.
|
||||||
|
return false;
|
||||||
|
} on MissingPluginException catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
import '../services/app_settings_service.dart';
|
||||||
|
|
||||||
|
/// Map layer toggles for the nav panel.
|
||||||
|
///
|
||||||
|
/// The Map view has no list to show, so its panel carries the layer controls
|
||||||
|
/// that otherwise live in a modal behind the floating button. Pinned on a wide
|
||||||
|
/// screen these stay visible and the map updates underneath as they are
|
||||||
|
/// flipped, instead of a dialog covering the thing being filtered.
|
||||||
|
///
|
||||||
|
/// The same settings back both surfaces, so a change here shows in the dialog
|
||||||
|
/// and vice versa.
|
||||||
|
class MapLayerPanel extends StatelessWidget {
|
||||||
|
const MapLayerPanel({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final service = context.watch<AppSettingsService>();
|
||||||
|
final settings = service.settings;
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
_sectionHeader(theme, l10n.map_nodeTypes),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_chatNodes,
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
value: settings.mapShowChatNodes,
|
||||||
|
onChanged: service.setMapShowChatNodes,
|
||||||
|
),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_repeaters,
|
||||||
|
icon: Icons.cell_tower,
|
||||||
|
value: settings.mapShowRepeaters,
|
||||||
|
onChanged: service.setMapShowRepeaters,
|
||||||
|
),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_otherNodes,
|
||||||
|
icon: Icons.help_outline,
|
||||||
|
value: settings.mapShowOtherNodes,
|
||||||
|
onChanged: service.setMapShowOtherNodes,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_alwaysShowNames,
|
||||||
|
icon: Icons.label_outline,
|
||||||
|
value: settings.mapAlwaysShowNames,
|
||||||
|
onChanged: service.setMapAlwaysShowNames,
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_sectionHeader(theme, l10n.map_filterNodes),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_showDiscoveryContacts,
|
||||||
|
icon: Icons.person_search,
|
||||||
|
value: settings.mapShowDiscoveryContacts,
|
||||||
|
onChanged: service.setMapShowDiscoveryContacts,
|
||||||
|
),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_showGuessedLocations,
|
||||||
|
icon: Icons.location_searching,
|
||||||
|
value: settings.mapShowGuessedLocations,
|
||||||
|
onChanged: service.setMapShowGuessedLocations,
|
||||||
|
),
|
||||||
|
_toggle(
|
||||||
|
label: l10n.map_showOverlaps,
|
||||||
|
icon: Icons.layers,
|
||||||
|
value: settings.mapShowOverlaps,
|
||||||
|
onChanged: service.setMapShowOverlaps,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionHeader(ThemeData theme, String label) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _toggle({
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool> onChanged,
|
||||||
|
}) {
|
||||||
|
return SwitchListTile(
|
||||||
|
dense: true,
|
||||||
|
secondary: Icon(icon, size: 20),
|
||||||
|
title: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,118 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||||
|
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Migration safety for #335.
|
||||||
|
///
|
||||||
|
/// The bar here is set by #333: a storage path chose a key silently and 566
|
||||||
|
/// real messages read as empty. A migration that gets this wrong loses data
|
||||||
|
/// permanently rather than cosmetically, so the failure paths are tested, not
|
||||||
|
/// just the happy one.
|
||||||
|
void main() {
|
||||||
|
late OffbandDatabase db;
|
||||||
|
late BlobStore store;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = OffbandDatabase(NativeDatabase.memory());
|
||||||
|
store = BlobStore(db);
|
||||||
|
PrefsManager.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
Future<void> seedPrefs(Map<String, Object> values) async {
|
||||||
|
SharedPreferences.setMockInitialValues(values);
|
||||||
|
await PrefsManager.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('moves bulk keys and removes them from prefs', () async {
|
||||||
|
await seedPrefs({
|
||||||
|
'channel_messages_devpsk_abc': '[{"m":1}]',
|
||||||
|
'messages_devcontact': '[{"m":2}]',
|
||||||
|
'contacts_dev': '[{"c":1}]',
|
||||||
|
'discovered_contacts': '[{"d":1}]',
|
||||||
|
});
|
||||||
|
|
||||||
|
final report = await store.migrateFromPrefs();
|
||||||
|
|
||||||
|
expect(report.migrated, 4);
|
||||||
|
expect(report.failed, 0);
|
||||||
|
expect(await store.read('channel_messages_devpsk_abc'), '[{"m":1}]');
|
||||||
|
expect(await store.read('discovered_contacts'), '[{"d":1}]');
|
||||||
|
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
expect(prefs.getString('channel_messages_devpsk_abc'), isNull);
|
||||||
|
expect(prefs.getString('discovered_contacts'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves settings alone', () async {
|
||||||
|
await seedPrefs({
|
||||||
|
'ui_channels_sort_option': 'manual',
|
||||||
|
'app_settings': '{"theme":"dark"}',
|
||||||
|
'contacts_dev': '[{"c":1}]',
|
||||||
|
});
|
||||||
|
|
||||||
|
final report = await store.migrateFromPrefs();
|
||||||
|
|
||||||
|
expect(report.migrated, 1, reason: 'only the bulk key should move');
|
||||||
|
final prefs = PrefsManager.instance;
|
||||||
|
expect(prefs.getString('ui_channels_sort_option'), 'manual');
|
||||||
|
expect(prefs.getString('app_settings'), '{"theme":"dark"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent: a second run moves nothing and loses nothing', () async {
|
||||||
|
await seedPrefs({'contacts_dev': '[{"c":1}]'});
|
||||||
|
|
||||||
|
final first = await store.migrateFromPrefs();
|
||||||
|
expect(first.migrated, 1);
|
||||||
|
|
||||||
|
final second = await store.migrateFromPrefs();
|
||||||
|
expect(second.migrated, 0);
|
||||||
|
expect(second.failed, 0);
|
||||||
|
expect(await store.read('contacts_dev'), '[{"c":1}]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a re-added prefs key does not clobber migrated data', () async {
|
||||||
|
// Simulates an older build writing the key again after migration. The
|
||||||
|
// migrated copy must win; the stale prefs copy is discarded, not promoted.
|
||||||
|
await seedPrefs({'contacts_dev': '[{"c":"new"}]'});
|
||||||
|
await store.write('contacts_dev', '[{"c":"migrated"}]');
|
||||||
|
|
||||||
|
final report = await store.migrateFromPrefs();
|
||||||
|
|
||||||
|
expect(report.alreadyPresent, 1);
|
||||||
|
expect(await store.read('contacts_dev'), '[{"c":"migrated"}]');
|
||||||
|
expect(PrefsManager.instance.getString('contacts_dev'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves a payload larger than the 5 MiB localStorage cap', () async {
|
||||||
|
// 4 chars per element, so >1.4M elements clears 5 MiB.
|
||||||
|
final big = '[${'"x",' * 1400000}"end"]';
|
||||||
|
expect(big.length, greaterThan(5 * 1024 * 1024));
|
||||||
|
await seedPrefs({'channel_messages_devpsk_big': big});
|
||||||
|
|
||||||
|
final report = await store.migrateFromPrefs();
|
||||||
|
|
||||||
|
expect(report.failed, 0);
|
||||||
|
expect(
|
||||||
|
(await store.read('channel_messages_devpsk_big'))!.length,
|
||||||
|
big.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-string values are skipped, not failed', () async {
|
||||||
|
// getString THROWS on a non-string, so this must be type-checked, not
|
||||||
|
// caught as a failure. Verified against the real store: 'contacts' only
|
||||||
|
// ever matches bulk blobs there, but the store must not mis-report if a
|
||||||
|
// non-string ever lands under a bulk prefix.
|
||||||
|
await seedPrefs({'contacts_probe': 42});
|
||||||
|
|
||||||
|
final report = await store.migrateFromPrefs();
|
||||||
|
|
||||||
|
expect(report.failed, 0);
|
||||||
|
expect(report.skipped, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/models/channel_message.dart';
|
||||||
|
import 'package:meshcore_open/storage/channel_message_store.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||||
|
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// #343: saving a windowed in-memory list must not truncate the persisted
|
||||||
|
/// history. The store keeps full history; deletion is explicit.
|
||||||
|
void main() {
|
||||||
|
late OffbandDatabase db;
|
||||||
|
late ChannelMessageStore store;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
PrefsManager.reset();
|
||||||
|
await PrefsManager.initialize();
|
||||||
|
db = OffbandDatabase(NativeDatabase.memory());
|
||||||
|
BlobStore.overrideForTest(BlobStore(db));
|
||||||
|
store = ChannelMessageStore();
|
||||||
|
store.setPublicKeyHex = 'a' * 20;
|
||||||
|
// No PSK resolver -> uses the slot-index key, which is fine for the test.
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
BlobStore.clearTestOverride();
|
||||||
|
});
|
||||||
|
|
||||||
|
ChannelMessage msg(int i) => ChannelMessage(
|
||||||
|
senderName: 's',
|
||||||
|
text: 'm$i',
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(1000 + i),
|
||||||
|
isOutgoing: false,
|
||||||
|
channelIndex: 0,
|
||||||
|
messageId: 'id$i',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('saving a 200-window does not truncate a 250-message history', () async {
|
||||||
|
// Seed 250 messages, as if a full history were persisted.
|
||||||
|
await store.saveChannelMessages(0, [for (var i = 0; i < 250; i++) msg(i)]);
|
||||||
|
expect((await store.loadChannelMessages(0)).length, 250);
|
||||||
|
|
||||||
|
// The app now saves only the most-recent 200 (its in-memory window).
|
||||||
|
final window = [for (var i = 50; i < 250; i++) msg(i)];
|
||||||
|
await store.saveChannelMessages(0, window);
|
||||||
|
|
||||||
|
// Full history must survive: the older 50 are still there.
|
||||||
|
final all = await store.loadChannelMessages(0);
|
||||||
|
expect(all.length, 250, reason: 'the older 50 must not be dropped');
|
||||||
|
expect(all.first.text, 'm0');
|
||||||
|
expect(all.last.text, 'm249');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new message appends without dropping old history', () async {
|
||||||
|
await store.saveChannelMessages(0, [for (var i = 0; i < 10; i++) msg(i)]);
|
||||||
|
await store.saveChannelMessages(0, [msg(10)]);
|
||||||
|
final all = await store.loadChannelMessages(0);
|
||||||
|
expect(all.length, 11);
|
||||||
|
expect(all.last.text, 'm10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an edit to a message is captured, not duplicated', () async {
|
||||||
|
await store.saveChannelMessages(0, [msg(1)]);
|
||||||
|
final edited = ChannelMessage(
|
||||||
|
senderName: 's',
|
||||||
|
text: 'm1',
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(1001),
|
||||||
|
isOutgoing: false,
|
||||||
|
channelIndex: 0,
|
||||||
|
messageId: 'id1',
|
||||||
|
reactions: {'thumbsup': 2},
|
||||||
|
);
|
||||||
|
await store.saveChannelMessages(0, [edited]);
|
||||||
|
final all = await store.loadChannelMessages(0);
|
||||||
|
expect(all, hasLength(1));
|
||||||
|
expect(all.single.reactions['thumbsup'], 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'concurrent saves to one channel do not lose messages (Gemini)',
|
||||||
|
() async {
|
||||||
|
await store.saveChannelMessages(0, [msg(0)]);
|
||||||
|
// Fire two saves without awaiting between them: they race on the same key.
|
||||||
|
// Serialisation must make the result the union, not last-writer-wins.
|
||||||
|
final a = store.saveChannelMessages(0, [msg(1)]);
|
||||||
|
final b = store.saveChannelMessages(0, [msg(2)]);
|
||||||
|
await Future.wait([a, b]);
|
||||||
|
final all = await store.loadChannelMessages(0);
|
||||||
|
expect(
|
||||||
|
all.map((m) => m.text),
|
||||||
|
containsAll(['m0', 'm1', 'm2']),
|
||||||
|
reason: 'neither concurrent save may clobber the other',
|
||||||
|
);
|
||||||
|
expect(all, hasLength(3));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('deleting a message does NOT resurrect it on the next save', () async {
|
||||||
|
await store.saveChannelMessages(0, [for (var i = 0; i < 5; i++) msg(i)]);
|
||||||
|
|
||||||
|
// Delete via the explicit path.
|
||||||
|
await store.removeChannelMessage(0, msg(2));
|
||||||
|
expect(
|
||||||
|
(await store.loadChannelMessages(0)).map((m) => m.text),
|
||||||
|
isNot(contains('m2')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A later save of the remaining in-memory list must not bring it back.
|
||||||
|
final remaining = [
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
if (i != 2) msg(i),
|
||||||
|
];
|
||||||
|
await store.saveChannelMessages(0, remaining);
|
||||||
|
|
||||||
|
final all = await store.loadChannelMessages(0);
|
||||||
|
expect(all.map((m) => m.text), isNot(contains('m2')));
|
||||||
|
expect(all, hasLength(4));
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
/// Guards the one way the web build can break silently (#335).
|
||||||
|
///
|
||||||
|
/// drift on the web needs two binaries shipped in `web/`, and they must match
|
||||||
|
/// the resolved `drift` package. If someone bumps drift without re-downloading
|
||||||
|
/// them, nothing fails at build time: the app compiles, deploys, and then
|
||||||
|
/// fails in the browser. SAFELANE 6 says that must not be silent, so this
|
||||||
|
/// turns it into a red test with instructions.
|
||||||
|
///
|
||||||
|
/// Update `web/drift_assets.version` whenever the assets are re-downloaded.
|
||||||
|
void main() {
|
||||||
|
test('shipped drift web assets match the pinned drift version', () {
|
||||||
|
final root = Directory.current.path;
|
||||||
|
|
||||||
|
final lock = File('$root/pubspec.lock');
|
||||||
|
expect(
|
||||||
|
lock.existsSync(),
|
||||||
|
isTrue,
|
||||||
|
reason:
|
||||||
|
'pubspec.lock must be committed so dependency resolution is '
|
||||||
|
'reproducible; the drift web assets are matched against it.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Normalise line endings first: pubspec.lock is CRLF on Windows and LF in
|
||||||
|
// CI, and a newline-sensitive pattern would pass on one and fail on the
|
||||||
|
// other. A guard that is itself platform-fragile is worse than none.
|
||||||
|
final lockText = lock.readAsStringSync().replaceAll('\r\n', '\n');
|
||||||
|
final resolved = RegExp(
|
||||||
|
r'^ drift:\n(?:.*\n)*? version: "([^"]+)"',
|
||||||
|
multiLine: true,
|
||||||
|
).firstMatch(lockText)?.group(1);
|
||||||
|
expect(resolved, isNotNull, reason: 'drift not found in pubspec.lock');
|
||||||
|
|
||||||
|
final stamp = File('$root/web/drift_assets.version');
|
||||||
|
expect(
|
||||||
|
stamp.existsSync(),
|
||||||
|
isTrue,
|
||||||
|
reason:
|
||||||
|
'web/drift_assets.version is missing. It records which drift release '
|
||||||
|
'web/sqlite3.wasm and web/drift_worker.js came from.',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
stamp.readAsStringSync().trim(),
|
||||||
|
resolved,
|
||||||
|
reason:
|
||||||
|
'\nDrift web assets are STALE.\n'
|
||||||
|
'pubspec.lock resolves drift $resolved, but the shipped assets are '
|
||||||
|
'from ${stamp.readAsStringSync().trim()}.\n'
|
||||||
|
'The web build would compile and then fail in the browser.\n\n'
|
||||||
|
'Fix:\n'
|
||||||
|
' gh release download drift-$resolved --repo simolus3/drift \\\n'
|
||||||
|
' --pattern drift_worker.js --pattern sqlite3.wasm --clobber\n'
|
||||||
|
' (run inside web/, then write $resolved into web/drift_assets.version)\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final name in ['sqlite3.wasm', 'drift_worker.js']) {
|
||||||
|
expect(
|
||||||
|
File('$root/web/$name').existsSync(),
|
||||||
|
isTrue,
|
||||||
|
reason: 'web/$name is missing; the web build would fail at runtime.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap integrity check: the wasm must actually be WebAssembly.
|
||||||
|
final magic = File('$root/web/sqlite3.wasm').openSync().readSync(4);
|
||||||
|
expect(
|
||||||
|
magic,
|
||||||
|
orderedEquals([0x00, 0x61, 0x73, 0x6d]),
|
||||||
|
reason:
|
||||||
|
'web/sqlite3.wasm does not start with the WebAssembly magic '
|
||||||
|
'bytes (\\0asm) — the download is corrupt or is not a wasm file.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||||
|
|
||||||
|
/// Step-1 gate for #335: the database must open, write and read back before
|
||||||
|
/// any user data is migrated into it.
|
||||||
|
///
|
||||||
|
/// This runs against an in-memory SQLite instance so it exercises the drift
|
||||||
|
/// layer and generated code on any host. It does NOT prove the per-platform
|
||||||
|
/// backends (native libs on desktop/mobile, WASM on web) — those are proven by
|
||||||
|
/// building each target and by `verifyReadWrite()` at runtime.
|
||||||
|
void main() {
|
||||||
|
late OffbandDatabase db;
|
||||||
|
|
||||||
|
setUp(() => db = OffbandDatabase(NativeDatabase.memory()));
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test('opens, writes and reads back (the #335 gate)', () async {
|
||||||
|
expect(await db.verifyReadWrite(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips a payload larger than the 5 MiB localStorage cap', () async {
|
||||||
|
// The web build currently stores through localStorage, capped at 5 MiB per
|
||||||
|
// origin, while this install already holds ~7 MB. Proving a >5 MiB value
|
||||||
|
// survives is the whole point of the migration.
|
||||||
|
final big = 'x' * (6 * 1024 * 1024);
|
||||||
|
await db
|
||||||
|
.into(db.storedBlobs)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
StoredBlobsCompanion.insert(key: 'big', value: big),
|
||||||
|
);
|
||||||
|
|
||||||
|
final row = await (db.select(
|
||||||
|
db.storedBlobs,
|
||||||
|
)..where((t) => t.key.equals('big'))).getSingle();
|
||||||
|
|
||||||
|
expect(row.value.length, big.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsert replaces rather than duplicating a key', () async {
|
||||||
|
for (final v in ['first', 'second']) {
|
||||||
|
await db
|
||||||
|
.into(db.storedBlobs)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
StoredBlobsCompanion.insert(key: 'k', value: v),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rows = await (db.select(
|
||||||
|
db.storedBlobs,
|
||||||
|
)..where((t) => t.key.equals('k'))).get();
|
||||||
|
|
||||||
|
expect(rows, hasLength(1));
|
||||||
|
expect(rows.single.value, 'second');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
@Tags(['rehearsal'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/blob_store.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||||
|
import 'package:meshcore_open/storage/prefs_manager.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Rehearses the #335 migration against a COPY of a real 7 MB store.
|
||||||
|
///
|
||||||
|
/// Required by the plan before the migration may touch live data. Reads a
|
||||||
|
/// snapshot from disk; it never opens the user's actual store. Skipped when
|
||||||
|
/// the snapshot is absent, so CI does not depend on one machine's data.
|
||||||
|
void main() {
|
||||||
|
final path = Platform.environment['OFFBAND_REAL_STORE'];
|
||||||
|
|
||||||
|
test(
|
||||||
|
'migrates a real 7 MB store with zero loss',
|
||||||
|
() async {
|
||||||
|
if (path == null || !File(path).existsSync()) {
|
||||||
|
markTestSkipped(
|
||||||
|
'set OFFBAND_REAL_STORE to a shared_preferences.json copy',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw =
|
||||||
|
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
|
||||||
|
// Strip the `flutter.` prefix the plugin adds on disk.
|
||||||
|
final seed = <String, Object>{
|
||||||
|
for (final e in raw.entries)
|
||||||
|
if (e.value is String || e.value is int || e.value is bool)
|
||||||
|
e.key.replaceFirst('flutter.', ''): e.value as Object,
|
||||||
|
};
|
||||||
|
|
||||||
|
final expected = <String, int>{
|
||||||
|
for (final e in seed.entries)
|
||||||
|
if (e.value is String && BlobStore.isBulkKey(e.key))
|
||||||
|
e.key: (e.value as String).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
SharedPreferences.setMockInitialValues(seed);
|
||||||
|
PrefsManager.reset();
|
||||||
|
await PrefsManager.initialize();
|
||||||
|
|
||||||
|
final db = OffbandDatabase(NativeDatabase.memory());
|
||||||
|
final store = BlobStore(db);
|
||||||
|
final report = await store.migrateFromPrefs();
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(
|
||||||
|
'REHEARSAL: ${report.migrated} migrated, ${report.failed} failed, '
|
||||||
|
'${(report.bytes / 1024 / 1024).toStringAsFixed(2)} MB, '
|
||||||
|
'${expected.length} bulk keys expected',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.failed, 0, reason: 'no key may fail on real data');
|
||||||
|
expect(report.migrated, expected.length);
|
||||||
|
|
||||||
|
// Every byte accounted for, per key.
|
||||||
|
for (final e in expected.entries) {
|
||||||
|
final got = await store.read(e.key);
|
||||||
|
expect(got, isNotNull, reason: '${e.key} missing after migration');
|
||||||
|
expect(got!.length, e.value, reason: '${e.key} changed length');
|
||||||
|
expect(
|
||||||
|
PrefsManager.instance.get(e.key),
|
||||||
|
isNull,
|
||||||
|
reason: '${e.key} should be gone from prefs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings must survive untouched.
|
||||||
|
final settings = seed.keys.where((k) => !BlobStore.isBulkKey(k));
|
||||||
|
for (final k in settings) {
|
||||||
|
expect(
|
||||||
|
PrefsManager.instance.get(k),
|
||||||
|
isNotNull,
|
||||||
|
reason: 'setting $k was wrongly removed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.close();
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Cloudflare Pages response headers (#335, web storage via drift/SQLite-WASM).
|
||||||
|
#
|
||||||
|
# Syntax: a URL pattern, then indented headers. Cloudflare Pages reads this
|
||||||
|
# file from the build output and can add, override or remove response headers.
|
||||||
|
#
|
||||||
|
# Two things drift needs on the web:
|
||||||
|
#
|
||||||
|
# 1. sqlite3.wasm MUST be served as Content-Type: application/wasm, or
|
||||||
|
# WebAssembly streaming compilation refuses it.
|
||||||
|
#
|
||||||
|
# 2. COOP + COEP (cross-origin isolation) unlock drift's best storage backend,
|
||||||
|
# OPFS (`opfsLocks`). Verified locally: with these headers drift logs
|
||||||
|
# "Using WasmStorageImplementation.opfsLocks". WITHOUT them it still works,
|
||||||
|
# falling back to IndexedDB, which is slower but still unbounded compared to
|
||||||
|
# localStorage's 5 MiB cap. So these are a performance tier, not a
|
||||||
|
# correctness requirement.
|
||||||
|
#
|
||||||
|
# Verify after the first deploy:
|
||||||
|
# curl -sI https://<host>/sqlite3.wasm | grep -i 'content-type'
|
||||||
|
# -> expect: content-type: application/wasm
|
||||||
|
# and check the browser console for the opfsLocks line above.
|
||||||
|
|
||||||
|
/sqlite3.wasm
|
||||||
|
Content-Type: application/wasm
|
||||||
|
|
||||||
|
/*
|
||||||
|
Cross-Origin-Opener-Policy: same-origin
|
||||||
|
Cross-Origin-Embedder-Policy: require-corp
|
||||||
@ -0,0 +1 @@
|
|||||||
|
2.34.2
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -0,0 +1,87 @@
|
|||||||
|
// Standalone web probe for #335.
|
||||||
|
//
|
||||||
|
// Loads ONLY the drift stack, so a pass or fail is unambiguous: it exercises
|
||||||
|
// sqlite3.wasm + drift_worker.js in a real browser without the rest of the app
|
||||||
|
// (BLE, permissions, radio) confusing the result.
|
||||||
|
//
|
||||||
|
// Renders the outcome into the DOM so the result is readable without a
|
||||||
|
// debugger attached.
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:meshcore_open/storage/drift/offband_database.dart';
|
||||||
|
|
||||||
|
void main() => runApp(const _ProbeApp());
|
||||||
|
|
||||||
|
class _ProbeApp extends StatelessWidget {
|
||||||
|
const _ProbeApp();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const MaterialApp(
|
||||||
|
home: Scaffold(body: Center(child: _Probe())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Probe extends StatefulWidget {
|
||||||
|
const _Probe();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_Probe> createState() => _ProbeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProbeState extends State<_Probe> {
|
||||||
|
String _status = 'running…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_run();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
final lines = <String>[];
|
||||||
|
OffbandDatabase? db;
|
||||||
|
try {
|
||||||
|
db = OffbandDatabase();
|
||||||
|
|
||||||
|
final ok = await db.verifyReadWrite();
|
||||||
|
final r1 = ok
|
||||||
|
? 'PROBE-OK open+write+read'
|
||||||
|
: 'PROBE-FAIL readback mismatch';
|
||||||
|
lines.add(r1);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(r1);
|
||||||
|
|
||||||
|
// The point of the migration: exceed the 5 MiB localStorage ceiling.
|
||||||
|
final big = 'x' * (6 * 1024 * 1024);
|
||||||
|
await db
|
||||||
|
.into(db.storedBlobs)
|
||||||
|
.insertOnConflictUpdate(
|
||||||
|
StoredBlobsCompanion.insert(key: 'big', value: big),
|
||||||
|
);
|
||||||
|
final row = await (db.select(
|
||||||
|
db.storedBlobs,
|
||||||
|
)..where((t) => t.key.equals('big'))).getSingle();
|
||||||
|
final r2 = row.value.length == big.length
|
||||||
|
? 'PROBE-OK 6MiB round-trip (localStorage cap is 5MiB)'
|
||||||
|
: 'PROBE-FAIL 6MiB truncated to ${row.value.length}';
|
||||||
|
lines.add(r2);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(r2);
|
||||||
|
|
||||||
|
await (db.delete(db.storedBlobs)..where((t) => t.key.equals('big'))).go();
|
||||||
|
} catch (e) {
|
||||||
|
lines.add('PROBE-FAIL $e');
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('PROBE-FAIL $e');
|
||||||
|
} finally {
|
||||||
|
await db?.close();
|
||||||
|
}
|
||||||
|
if (mounted) setState(() => _status = lines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Text(
|
||||||
|
_status,
|
||||||
|
key: const Key('probe-result'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in new issue