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
Strycher 20 hours ago committed by GitHub
commit 7482f4166a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -30,6 +30,7 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json - name: Write dart_defines.json
env: env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
@ -52,6 +53,7 @@ jobs:
flutter-version: "3.44.1" flutter-version: "3.44.1"
cache: true cache: true
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- run: flutter build ios --release --no-codesign --no-pub - run: flutter build ios --release --no-codesign --no-pub
linux: linux:
@ -65,6 +67,7 @@ jobs:
- name: Install Linux build deps - name: Install Linux build deps
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json - name: Write dart_defines.json
env: env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
@ -87,6 +90,7 @@ jobs:
flutter-version: "3.44.1" flutter-version: "3.44.1"
cache: true cache: true
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- run: flutter build macos --release --no-pub - run: flutter build macos --release --no-pub
web: web:
@ -98,6 +102,7 @@ jobs:
flutter-version: "3.44.1" flutter-version: "3.44.1"
cache: true cache: true
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
- name: Write dart_defines.json - name: Write dart_defines.json
env: env:
GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }} GIPHY_API_KEY: ${{ secrets.GIPHY_API_KEY }}
@ -120,6 +125,7 @@ jobs:
flutter-version: "3.44.1" flutter-version: "3.44.1"
cache: true cache: true
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
# Windows runner defaults to PowerShell; jq is not guaranteed there, # Windows runner defaults to PowerShell; jq is not guaranteed there,
# so build the JSON with ConvertTo-Json, which escapes correctly. # so build the JSON with ConvertTo-Json, which escapes correctly.
- name: Write dart_defines.json - name: Write dart_defines.json

@ -50,6 +50,7 @@ jobs:
cache: true cache: true
- run: flutter pub get - run: flutter pub get
- run: dart run build_runner build --delete-conflicting-outputs
# Same mechanism as build.yml (#331). build_pipe's build_command in # Same mechanism as build.yml (#331). build_pipe's build_command in
# pubspec.yaml passes --dart-define-from-file, so this file must exist # pubspec.yaml passes --dart-define-from-file, so this file must exist

@ -22,6 +22,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
- name: Generate code (drift / build_runner)
run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze code - name: Analyze code
run: flutter analyze --fatal-infos --fatal-warnings run: flutter analyze --fatal-infos --fatal-warnings

1
.gitignore vendored

@ -30,7 +30,6 @@ migrate_working_dir/
.flutter-plugins-dependencies .flutter-plugins-dependencies
.pub-cache/ .pub-cache/
.pub/ .pub/
pubspec.lock
/build/ /build/
/coverage/ /coverage/
# fvm project files # fvm project files

@ -2,13 +2,32 @@ package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) } private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
private val appLifecycleChannelName = "meshcore_open/app_lifecycle"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
usbFunctions.configureFlutterEngine(flutterEngine) usbFunctions.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
appLifecycleChannelName
).setMethodCallHandler { call, result ->
when (call.method) {
// Send the task to the background WITHOUT finishing the
// activity. SystemNavigator.pop() calls finish(), which tears
// down the Flutter engine and drops the radio connection, so
// reopening the app lands on a disconnected radio.
"moveTaskToBack" -> {
result.success(moveTaskToBack(true))
}
else -> result.notImplemented()
}
}
} }
override fun onDestroy() { override fun onDestroy() {

@ -699,15 +699,36 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return; if (messages == null) return;
final removed = messages.remove(message); final removed = messages.remove(message);
if (!removed) return; if (!removed) return;
await _messageStore.saveMessages(contactKeyHex, messages); // Explicit delete: saveMessages now merges and never removes (#343).
await _messageStore.removeMessage(contactKeyHex, message);
notifyListeners(); notifyListeners();
} }
/// Aggregate cost of the per-contact conversation load, which runs once per
/// contact during a pull. It is fired unawaited from the contact handler, so
/// it escapes the frame-handler timing and has to be measured here.
int _convLoadCount = 0;
int _convLoadStoreMs = 0;
int _convLoadMergeMs = 0;
Future<void> _loadMessagesForContact(String contactKeyHex) async { Future<void> _loadMessagesForContact(String contactKeyHex) async {
if (_loadedConversationKeys.contains(contactKeyHex)) return; if (_loadedConversationKeys.contains(contactKeyHex)) return;
_loadedConversationKeys.add(contactKeyHex); _loadedConversationKeys.add(contactKeyHex);
final storeWatch = Stopwatch()..start();
final allMessages = await _messageStore.loadMessages(contactKeyHex); final allMessages = await _messageStore.loadMessages(contactKeyHex);
storeWatch.stop();
final mergeWatch = Stopwatch()..start();
_convLoadCount++;
_convLoadStoreMs += storeWatch.elapsedMilliseconds;
if (_convLoadCount % 25 == 0) {
appLogger.info(
'conversation loads: $_convLoadCount contacts, '
'store=${_convLoadStoreMs}ms(WALL, spans await - includes event-loop '
'queueing, NOT pure work) merge=${_convLoadMergeMs}ms(sync work)',
tag: 'Perf',
);
}
if (allMessages.isNotEmpty) { if (allMessages.isNotEmpty) {
// Keep only the most recent N messages in memory to bound memory usage // Keep only the most recent N messages in memory to bound memory usage
final windowedMessages = allMessages.length > _messageWindowSize final windowedMessages = allMessages.length > _messageWindowSize
@ -748,6 +769,8 @@ class MeshCoreConnector extends ChangeNotifier {
_conversations[contactKeyHex] = windowedMergedMessages; _conversations[contactKeyHex] = windowedMergedMessages;
notifyListeners(); notifyListeners();
} }
mergeWatch.stop();
_convLoadMergeMs += mergeWatch.elapsedMilliseconds;
} }
String _messageMergeKey(Message message) { String _messageMergeKey(Message message) {
@ -811,7 +834,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return; if (messages == null) return;
final removed = messages.remove(message); final removed = messages.remove(message);
if (!removed) return; if (!removed) return;
await _channelMessageStore.saveChannelMessages(channelIndex, messages); // Explicit delete path: saveChannelMessages now MERGES and never removes
// (#343), so deletion must go through removeChannelMessage or the message
// would be resurrected on the next save.
await _channelMessageStore.removeChannelMessage(channelIndex, message);
notifyListeners(); notifyListeners();
} }
@ -1191,10 +1217,17 @@ class MeshCoreConnector extends ChangeNotifier {
_contacts _contacts
..clear() ..clear()
..addAll(cached); ..addAll(cached);
for (final contact in cached) { // Load every contact's settings without notifying per contact, then
_ensureContactSmazSettingLoaded(contact.publicKeyHex); // notify once. Per-contact notification rebuilt the whole tree 2x per
_ensureContactCyr2LatSettingLoaded(contact.publicKeyHex); // contact (600 rebuilds for 300 contacts), and each rebuild walks the
} // contact list, which is what stalled the UI for ~44s on connect.
await Future.wait([
for (final contact in cached) ...[
_ensureContactSmazSettingLoaded(contact.publicKeyHex, notify: false),
_ensureContactCyr2LatSettingLoaded(contact.publicKeyHex, notify: false),
],
]);
notifyListeners();
} }
Future<void> _loadDiscoveredContactCache() async { Future<void> _loadDiscoveredContactCache() async {
@ -4342,7 +4375,64 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
/// Any frame handler that occupies the UI isolate long enough to drop
/// frames is a bug: it stalls rendering, so progress bars freeze and input
/// stops responding while sync appears to do nothing and then finish at
/// once. Logged rather than assumed, so the offending code is named.
static const Duration _slowHandlerThreshold = Duration(milliseconds: 100);
/// Cumulative synchronous time per frame code. A single handler under the
/// slow threshold can still dominate if it runs hundreds of times, which a
/// per-call threshold cannot see.
final Map<int, int> _frameCodeMicros = {};
final Map<int, int> _frameCodeCount = {};
int _frameTotalMicros = 0;
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
final handlerWatch = Stopwatch()..start();
_handleFrameInner(data);
handlerWatch.stop();
if (data.isEmpty) return;
final code = data[0];
final micros = handlerWatch.elapsedMicroseconds;
_frameCodeMicros[code] = (_frameCodeMicros[code] ?? 0) + micros;
_frameCodeCount[code] = (_frameCodeCount[code] ?? 0) + 1;
_frameTotalMicros += micros;
if (handlerWatch.elapsed > _slowHandlerThreshold) {
appLogger.info(
'slow frame handler: code=$code blocked UI for '
'${handlerWatch.elapsedMilliseconds}ms',
tag: 'Perf',
);
}
// Periodic cumulative report: names the code that owns the most isolate
// time even when no single call is slow.
if (_frameTotalMicros > 2000000) {
final ranked = _frameCodeMicros.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final top = ranked
.take(4)
.map(
(e) =>
'code=${e.key} ${(e.value / 1000).round()}ms'
'/${_frameCodeCount[e.key]}calls',
)
.join(' ');
appLogger.info(
'frame handler cumulative (${(_frameTotalMicros / 1000).round()}ms '
'total sync): $top',
tag: 'Perf',
);
_frameTotalMicros = 0;
_frameCodeMicros.clear();
_frameCodeCount.clear();
}
}
void _handleFrameInner(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxTime = DateTime.now(); _lastRxTime = DateTime.now();
@ -4642,16 +4732,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channelStore.setPublicKeyHex = selfPublicKeyHex; _channelStore.setPublicKeyHex = selfPublicKeyHex;
_unreadStore.setPublicKeyHex = selfPublicKeyHex; _unreadStore.setPublicKeyHex = selfPublicKeyHex;
// Now that we have self info, we can load all the persisted data for this node // Now that we have self info, we can load all the persisted data for this
_loadChannelOrder(); // node. Each step is timed: this sequence has stalled the UI isolate for
loadContactCache(); // ~40s on a large store, and the timings say which step is responsible.
loadChannelSettings(); unawaited(_timedStartupLoad());
loadCachedChannels();
// Load persisted channel messages
loadAllChannelMessages();
loadUnreadState();
_loadDiscoveredContactCache();
_awaitingSelfInfo = false; _awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel(); _selfInfoRetryTimer?.cancel();
@ -4662,6 +4746,26 @@ class MeshCoreConnector extends ChangeNotifier {
_maybeStartInitialChannelSync(); _maybeStartInitialChannelSync();
} }
Future<void> _timedStartupLoad() async {
Future<void> step(String name, Future<void> Function() body) async {
final sw = Stopwatch()..start();
await body();
sw.stop();
appLogger.info(
'startup-load $name took ${sw.elapsedMilliseconds}ms',
tag: 'Perf',
);
}
await step('channelOrder', () async => _loadChannelOrder());
await step('contactCache', loadContactCache);
await step('channelSettings', () => loadChannelSettings());
await step('cachedChannels', loadCachedChannels);
await step('channelMessages', () => loadAllChannelMessages());
await step('unreadState', loadUnreadState);
await step('discoveredContacts', _loadDiscoveredContactCache);
}
/// Extract the additive `offband_caps` byte from a device-info reply. /// Extract the additive `offband_caps` byte from a device-info reply.
/// ///
/// Offset verified against firmware MyMesh.cpp: the reply tail is three /// Offset verified against firmware MyMesh.cpp: the reply tail is three
@ -5001,6 +5105,28 @@ class MeshCoreConnector extends ChangeNotifier {
return physicsMax; return physicsMax;
} }
/// Coalesces notifications during a bulk contact pull.
///
/// Outside a pull this notifies immediately, preserving live-update
/// behaviour for adverts arriving one at a time.
DateTime? _lastContactPullNotify;
static const Duration _contactPullNotifyInterval = Duration(
milliseconds: 250,
);
void _notifyContactPullThrottled() {
if (!_isLoadingContacts) {
notifyListeners();
return;
}
final now = DateTime.now();
final last = _lastContactPullNotify;
if (last == null || now.difference(last) >= _contactPullNotifyInterval) {
_lastContactPullNotify = now;
notifyListeners();
}
}
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
final contactTmp = Contact.fromFrame(frame); final contactTmp = Contact.fromFrame(frame);
if (contactTmp != null) { if (contactTmp != null) {
@ -5088,7 +5214,14 @@ class MeshCoreConnector extends ChangeNotifier {
_pathHistoryService!.handlePathUpdated(contact); _pathHistoryService!.handlePathUpdated(contact);
} }
notifyListeners(); // During a bulk pull this fired once per contact, and each notification
// is a synchronous full-tree rebuild that itself walks the contact list.
// Measured at ~19ms per contact, which never tripped a per-call slow
// threshold but dominated total isolate time. The channel handler
// already guards its notify with _isLoadingChannels; this is the same
// guard, throttled rather than suppressed so the sync progress bar still
// advances while the pull runs.
_notifyContactPullThrottled();
// Show notification for new contact (advertisement) // Show notification for new contact (advertisement)
if (isNewContact && _appSettingsService != null) { if (isNewContact && _appSettingsService != null) {
@ -5621,22 +5754,32 @@ class MeshCoreConnector extends ChangeNotifier {
return frame.sublist(prefixOffset, prefixOffset + prefixLen); return frame.sublist(prefixOffset, prefixOffset + prefixLen);
} }
void _ensureContactSmazSettingLoaded(String contactKeyHex) { /// [notify] is false for bulk loads, which notify once at the end instead.
/// Notifying per contact rebuilds the whole tree once per contact, and each
/// rebuild itself walks the contact list, so a large address book turns this
/// into O(contacts^2) work on the UI isolate.
Future<void> _ensureContactSmazSettingLoaded(
String contactKeyHex, {
bool notify = true,
}) async {
if (_contactSmazEnabled.containsKey(contactKeyHex)) return; if (_contactSmazEnabled.containsKey(contactKeyHex)) return;
_contactSettingsStore.loadSmazEnabled(contactKeyHex).then((enabled) { final enabled = await _contactSettingsStore.loadSmazEnabled(contactKeyHex);
if (_contactSmazEnabled[contactKeyHex] == enabled) return; if (_contactSmazEnabled[contactKeyHex] == enabled) return;
_contactSmazEnabled[contactKeyHex] = enabled; _contactSmazEnabled[contactKeyHex] = enabled;
notifyListeners(); if (notify) notifyListeners();
});
} }
void _ensureContactCyr2LatSettingLoaded(String contactKeyHex) { Future<void> _ensureContactCyr2LatSettingLoaded(
String contactKeyHex, {
bool notify = true,
}) async {
if (_contactCyr2LatEnabled.containsKey(contactKeyHex)) return; if (_contactCyr2LatEnabled.containsKey(contactKeyHex)) return;
_contactSettingsStore.loadCyr2LatEnabled(contactKeyHex).then((enabled) { final enabled = await _contactSettingsStore.loadCyr2LatEnabled(
if (_contactCyr2LatEnabled[contactKeyHex] == enabled) return; contactKeyHex,
_contactCyr2LatEnabled[contactKeyHex] = enabled; );
notifyListeners(); if (_contactCyr2LatEnabled[contactKeyHex] == enabled) return;
}); _contactCyr2LatEnabled[contactKeyHex] = enabled;
if (notify) notifyListeners();
} }
void _ensureContactCyr2LatProfileLoaded(String contactKeyHex) { void _ensureContactCyr2LatProfileLoaded(String contactKeyHex) {

@ -976,6 +976,7 @@
"map_repeaters": "Repeaters", "map_repeaters": "Repeaters",
"map_otherNodes": "Other Nodes", "map_otherNodes": "Other Nodes",
"map_showOverlaps": "Repeater Key Overlaps", "map_showOverlaps": "Repeater Key Overlaps",
"map_alwaysShowNames": "Always show names",
"map_keyPrefix": "Key Prefix", "map_keyPrefix": "Key Prefix",
"map_filterByKeyPrefix": "Filter by key prefix", "map_filterByKeyPrefix": "Filter by key prefix",
"map_publicKeyPrefix": "Public key prefix", "map_publicKeyPrefix": "Public key prefix",

@ -3412,6 +3412,12 @@ abstract class AppLocalizations {
/// **'Repeater Key Overlaps'** /// **'Repeater Key Overlaps'**
String get map_showOverlaps; String get map_showOverlaps;
/// No description provided for @map_alwaysShowNames.
///
/// In en, this message translates to:
/// **'Always show names'**
String get map_alwaysShowNames;
/// No description provided for @map_keyPrefix. /// No description provided for @map_keyPrefix.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

@ -1890,6 +1890,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Покриване на ключа на повтаряча'; String get map_showOverlaps => 'Покриване на ключа на повтаряча';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Префикс на ключа'; String get map_keyPrefix => 'Префикс на ключа';

@ -1887,6 +1887,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Überlappungen der Repeater-Taste'; String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Schlüsselpräfix'; String get map_keyPrefix => 'Schlüsselpräfix';

@ -1854,6 +1854,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Repeater Key Overlaps'; String get map_showOverlaps => 'Repeater Key Overlaps';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Key Prefix'; String get map_keyPrefix => 'Key Prefix';

@ -1885,6 +1885,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Superposiciones de tecla repetidora'; String get map_showOverlaps => 'Superposiciones de tecla repetidora';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefijo de clave'; String get map_keyPrefix => 'Prefijo de clave';

@ -1895,6 +1895,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Chevauchement de la touche répétitive'; String get map_showOverlaps => 'Chevauchement de la touche répétitive';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Préfixe clé'; String get map_keyPrefix => 'Préfixe clé';

@ -1896,6 +1896,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek'; String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Kulcsfontosságú előtag'; String get map_keyPrefix => 'Kulcsfontosságú előtag';

@ -1888,6 +1888,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore'; String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefisso Chiave'; String get map_keyPrefix => 'Prefisso Chiave';

@ -1811,6 +1811,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get map_showOverlaps => 'リピーターキーの重複'; String get map_showOverlaps => 'リピーターキーの重複';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => '主要なプレフィックス'; String get map_keyPrefix => '主要なプレフィックス';

@ -1807,6 +1807,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get map_showOverlaps => '반복 키 중복'; String get map_showOverlaps => '반복 키 중복';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => '핵심 접두사'; String get map_keyPrefix => '핵심 접두사';

@ -1874,6 +1874,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Herhalingssleutel overlapt'; String get map_showOverlaps => 'Herhalingssleutel overlapt';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefix sleutel'; String get map_keyPrefix => 'Prefix sleutel';

@ -1900,6 +1900,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Nakładające się klucze przekaźników'; String get map_showOverlaps => 'Nakładające się klucze przekaźników';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefiks klucza'; String get map_keyPrefix => 'Prefiks klucza';

@ -1885,6 +1885,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Sobreposições da Chave Repeater'; String get map_showOverlaps => 'Sobreposições da Chave Repeater';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Prefixo Chave'; String get map_keyPrefix => 'Prefixo Chave';

@ -1889,6 +1889,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Перекрытия ключа повтора'; String get map_showOverlaps => 'Перекрытия ключа повтора';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Префикс ключа'; String get map_keyPrefix => 'Префикс ключа';

@ -1876,6 +1876,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Prekrývanie opakovača kľúča'; String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Päťciferné predpona'; String get map_keyPrefix => 'Päťciferné predpona';

@ -1871,6 +1871,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja'; String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Predpona ključa'; String get map_keyPrefix => 'Predpona ključa';

@ -1864,6 +1864,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Repeater-nyckelöverlappningar'; String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Nyckelprefix'; String get map_keyPrefix => 'Nyckelprefix';

@ -1884,6 +1884,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_showOverlaps => 'Перекриття ключів ретрансляторів'; String get map_showOverlaps => 'Перекриття ключів ретрансляторів';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => 'Префікс ключа'; String get map_keyPrefix => 'Префікс ключа';

@ -1778,6 +1778,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_showOverlaps => '重复键重叠'; String get map_showOverlaps => '重复键重叠';
@override
String get map_alwaysShowNames => 'Always show names';
@override @override
String get map_keyPrefix => '关键字前缀'; String get map_keyPrefix => '关键字前缀';

@ -27,6 +27,7 @@ import 'services/ui_view_state_service.dart';
import 'services/timeout_prediction_service.dart'; import 'services/timeout_prediction_service.dart';
import 'services/observer_config_service.dart'; import 'services/observer_config_service.dart';
import 'services/block_service.dart'; import 'services/block_service.dart';
import 'storage/drift/blob_store.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
@ -36,6 +37,14 @@ void main() async {
// Initialize SharedPreferences cache // Initialize SharedPreferences cache
await PrefsManager.initialize(); await PrefsManager.initialize();
// Move bulk data (message history, contacts) out of SharedPreferences into
// drift (#335). Must run after prefs are up and BEFORE any store reads, so
// no code sees a half-migrated state. Idempotent: a no-op once done.
//
// Deliberately awaited: the alternative is stores racing the migration, and
// this is the failure mode that produced #333.
await BlobStore.instance.migrateFromPrefs();
// Start always-on file logging (#97); no-op on web. // Start always-on file logging (#97); no-op on web.
await FileLogService.instance.init(); await FileLogService.instance.init();

@ -112,6 +112,9 @@ class AppSettings {
final bool mapShowChatNodes; final bool mapShowChatNodes;
final bool mapShowOtherNodes; final bool mapShowOtherNodes;
final bool mapShowOverlaps; final bool mapShowOverlaps;
/// Show node names at any zoom, instead of only past the zoom threshold.
final bool mapAlwaysShowNames;
final double mapTimeFilterHours; // 0 = all time final double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled; final bool mapKeyPrefixEnabled;
final String mapKeyPrefix; final String mapKeyPrefix;
@ -188,6 +191,7 @@ class AppSettings {
this.mapShowChatNodes = true, this.mapShowChatNodes = true,
this.mapShowOtherNodes = true, this.mapShowOtherNodes = true,
this.mapShowOverlaps = false, this.mapShowOverlaps = false,
this.mapAlwaysShowNames = false,
this.mapTimeFilterHours = 0, // Default to all time this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false, this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '', this.mapKeyPrefix = '',
@ -252,6 +256,7 @@ class AppSettings {
'map_show_chat_nodes': mapShowChatNodes, 'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes, 'map_show_other_nodes': mapShowOtherNodes,
'map_show_overlaps': mapShowOverlaps, 'map_show_overlaps': mapShowOverlaps,
'map_always_show_names': mapAlwaysShowNames,
'map_time_filter_hours': mapTimeFilterHours, 'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled, 'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix, 'map_key_prefix': mapKeyPrefix,
@ -338,6 +343,7 @@ class AppSettings {
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true, mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true, mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false, mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
mapAlwaysShowNames: json['map_always_show_names'] as bool? ?? false,
mapTimeFilterHours: mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0, (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false, mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
@ -462,6 +468,7 @@ class AppSettings {
bool? mapShowChatNodes, bool? mapShowChatNodes,
bool? mapShowOtherNodes, bool? mapShowOtherNodes,
bool? mapShowOverlaps, bool? mapShowOverlaps,
bool? mapAlwaysShowNames,
double? mapTimeFilterHours, double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled, bool? mapKeyPrefixEnabled,
String? mapKeyPrefix, String? mapKeyPrefix,
@ -510,6 +517,7 @@ class AppSettings {
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes, mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes, mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps, mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
mapAlwaysShowNames: mapAlwaysShowNames ?? this.mapAlwaysShowNames,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours, mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,

@ -29,6 +29,8 @@ import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart'; import '../services/translation_service.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import 'settings_screen.dart';
import '../utils/dialog_utils.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../widgets/channel_drawer_list.dart'; import '../widgets/channel_drawer_list.dart';
import '../widgets/mention_autocomplete.dart'; import '../widgets/mention_autocomplete.dart';
@ -287,6 +289,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
Future<void> _disconnect(BuildContext context) async {
final connector = context.read<MeshCoreConnector>();
await showDisconnectDialog(context, connector);
}
/// Bottom-bar navigation out of an open channel. /// Bottom-bar navigation out of an open channel.
/// ///
/// Tapping Channels returns to the channel list. Tapping Contacts or Map /// Tapping Channels returns to the channel list. Tapping Contacts or Map
@ -351,6 +358,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
currentChannelIndex: _currentChannel.index, currentChannelIndex: _currentChannel.index,
onChannelSelected: _switchChannel, onChannelSelected: _switchChannel,
), ),
// Present here too: the footer is app-level, so it belongs on every
// screen carrying the panel, not just the primary views.
onDisconnect: () => _disconnect(context),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBarBuilder: (context, pinned) => AppBar( appBarBuilder: (context, pinned) => AppBar(
// Pinned: the panel is already docked, so no hamburger is needed. // Pinned: the panel is already docked, so no hamburger is needed.
// Unpinned: this is a pushed route, so Scaffold would put a back arrow // Unpinned: this is a pushed route, so Scaffold would put a back arrow

@ -97,251 +97,229 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final allowBack = !connector.isConnected; return AppShell(
selectedIndex: 1,
return PopScope( onDestinationSelected: (index) => _handleQuickSwitch(index, context),
canPop: allowBack, contactsUnreadCount: connector.getTotalContactsUnreadCount(),
child: AppShell( channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
selectedIndex: 1, drawerContent: ChannelDrawerList(
onDestinationSelected: (index) => _handleQuickSwitch(index, context), onChannelSelected: (channel) => _openChannel(context, channel),
contactsUnreadCount: connector.getTotalContactsUnreadCount(), ),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(), onDisconnect: () => _disconnect(context),
drawerContent: ChannelDrawerList( onSettings: () => Navigator.push(
onChannelSelected: (channel) => _openChannel(context, channel), context,
), MaterialPageRoute(builder: (context) => const SettingsScreen()),
appBar: AppBar( ),
title: AppBarTitle(context.l10n.channels_title), appBar: AppBar(
centerTitle: true, title: AppBarTitle(context.l10n.channels_title),
bottom: const SyncProgressAppBarBottom(), centerTitle: true,
actions: [ bottom: const SyncProgressAppBarBottom(),
actions: [
// Disconnect and Settings moved to the panel footer (#290); only
// the screen-level Communities entry remains, and it already only
// appears once a community has been joined.
if (_communities.isNotEmpty)
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
const Icon(Icons.logout, color: Colors.red), const Icon(Icons.groups),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.common_disconnect), Text(context.l10n.community_manageCommunities),
], ],
), ),
onTap: () => _disconnect(context), onTap: () => _showManageCommunitiesDialog(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.groups),
const SizedBox(width: 8),
Text(context.l10n.community_manageCommunities),
],
),
onTap: () => _showManageCommunitiesDialog(context),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
), ),
], ],
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
), ),
], ],
), ),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await context.read<MeshCoreConnector>().getChannels(force: true); await context.read<MeshCoreConnector>().getChannels(force: true);
}, },
child: () { child: () {
final channels = connector.channels; final channels = connector.channels;
final waitingForFirstChannel = final waitingForFirstChannel =
connector.isLoadingChannels && channels.isEmpty; connector.isLoadingChannels && channels.isEmpty;
// Only block the list while the first channel is actively loading. // Only block the list while the first channel is actively loading.
// If the initial sync aborts, show cached/partial channels instead // If the initial sync aborts, show cached/partial channels instead
// of trapping the user behind an idle spinner. // of trapping the user behind an idle spinner.
if (waitingForFirstChannel) { if (waitingForFirstChannel) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (channels.isEmpty) { if (channels.isEmpty) {
return ListView( return ListView(
children: [ children: [
SizedBox( SizedBox(
height: MediaQuery.of(context).size.height - 200, height: MediaQuery.of(context).size.height - 200,
child: EmptyState( child: EmptyState(
icon: Icons.tag, icon: Icons.tag,
title: context.l10n.channels_noChannelsConfigured, title: context.l10n.channels_noChannelsConfigured,
action: FilledButton.icon( action: FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector), onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public), icon: const Icon(Icons.public),
label: Text(context.l10n.channels_addPublicChannel), label: Text(context.l10n.channels_addPublicChannel),
),
), ),
), ),
], ),
); ],
}
final filteredChannels = _filterAndSortChannels(
channels,
connector,
viewState,
); );
}
return Column( final filteredChannels = _filterAndSortChannels(
children: [ channels,
Padding( connector,
padding: const EdgeInsets.all(8.0), viewState,
child: TextField( );
controller: _searchController,
decoration: InputDecoration( return Column(
hintText: context.l10n.channels_searchChannels, children: [
prefixIcon: const Icon(Icons.search), Padding(
suffixIcon: Row( padding: const EdgeInsets.all(8.0),
mainAxisSize: MainAxisSize.min, child: TextField(
children: [ controller: _searchController,
if (viewState.channelsSearchText.isNotEmpty) decoration: InputDecoration(
IconButton( hintText: context.l10n.channels_searchChannels,
icon: const Icon(Icons.clear), prefixIcon: const Icon(Icons.search),
onPressed: () { suffixIcon: Row(
_searchDebounce?.cancel(); mainAxisSize: MainAxisSize.min,
_searchDebounce = null; children: [
_searchController.clear(); if (viewState.channelsSearchText.isNotEmpty)
context IconButton(
.read<UiViewStateService>() icon: const Icon(Icons.clear),
.setChannelsSearchText(''); onPressed: () {
}, _searchDebounce?.cancel();
), _searchDebounce = null;
_buildFilterButton(viewState), _searchController.clear();
], context
), .read<UiViewStateService>()
border: OutlineInputBorder( .setChannelsSearchText('');
borderRadius: BorderRadius.circular(12), },
), ),
contentPadding: const EdgeInsets.symmetric( _buildFilterButton(viewState),
horizontal: 16, ],
vertical: 12, ),
), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
), ),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return;
context
.read<UiViewStateService>()
.setChannelsSearchText(value);
},
);
},
), ),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return;
context
.read<UiViewStateService>()
.setChannelsSearchText(value);
},
);
},
), ),
Expanded( ),
child: filteredChannels.isEmpty Expanded(
? ListView( child: filteredChannels.isEmpty
children: [ ? ListView(
SizedBox( children: [
height: MediaQuery.of(context).size.height - 300, SizedBox(
child: Center( height: MediaQuery.of(context).size.height - 300,
child: Column( child: Center(
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ mainAxisAlignment: MainAxisAlignment.center,
Icon( children: [
Icons.search_off, Icon(
size: 64, Icons.search_off,
color: Colors.grey[400], size: 64,
), color: Colors.grey[400],
const SizedBox(height: 16), ),
Text( const SizedBox(height: 16),
context.l10n.channels_noChannelsFound, Text(
style: TextStyle( context.l10n.channels_noChannelsFound,
fontSize: 16, style: TextStyle(
color: Colors.grey[600], fontSize: 16,
), color: Colors.grey[600],
), ),
], ),
), ],
), ),
), ),
],
)
: (viewState.channelsSortOption ==
ChannelSortOption.manual &&
viewState.channelsSearchText.isEmpty)
? ReorderableListView.builder(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: 88,
), ),
buildDefaultDragHandles: false, ],
itemCount: filteredChannels.length, )
onReorderItem: (oldIndex, newIndex) { : (viewState.channelsSortOption ==
// onReorderItem already adjusts newIndex after the ChannelSortOption.manual &&
// removed item, unlike the deprecated onReorder. viewState.channelsSearchText.isEmpty)
final reordered = List<Channel>.from( ? ReorderableListView.builder(
filteredChannels, padding: const EdgeInsets.only(
); left: 16,
final item = reordered.removeAt(oldIndex); right: 16,
reordered.insert(newIndex, item); top: 8,
unawaited( bottom: 88,
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
);
},
itemBuilder: (context, index) {
final channel = filteredChannels[index];
return _buildChannelTile(
context,
connector,
channelMessageStore,
channel,
showDragHandle: true,
dragIndex: index,
);
},
)
: ListView.builder(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: 88,
),
itemCount: filteredChannels.length,
itemBuilder: (context, index) {
final channel = filteredChannels[index];
return _buildChannelTile(
context,
connector,
channelMessageStore,
channel,
);
},
), ),
), buildDefaultDragHandles: false,
], itemCount: filteredChannels.length,
); onReorderItem: (oldIndex, newIndex) {
}(), // onReorderItem already adjusts newIndex after the
), // removed item, unlike the deprecated onReorder.
floatingActionButton: FloatingActionButton( final reordered = List<Channel>.from(
onPressed: () => _showAddChannelDialog(context), filteredChannels,
tooltip: context.l10n.channels_addChannel, );
child: const Icon(Icons.add), final item = reordered.removeAt(oldIndex);
), reordered.insert(newIndex, item);
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
);
},
itemBuilder: (context, index) {
final channel = filteredChannels[index];
return _buildChannelTile(
context,
connector,
channelMessageStore,
channel,
showDragHandle: true,
dragIndex: index,
);
},
)
: ListView.builder(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: 88,
),
itemCount: filteredChannels.length,
itemBuilder: (context, index) {
final channel = filteredChannels[index];
return _buildChannelTile(
context,
connector,
channelMessageStore,
channel,
);
},
),
),
],
);
}(),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context),
tooltip: context.l10n.channels_addChannel,
child: const Icon(Icons.add),
), ),
); );
} }

@ -317,125 +317,103 @@ class _ContactsScreenState extends State<ContactsScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final allowBack = !connector.isConnected; return AppShell(
return PopScope( selectedIndex: 0,
canPop: allowBack, onDestinationSelected: (index) => _handleQuickSwitch(index, context),
child: AppShell( contactsUnreadCount: connector.getTotalContactsUnreadCount(),
selectedIndex: 0, channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
onDestinationSelected: (index) => _handleQuickSwitch(index, context), drawerContent: const ContactFilterRail(),
contactsUnreadCount: connector.getTotalContactsUnreadCount(), onDisconnect: () => _disconnect(context, connector),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(), onSettings: () => Navigator.push(
drawerContent: const ContactFilterRail(), context,
appBar: AppBar( MaterialPageRoute(builder: (context) => const SettingsScreen()),
title: AppBarTitle(context.l10n.contacts_title), ),
bottom: const SyncProgressAppBarBottom(), appBar: AppBar(
actions: [ title: AppBarTitle(context.l10n.contacts_title),
PopupMenuButton( bottom: const SyncProgressAppBarBottom(),
itemBuilder: (context) => [ actions: [
PopupMenuItem( PopupMenuButton(
child: Row( itemBuilder: (context) => [
children: [ PopupMenuItem(
const Icon(Icons.connect_without_contact), child: Row(
const SizedBox(width: 8), children: [
Text(context.l10n.contacts_zeroHopAdvert), const Icon(Icons.connect_without_contact),
], const SizedBox(width: 8),
), Text(context.l10n.contacts_zeroHopAdvert),
onTap: () => { ],
connector.sendSelfAdvert(flood: false),
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_advertisementSent),
),
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: true),
showDismissibleSnackBar(
context,
content: Text(context.l10n.settings_advertisementSent),
),
},
), ),
PopupMenuItem( onTap: () => {
child: Row( connector.sendSelfAdvert(flood: false),
children: [ showDismissibleSnackBar(
const Icon(Icons.copy), context,
const SizedBox(width: 8), content: Text(context.l10n.settings_advertisementSent),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
), ),
onTap: () => _contactExport(Uint8List.fromList([])), },
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
), ),
PopupMenuItem( onTap: () => {
child: Row( connector.sendSelfAdvert(flood: true),
children: [ showDismissibleSnackBar(
const Icon(Icons.paste), context,
const SizedBox(width: 8), content: Text(context.l10n.settings_advertisementSent),
Text(context.l10n.contacts_addContactFromClipboard),
],
), ),
onTap: () => _contactImport(), },
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.copy),
const SizedBox(width: 8),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
), ),
], onTap: () => _contactExport(Uint8List.fromList([])),
icon: const Icon(Icons.connect_without_contact), ),
), PopupMenuItem(
PopupMenuButton( child: Row(
itemBuilder: (context) => [ children: [
PopupMenuItem( const Icon(Icons.paste),
child: Row( const SizedBox(width: 8),
children: [ Text(context.l10n.contacts_addContactFromClipboard),
const Icon(Icons.logout, color: Colors.red), ],
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
), ),
PopupMenuItem( onTap: () => _contactImport(),
child: Row( ),
children: [ ],
const Icon(Icons.person_add_rounded), icon: const Icon(Icons.connect_without_contact),
const SizedBox(width: 8), ),
Text(context.l10n.discoveredContacts_Title), // Disconnect and Settings moved to the panel footer (#290).
], // Discovered contacts is screen-level and stays here.
), PopupMenuButton(
onTap: () => Navigator.push( itemBuilder: (context) => [
context, PopupMenuItem(
MaterialPageRoute( child: Row(
builder: (context) => const DiscoveryScreen(), children: [
), const Icon(Icons.person_add_rounded),
), const SizedBox(width: 8),
Text(context.l10n.discoveredContacts_Title),
],
), ),
PopupMenuItem( onTap: () => Navigator.push(
child: Row( context,
children: [ MaterialPageRoute(
const Icon(Icons.settings), builder: (context) => const DiscoveryScreen(),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
), ),
), ),
], ),
icon: const Icon(Icons.more_vert), ],
), icon: const Icon(Icons.more_vert),
], ),
), ],
body: _buildContactsBody(context, connector),
), ),
body: _buildContactsBody(context, connector),
); );
} }

@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../widgets/map_layer_panel.dart';
import '../widgets/sync_progress_overlay.dart'; import '../widgets/sync_progress_overlay.dart';
import '../icons/los_icon.dart'; import '../icons/los_icon.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
@ -398,7 +399,8 @@ class _MapScreenState extends State<MapScreen> {
// Re center map after removed markers have loaded // Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded) { if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true; _hasInitializedMap = true;
_showNodeLabels = initialZoom >= _labelZoomThreshold; _showNodeLabels =
settings.mapAlwaysShowNames || initialZoom >= _labelZoomThreshold;
if (hasMapContent) { if (hasMapContent) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
@ -408,281 +410,253 @@ class _MapScreenState extends State<MapScreen> {
} }
} }
final allowBack = !connector.isConnected; return AppShell(
selectedIndex: 2,
return PopScope( onDestinationSelected: (index) => _handleQuickSwitch(index, context),
canPop: allowBack, contactsUnreadCount: connector.getTotalContactsUnreadCount(),
child: AppShell( channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
selectedIndex: 2, drawerContent: const MapLayerPanel(),
onDestinationSelected: (index) => onDisconnect: () => _disconnect(context, connector),
_handleQuickSwitch(index, context), onSettings: () => Navigator.push(
contactsUnreadCount: connector.getTotalContactsUnreadCount(), context,
channelsUnreadCount: connector.getTotalChannelsUnreadCount(), MaterialPageRoute(builder: (context) => const SettingsScreen()),
appBar: AppBar( ),
title: AppBarTitle(context.l10n.map_title), appBar: AppBar(
centerTitle: true, title: AppBarTitle(context.l10n.map_title),
bottom: const SyncProgressAppBarBottom(), centerTitle: true,
actions: [ bottom: const SyncProgressAppBarBottom(),
if (!_isBuildingPathTrace) actions: [
IconButton( if (!_isBuildingPathTrace)
icon: const Icon(Icons.radar), IconButton(
onPressed: () => _startPath( icon: const Icon(Icons.radar),
LatLng(connector.selfLatitude!, connector.selfLongitude!), onPressed: () => _startPath(
), LatLng(connector.selfLatitude!, connector.selfLongitude!),
tooltip: context.l10n.contacts_pathTrace,
), ),
if (!_isBuildingPathTrace) tooltip: context.l10n.contacts_pathTrace,
IconButton( ),
icon: const LosIcon(), if (!_isBuildingPathTrace)
onPressed: () { IconButton(
final candidates = <LineOfSightEndpoint>[]; icon: const LosIcon(),
if (connector.selfLatitude != null && onPressed: () {
connector.selfLongitude != null) { final candidates = <LineOfSightEndpoint>[];
candidates.add( if (connector.selfLatitude != null &&
LineOfSightEndpoint( connector.selfLongitude != null) {
label: context.l10n.pathTrace_you, candidates.add(
point: LatLng( LineOfSightEndpoint(
connector.selfLatitude!, label: context.l10n.pathTrace_you,
connector.selfLongitude!, point: LatLng(
), connector.selfLatitude!,
color: Colors.teal, connector.selfLongitude!,
icon: Icons.person_pin_circle,
),
);
}
for (final c in contactsWithLocation) {
candidates.add(
LineOfSightEndpoint(
label: c.name,
point: LatLng(c.latitude!, c.longitude!),
color: _getNodeColor(c.type),
icon: _getNodeIcon(c.type),
),
);
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LineOfSightMapScreen(
title: context.l10n.map_losScreenTitle,
candidates: candidates,
), ),
color: Colors.teal,
icon: Icons.person_pin_circle,
), ),
); );
}, }
tooltip: context.l10n.map_lineOfSight, for (final c in contactsWithLocation) {
), candidates.add(
PopupMenuButton( LineOfSightEndpoint(
itemBuilder: (context) => [ label: c.name,
PopupMenuItem( point: LatLng(c.latitude!, c.longitude!),
child: Row( color: _getNodeColor(c.type),
children: [ icon: _getNodeIcon(c.type),
const Icon(Icons.logout, color: Colors.red), ),
const SizedBox(width: 8), );
Text(context.l10n.common_disconnect), }
], Navigator.push(
), context,
onTap: () => _disconnect(context, connector), MaterialPageRoute(
), builder: (context) => LineOfSightMapScreen(
PopupMenuItem( title: context.l10n.map_losScreenTitle,
child: Row( candidates: candidates,
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
), ),
), ),
), );
], },
icon: const Icon(Icons.more_vert), tooltip: context.l10n.map_lineOfSight,
), ),
], ],
), ),
body: Stack( body: Stack(
children: [ children: [
FlutterMap( FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: center, initialCenter: center,
initialZoom: initialZoom, initialZoom: initialZoom,
minZoom: _mapMinZoom, minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom, maxZoom: _mapMaxZoom,
interactionOptions: InteractionOptions( interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate, flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005, scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions: cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(), CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop keyboardOptions: isDesktop
? const KeyboardOptions( ? const KeyboardOptions(
enableArrowKeysPanning: true, enableArrowKeysPanning: true,
enableWASDPanning: true, enableWASDPanning: true,
enableRFZooming: true, enableRFZooming: true,
) )
: const KeyboardOptions.disabled(), : const KeyboardOptions.disabled(),
), ),
onTap: (_, latLng) { onTap: (_, latLng) {
if (_isSelectingPoi) { if (_isSelectingPoi) {
setState(() { setState(() {
_isSelectingPoi = false; _isSelectingPoi = false;
}); });
_shareMarker( _shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
},
onLongPress: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context, context: context,
connector: connector, connector: connector,
position: latLng, position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
); );
}, }
onPositionChanged: (camera, hasGesture) { },
final shouldShow = camera.zoom >= _labelZoomThreshold; onLongPress: (_, latLng) {
if (shouldShow != _showNodeLabels && mounted) { if (_isSelectingPoi) {
setState(() { setState(() {
_showNodeLabels = shouldShow; _isSelectingPoi = false;
}); });
} _shareMarker(
}, context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
position: latLng,
);
},
onPositionChanged: (camera, hasGesture) {
final shouldShow =
settings.mapAlwaysShowNames ||
camera.zoom >= _labelZoomThreshold;
if (shouldShow != _showNodeLabels && mounted) {
setState(() {
_showNodeLabels = shouldShow;
});
}
},
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
), ),
children: [ if (_polylines.isNotEmpty && _isBuildingPathTrace)
TileLayer( PolylineLayer(polylines: _polylines),
urlTemplate: kMapTileUrlTemplate, if (sharedMarkerPolylines.isNotEmpty)
tileProvider: tileCache.tileProvider, PolylineLayer(polylines: sharedMarkerPolylines),
userAgentPackageName: MarkerLayer(
MapTileCacheService.userAgentPackageName, markers: [
maxZoom: 19, if (highlightPosition != null)
), Marker(
if (_polylines.isNotEmpty && _isBuildingPathTrace) point: highlightPosition,
PolylineLayer(polylines: _polylines), width: 40,
if (sharedMarkerPolylines.isNotEmpty) height: 40,
PolylineLayer(polylines: sharedMarkerPolylines), child: IgnorePointer(
MarkerLayer( child: Icon(
markers: [ Icons.location_on_outlined,
if (highlightPosition != null) color: Colors.red[600],
Marker( size: 34,
point: highlightPosition,
width: 40,
height: 40,
child: IgnorePointer(
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
), ),
), ),
if (!settings.mapShowOverlaps)
..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildMarkers(
contactsWithLocation,
settings,
showLabels: _showNodeLabels,
), ),
...sharedMarkers.map(_buildSharedMarker), if (!settings.mapShowOverlaps)
if (connector.selfLatitude != null && ..._buildGuessedMarker(
connector.selfLongitude != null) guessedLocations,
Marker( showLabels:
point: LatLng( settings.mapAlwaysShowNames || _showNodeLabels,
connector.selfLatitude!, ),
connector.selfLongitude!, ..._buildMarkers(
), contactsWithLocation,
width: 40, settings,
height: 40, showLabels:
child: IgnorePointer( settings.mapAlwaysShowNames || _showNodeLabels,
ignoring: true, ),
child: Container( ...sharedMarkers.map(_buildSharedMarker),
padding: const EdgeInsets.all(4), if (connector.selfLatitude != null &&
decoration: BoxDecoration( connector.selfLongitude != null)
color: Colors.teal, Marker(
shape: BoxShape.circle, point: LatLng(
border: Border.all( connector.selfLatitude!,
color: Colors.white, connector.selfLongitude!,
width: 2, ),
), width: 40,
boxShadow: [ height: 40,
BoxShadow( child: IgnorePointer(
color: Colors.black.withValues( ignoring: true,
alpha: 0.3, child: Container(
), padding: const EdgeInsets.all(4),
blurRadius: 4, decoration: BoxDecoration(
offset: const Offset(0, 2), color: Colors.teal,
), shape: BoxShape.circle,
], border: Border.all(
),
alignment: Alignment.center,
child: const Icon(
Icons.person_pin_circle,
color: Colors.white, color: Colors.white,
size: 20, width: 2,
), ),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Icon(
Icons.person_pin_circle,
color: Colors.white,
size: 20,
), ),
), ),
), ),
if (_showNodeLabels && ),
connector.selfLatitude != null && if (_showNodeLabels &&
connector.selfLongitude != null) connector.selfLatitude != null &&
_buildNodeLabelMarker( connector.selfLongitude != null)
point: LatLng( _buildNodeLabelMarker(
connector.selfLatitude!, point: LatLng(
connector.selfLongitude!, connector.selfLatitude!,
), connector.selfLongitude!,
label: context.l10n.pathTrace_you,
), ),
], label: context.l10n.pathTrace_you,
), ),
], ],
),
if (!_isBuildingPathTrace)
_buildLegend(
contacts,
contactsWithLocation,
settings,
sharedMarkers.length,
guessedLocations.length,
),
if (isDesktop)
_buildDesktopMapControls(
context,
center: center,
zoom: initialZoom,
hasPathSelector: _isBuildingPathTrace,
), ),
if (_isBuildingPathTrace) _buildPathTraceOverlay(), ],
], ),
), if (!_isBuildingPathTrace)
floatingActionButton: FloatingActionButton( _buildLegend(
onPressed: () => _showFilterDialog(context, settingsService), contacts,
tooltip: context.l10n.map_filterNodes, contactsWithLocation,
child: const Icon(Icons.filter_list), settings,
), sharedMarkers.length,
guessedLocations.length,
),
if (isDesktop)
_buildDesktopMapControls(
context,
center: center,
zoom: initialZoom,
hasPathSelector: _isBuildingPathTrace,
),
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showFilterDialog(context, settingsService),
tooltip: context.l10n.map_filterNodes,
child: const Icon(Icons.filter_list),
), ),
); );
}, },

@ -25,6 +25,11 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> { class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false; bool _changedNavigation = false;
/// Re-entrancy guard for routing into the app. Distinct from
/// [_changedNavigation], which records that we entered at least once and
/// gates the disconnect-on-dispose cleanup.
bool _routingIntoApp = false;
late final MeshCoreConnector _connector; late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener; late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@ -40,14 +45,28 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (_connector.state == MeshCoreConnectionState.disconnected) { if (_connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false; _changedNavigation = false;
} else if (_connector.state == MeshCoreConnectionState.connected && } else if (_connector.state == MeshCoreConnectionState.connected &&
_connector.activeTransport == MeshCoreTransportType.bluetooth &&
isCurrentRoute && isCurrentRoute &&
!_changedNavigation) { !_routingIntoApp) {
// Route in whenever this screen is showing while connected, not just
// on the first connect. Otherwise landing back here with a live
// connection is a dead end: Connect does nothing, because there is
// nothing left to connect.
//
// Deliberately transport-agnostic. This was bluetooth-only, which left
// the same dead end for USB and TCP users, who cannot connect their way
// out of it either. The first-connect path for those transports is not
// affected: their own screen sits on top while connecting, so
// isCurrentRoute is false here and they still navigate themselves.
_changedNavigation = true; _changedNavigation = true;
_routingIntoApp = true;
if (mounted) { if (mounted) {
Navigator.of(context).push( Navigator.of(context)
MaterialPageRoute(builder: (context) => const ChannelsScreen()), .push(
); MaterialPageRoute(builder: (context) => const ChannelsScreen()),
)
.then((_) {
if (mounted) _routingIntoApp = false;
});
} }
} }
}; };

@ -76,6 +76,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowOverlaps: value)); await updateSettings(_settings.copyWith(mapShowOverlaps: value));
} }
Future<void> setMapAlwaysShowNames(bool value) async {
await updateSettings(_settings.copyWith(mapAlwaysShowNames: value));
}
Future<void> setMapTimeFilterHours(double value) async { Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value)); await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
} }

@ -5,7 +5,7 @@ import 'package:meshcore_open/utils/app_logger.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../models/translation_support.dart'; import '../models/translation_support.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import 'prefs_manager.dart'; import 'drift/blob_store.dart';
class ChannelMessageStore { class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_'; static const String _keyPrefix = 'channel_messages_';
@ -32,11 +32,25 @@ class ChannelMessageStore {
String _indexKey(int channelIndex) => '$keyFor$channelIndex'; String _indexKey(int channelIndex) => '$keyFor$channelIndex';
/// Active storage key: PSK identity when known, else the slot index. /// Active storage key: PSK identity when known, else the slot index.
///
/// The index fallback is legitimate before a channel list has loaded, but it
/// reads a DIFFERENT key: if history was already migrated to the PSK key, the
/// caller gets an empty list that is indistinguishable from data loss.
/// SAFELANE 6 - this must never be silent. Falling back where a resolver
/// exists means the channel list was not ready, which is the #333 race.
String _storageKey(int channelIndex) { String _storageKey(int channelIndex) {
final pskHex = channelPskResolver?.call(channelIndex); final pskHex = channelPskResolver?.call(channelIndex);
if (pskHex != null && pskHex.isNotEmpty) { if (pskHex != null && pskHex.isNotEmpty) {
return '$keyFor$_pskMarker$pskHex'; return '$keyFor$_pskMarker$pskHex';
} }
if (channelPskResolver != null) {
appLogger.warn(
'Channel $channelIndex has no PSK yet; falling back to the slot-index '
'key. Any history already migrated to the PSK key will read as EMPTY. '
'This means the channel list was not loaded first (#333).',
tag: 'Storage',
);
}
return _indexKey(channelIndex); return _indexKey(channelIndex);
} }
@ -51,9 +65,89 @@ class ChannelMessageStore {
); );
return; return;
} }
final prefs = PrefsManager.instance; // Merge into the persisted full history rather than overwriting it. The
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); // in-memory list is windowed to the most recent N for memory, so a plain
await prefs.setString(_storageKey(channelIndex), jsonEncode(jsonList)); // overwrite would truncate the store to N and erode old history (#343).
// Upsert by identity: keep older persisted messages, add new ones, and let
// the in-memory copy win so edits/reactions/status updates are captured.
// Deletion has its own path (removeChannelMessage) so this never
// resurrects a message the user deleted.
final key = _storageKey(channelIndex);
final blobs = BlobStore.instance;
// Serialise the whole read-modify-write against other saves/deletes on this
// key so two concurrent saves cannot clobber each other (Gemini review).
await blobs.synchronized(key, () async {
final byKey = <String, ChannelMessage>{};
final existing = await blobs.readWithPrefsFallback(key);
if (existing != null && existing.isNotEmpty) {
try {
for (final e in jsonDecode(existing) as List<dynamic>) {
final m = _messageFromJson(e as Map<String, dynamic>);
byKey[_mergeKey(m)] = m;
}
} catch (e) {
// SAFELANE 6: never merge into an empty base and truncate silently.
appLogger.error(
'Failed to decode existing channel $channelIndex history before '
'merge; aborting save to avoid truncation: $e',
tag: 'Storage',
);
return;
}
}
for (final m in messages) {
byKey[_mergeKey(m)] = m;
}
final merged = byKey.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
await blobs.write(key, jsonEncode(merged.map(_messageToJson).toList()));
});
}
/// Stable identity for merge/dedupe. messageId when present, else a composite
/// that distinguishes distinct messages that share no id.
String _mergeKey(ChannelMessage m) {
if (m.messageId.isNotEmpty) return 'id:${m.messageId}';
// Include the sender: two different senders can post identical text at the
// same timestamp without a messageId, and would otherwise collide and lose
// one (Gemini review, 2026-07-20).
final sender = m.senderKey == null
? ''
: m.senderKey!.map((b) => b.toRadixString(16)).join();
return 'x:$sender:${m.packetHash ?? ''}:'
'${m.timestamp.millisecondsSinceEpoch}:${m.text}';
}
/// Removes a single message from the persisted history. The explicit delete
/// path (#343): save merges and never removes, so deletion cannot go through
/// save.
Future<void> removeChannelMessage(
int channelIndex,
ChannelMessage message,
) async {
if (publicKeyHex.isEmpty) return;
final key = _storageKey(channelIndex);
final blobs = BlobStore.instance;
await blobs.synchronized(key, () async {
final existing = await blobs.readWithPrefsFallback(key);
if (existing == null || existing.isEmpty) return;
final List<dynamic> raw;
try {
raw = jsonDecode(existing) as List<dynamic>;
} catch (e) {
appLogger.error(
'Failed to decode channel $channelIndex history for delete: $e',
tag: 'Storage',
);
return;
}
final target = _mergeKey(message);
final kept = raw
.map((e) => _messageFromJson(e as Map<String, dynamic>))
.where((m) => _mergeKey(m) != target)
.toList();
await blobs.write(key, jsonEncode(kept.map(_messageToJson).toList()));
});
} }
/// Load messages for a specific channel /// Load messages for a specific channel
@ -64,9 +158,11 @@ class ChannelMessageStore {
); );
return []; return [];
} }
final prefs = PrefsManager.instance; final blobs = BlobStore.instance;
final key = _storageKey(channelIndex); final key = _storageKey(channelIndex);
String? jsonString = prefs.getString(key); // Bulk data lives in drift (#335); the fallback covers an unmigrated key
// and logs loudly if it fires.
String? jsonString = await blobs.readWithPrefsFallback(key);
// One-time migration into the PSK-identity key. Only runs when the PSK is // One-time migration into the PSK-identity key. Only runs when the PSK is
// known (key != index key). Adopts pre-#194 history keyed by slot index // known (key != index key). Adopts pre-#194 history keyed by slot index
@ -79,14 +175,37 @@ class ChannelMessageStore {
_indexKey(channelIndex), _indexKey(channelIndex),
'$_keyPrefix$channelIndex', // pre-device-scoping, unscoped index key '$_keyPrefix$channelIndex', // pre-device-scoping, unscoped index key
]) { ]) {
final legacy = prefs.getString(legacyKey); // Legacy keys may sit in either backend depending on when this
// install last ran, so check both.
final legacy = await blobs.readWithPrefsFallback(legacyKey);
if (legacy != null && legacy.isNotEmpty) { if (legacy != null && legacy.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating channel messages $legacyKey -> $key (PSK-keyed, #194)', 'Migrating channel messages $legacyKey -> $key (PSK-keyed, #194)',
); );
await prefs.setString(key, legacy); // Under the key lock, and MERGE rather than overwrite: a save may
await prefs.remove(legacyKey); // have landed on the PSK key between the read above and here, and a
jsonString = legacy; // blind write would clobber it (Gemini review). Union keeps both the
// adopted legacy history and any freshly-saved message.
jsonString = await blobs.synchronized(key, () async {
final byKey = <String, ChannelMessage>{};
for (final srcJson in [await blobs.read(key), legacy]) {
if (srcJson == null || srcJson.isEmpty) continue;
try {
for (final e in jsonDecode(srcJson) as List<dynamic>) {
final m = _messageFromJson(e as Map<String, dynamic>);
byKey[_mergeKey(m)] = m;
}
} catch (_) {
// Skip an undecodable source rather than aborting the adoption.
}
}
final merged = byKey.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final encoded = jsonEncode(merged.map(_messageToJson).toList());
await blobs.write(key, encoded);
return encoded;
});
await blobs.deleteEverywhere(legacyKey);
break; break;
} }
} }
@ -108,17 +227,16 @@ class ChannelMessageStore {
/// history gone, and setChannel's reuse-clear (#193) needs any stale /// history gone, and setChannel's reuse-clear (#193) needs any stale
/// slot-index history gone so it can't be migrated onto the new occupant. /// slot-index history gone so it can't be migrated onto the new occupant.
Future<void> clearChannelMessages(int channelIndex) async { Future<void> clearChannelMessages(int channelIndex) async {
final prefs = PrefsManager.instance; final blobs = BlobStore.instance;
await prefs.remove(_storageKey(channelIndex)); await blobs.deleteEverywhere(_storageKey(channelIndex));
await prefs.remove(_indexKey(channelIndex)); await blobs.deleteEverywhere(_indexKey(channelIndex));
} }
/// Clear all channel messages /// Clear all channel messages
Future<void> clearAllChannelMessages() async { Future<void> clearAllChannelMessages() async {
final prefs = PrefsManager.instance; final blobs = BlobStore.instance;
final keys = prefs.getKeys().where((k) => k.startsWith(keyFor)).toList(); for (final key in await blobs.keysWithPrefix(keyFor)) {
for (var key in keys) { await blobs.deleteEverywhere(key);
await prefs.remove(key);
} }
} }

@ -29,13 +29,15 @@ class ChannelOrderStore {
String? jsonString = prefs.getString(keyFor); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor', 'Migrating channel order from legacy key $_keyPrefix to scoped key $keyFor',
); );
await prefs.setString(keyFor, legacyJsonString); await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString; jsonString = legacyJsonString;
} }
} }

@ -25,13 +25,18 @@ class ChannelSettingsStore {
bool? enabled = prefs.getBool(key); bool? enabled = prefs.getBool(key);
if (enabled == null) { if (enabled == null) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only touch storage when a legacy key actually exists. This ran
// unconditionally, and on Windows shared_preferences re-serialises and
// rewrites the WHOLE prefs file on every mutation, so removing a key
// that was never there still cost a full multi-MB write. Repeated per
// channel/contact on connect, that blocked the UI isolate for ~38s.
enabled = prefs.getBool(oldKey); enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) { if (enabled != null) {
appLogger.info( appLogger.info(
'Migrating channel settings from legacy key $oldKey to scoped key $key', 'Migrating channel settings from legacy key $oldKey to scoped key $key',
); );
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
await prefs.remove(oldKey);
} }
} }
return enabled ?? false; return enabled ?? false;

@ -22,13 +22,15 @@ class ChannelStore {
String? jsonString = prefs.getString(keyFor); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
); );
await prefs.setString(keyFor, legacyJsonString); await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString; jsonString = legacyJsonString;
} }
} }
@ -45,7 +47,11 @@ class ChannelStore {
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode channels: $e', tag: 'Storage');
return []; return [];
} }
} }

@ -28,13 +28,15 @@ class CommunityStore {
String? jsonString = prefs.getString(keyFor); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor', 'Migrating communities from legacy key $_keyPrefix to scoped key $keyFor',
); );
await prefs.setString(keyFor, legacyJsonString); await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString; jsonString = legacyJsonString;
} }
} }
@ -105,7 +107,11 @@ class CommunityStore {
final communities = await loadCommunities(); final communities = await loadCommunities();
try { try {
return communities.firstWhere((c) => c.id == communityId); return communities.firstWhere((c) => c.id == communityId);
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode communities: $e', tag: 'Storage');
return null; return null;
} }
} }
@ -116,7 +122,11 @@ class CommunityStore {
final communities = await loadCommunities(); final communities = await loadCommunities();
try { try {
return communities.firstWhere((c) => c.communityId == cid); return communities.firstWhere((c) => c.communityId == cid);
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode communities: $e', tag: 'Storage');
return null; return null;
} }
} }

@ -2,14 +2,16 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import 'prefs_manager.dart'; import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
class ContactDiscoveryStore { class ContactDiscoveryStore {
static const String _keyPrefix = 'discovered_contacts'; static const String _keyPrefix = 'discovered_contacts';
Future<List<Contact>> loadContacts() async { Future<List<Contact>> loadContacts() async {
final prefs = PrefsManager.instance; // Bulk data lives in drift (#335), not SharedPreferences. The fallback
final jsonStr = prefs.getString(_keyPrefix); // covers a key the migration has not moved yet and logs loudly if it fires.
final jsonStr = await BlobStore.instance.readWithPrefsFallback(_keyPrefix);
if (jsonStr == null) return []; if (jsonStr == null) return [];
try { try {
@ -17,15 +19,19 @@ class ContactDiscoveryStore {
return jsonList return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>)) .map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList(); .toList();
} catch (_) { } catch (e) {
// SAFELANE 6: a decode failure is not "no data".
appLogger.error(
'Failed to decode discovered contacts: $e',
tag: 'Storage',
);
return []; return [];
} }
} }
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(_keyPrefix, jsonEncode(jsonList)); await BlobStore.instance.write(_keyPrefix, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Contact contact) { Map<String, dynamic> _toJson(Contact contact) {

@ -21,13 +21,15 @@ class ContactGroupStore {
String? jsonString = prefs.getString(keyFor); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
); );
await prefs.setString(keyFor, legacyJsonString); await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString; jsonString = legacyJsonString;
} }
} }

@ -25,13 +25,18 @@ class ContactSettingsStore {
bool? enabled = prefs.getBool(key); bool? enabled = prefs.getBool(key);
if (enabled == null) { if (enabled == null) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only touch storage when a legacy key actually exists. This ran
// unconditionally, and on Windows shared_preferences re-serialises and
// rewrites the WHOLE prefs file on every mutation, so removing a key
// that was never there still cost a full multi-MB write. Repeated per
// channel/contact on connect, that blocked the UI isolate for ~38s.
enabled = prefs.getBool(oldKey); enabled = prefs.getBool(oldKey);
prefs.remove(oldKey);
if (enabled != null) { if (enabled != null) {
appLogger.info( appLogger.info(
'Migrating contact settings from legacy key $oldKey to scoped key $key', 'Migrating contact settings from legacy key $oldKey to scoped key $key',
); );
await prefs.setBool(key, enabled); await prefs.setBool(key, enabled);
await prefs.remove(oldKey);
} }
} }
return prefs.getBool(key) ?? false; return prefs.getBool(key) ?? false;

@ -3,6 +3,7 @@ import 'dart:typed_data';
import '../models/contact.dart'; import '../models/contact.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class ContactStore { class ContactStore {
@ -19,23 +20,27 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot load contacts.'); appLogger.warn('Public key hex is not set. Cannot load contacts.');
return []; return [];
} }
final prefs = PrefsManager.instance; // Bulk data lives in drift (#335). The fallback covers a key the migration
String? jsonString = prefs.getString(keyFor); // has not moved yet, and logs loudly if it fires.
final blobs = BlobStore.instance;
String? jsonString = await blobs.readWithPrefsFallback(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Pre-device-scoping data still sits under the legacy unscoped key in
final legacyJsonString = prefs.getString(_keyPrefix); // SharedPreferences. Only touch prefs when it actually exists: an
prefs.remove(_keyPrefix); // unconditional remove costs a full-file rewrite on Windows and a
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { // 5 MiB-capped synchronous write on web (#306).
final prefs = PrefsManager.instance;
final legacy = prefs.get(_keyPrefix);
if (legacy is String && legacy.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating contacts from legacy key $_keyPrefix to scoped key $keyFor', 'Migrating contacts from legacy key $_keyPrefix to $keyFor (drift)',
); );
await prefs.setString(keyFor, legacyJsonString); await blobs.write(keyFor, legacy);
jsonString = legacyJsonString; await prefs.remove(_keyPrefix);
jsonString = legacy;
} }
} }
if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor);
}
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return []; return [];
} }
@ -55,9 +60,8 @@ class ContactStore {
appLogger.warn('Public key hex is not set. Cannot save contacts.'); appLogger.warn('Public key hex is not set. Cannot save contacts.');
return; return;
} }
final prefs = PrefsManager.instance;
final jsonList = contacts.map(_toJson).toList(); final jsonList = contacts.map(_toJson).toList();
await prefs.setString(keyFor, jsonEncode(jsonList)); await BlobStore.instance.write(keyFor, jsonEncode(jsonList));
} }
Map<String, dynamic> _toJson(Contact contact) { Map<String, dynamic> _toJson(Contact contact) {

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

@ -4,6 +4,7 @@ import '../models/message.dart';
import '../models/translation_support.dart'; import '../models/translation_support.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'drift/blob_store.dart';
import 'prefs_manager.dart'; import 'prefs_manager.dart';
class MessageStore { class MessageStore {
@ -23,10 +24,69 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot save messages.'); appLogger.warn('Public key hex is not set. Cannot save messages.');
return; return;
} }
final prefs = PrefsManager.instance; // Merge into the persisted full history rather than overwriting it (#343).
// The in-memory list is windowed for memory, so a plain overwrite would
// truncate the store. Upsert by identity; deletion is explicit
// (removeMessage).
final key = '$keyFor$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList(); final blobs = BlobStore.instance;
await prefs.setString(key, jsonEncode(jsonList)); await blobs.synchronized(key, () async {
final byKey = <String, Message>{};
final existing = await blobs.readWithPrefsFallback(key);
if (existing != null && existing.isNotEmpty) {
try {
for (final e in jsonDecode(existing) as List<dynamic>) {
final m = _messageFromJson(e as Map<String, dynamic>);
byKey[_mergeKey(m)] = m;
}
} catch (e) {
appLogger.error(
'Failed to decode existing DM history before merge; aborting save '
'to avoid truncation: $e',
tag: 'Storage',
);
return;
}
}
for (final m in messages) {
byKey[_mergeKey(m)] = m;
}
final merged = byKey.values.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
await blobs.write(key, jsonEncode(merged.map(_messageToJson).toList()));
});
}
String _mergeKey(Message m) {
if (m.messageId.isNotEmpty) return 'id:${m.messageId}';
return 'x:${m.senderKeyHex}:${m.timestamp.millisecondsSinceEpoch}:${m.text}';
}
/// Explicit delete path (#343): save merges and never removes.
Future<void> removeMessage(String contactKeyHex, Message message) async {
if (publicKeyHex.isEmpty) return;
final key = '$keyFor$contactKeyHex';
final existing = await BlobStore.instance.readWithPrefsFallback(key);
if (existing == null || existing.isEmpty) return;
final List<dynamic> raw;
try {
raw = jsonDecode(existing) as List<dynamic>;
} catch (e) {
appLogger.error(
'Failed to decode DM history for delete: $e',
tag: 'Storage',
);
return;
}
final target = _mergeKey(message);
final kept = raw
.map((e) => _messageFromJson(e as Map<String, dynamic>))
.where((m) => _mergeKey(m) != target)
.toList();
await BlobStore.instance.write(
key,
jsonEncode(kept.map(_messageToJson).toList()),
);
} }
Future<List<Message>> loadMessages(String contactKeyHex) async { Future<List<Message>> loadMessages(String contactKeyHex) async {
@ -34,24 +94,30 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot load messages.'); appLogger.warn('Public key hex is not set. Cannot load messages.');
return []; return [];
} }
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex'; final key = '$keyFor$contactKeyHex';
final oldKey = '$_keyPrefix$contactKeyHex'; final oldKey = '$_keyPrefix$contactKeyHex';
String? jsonString = prefs.getString(key); // Bulk data lives in drift (#335); fallback covers an unmigrated key and
// logs loudly if it fires.
final blobs = BlobStore.instance;
String? jsonString = await blobs.readWithPrefsFallback(key);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Only touch prefs when the legacy key actually exists. An unconditional
final legacyJsonString = prefs.getString(oldKey); // remove here ran once per contact and cost a full-file rewrite each
prefs.remove(oldKey); // time on Windows (#306).
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { final prefs = PrefsManager.instance;
final legacy = prefs.get(oldKey);
if (legacy is String && legacy.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating messages from legacy key $oldKey to scoped key $key', 'Migrating messages from legacy key $oldKey to $key (drift)',
); );
await prefs.setString(key, legacyJsonString); await blobs.write(key, legacy);
jsonString = legacyJsonString; await prefs.remove(oldKey);
jsonString = legacy;
} }
} }
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
jsonString = prefs.getString(keyFor); jsonString = await blobs.readWithPrefsFallback(keyFor);
} }
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
return []; return [];
@ -70,9 +136,11 @@ class MessageStore {
appLogger.warn('Public key hex is not set. Cannot clear messages.'); appLogger.warn('Public key hex is not set. Cannot clear messages.');
return; return;
} }
final prefs = PrefsManager.instance;
final key = '$keyFor$contactKeyHex'; final key = '$keyFor$contactKeyHex';
await prefs.remove(key); // Clear both backends: drift is authoritative, but a pre-migration prefs
// copy must not survive a clear and reappear via the read fallback.
await BlobStore.instance.delete(key);
await PrefsManager.instance.remove(key);
} }
Map<String, dynamic> _messageToJson(Message msg) { Map<String, dynamic> _messageToJson(Message msg) {

@ -35,13 +35,15 @@ class UnreadStore {
String? jsonString = prefs.getString(keyFor); String? jsonString = prefs.getString(keyFor);
if (jsonString == null || jsonString.isEmpty) { if (jsonString == null || jsonString.isEmpty) {
// Attempt migration from legacy unscoped key on first load // Attempt migration from legacy unscoped key on first load
// Only remove the legacy key when it actually exists: every prefs
// mutation rewrites the whole file on Windows.
final legacyJsonString = prefs.getString(_keyPrefix); final legacyJsonString = prefs.getString(_keyPrefix);
prefs.remove(_keyPrefix);
if (legacyJsonString != null && legacyJsonString.isNotEmpty) { if (legacyJsonString != null && legacyJsonString.isNotEmpty) {
appLogger.info( appLogger.info(
'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor', 'Migrating channel messages from legacy key $_keyPrefix to scoped key $keyFor',
); );
await prefs.setString(keyFor, legacyJsonString); await prefs.setString(keyFor, legacyJsonString);
await prefs.remove(_keyPrefix);
jsonString = legacyJsonString; jsonString = legacyJsonString;
} }
} }
@ -55,7 +57,11 @@ class UnreadStore {
try { try {
final json = jsonDecode(jsonString) as Map<String, dynamic>; final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as int)); return json.map((key, value) => MapEntry(key, value as int));
} catch (_) { } catch (e) {
// SAFELANE 6: never swallow. A decode failure here is
// indistinguishable from 'no data' to the caller, which reads
// to the user as data loss.
appLogger.error('Failed to decode unread state: $e', tag: 'Storage');
return {}; return {};
} }
} }

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

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/ui_view_state_service.dart'; import '../services/ui_view_state_service.dart';
import '../utils/app_backgrounder.dart';
import 'quick_switch_bar.dart'; import 'quick_switch_bar.dart';
/// Shared shell for the primary views (Contacts / Channels / Map). /// Shared shell for the primary views (Contacts / Channels / Map).
@ -11,7 +13,7 @@ import 'quick_switch_bar.dart';
/// dockable pane that can be pinned open on wide ones. /// dockable pane that can be pinned open on wide ones.
/// ///
/// The bottom bar stays a bottom bar at every width; it never becomes a rail. /// The bottom bar stays a bottom bar at every width; it never becomes a rail.
class AppShell extends StatelessWidget { class AppShell extends StatefulWidget {
static const double wideBreakpoint = 720; static const double wideBreakpoint = 720;
static const double _drawerWidth = 300; static const double _drawerWidth = 300;
@ -36,6 +38,12 @@ class AppShell extends StatelessWidget {
/// List content for the active view. Null renders an empty drawer body. /// List content for the active view. Null renders an empty drawer body.
final Widget? drawerContent; final Widget? drawerContent;
/// App-level actions, pinned to the bottom of the panel. These are not
/// contextual, which is why they were duplicated in three screens' overflow
/// menus before. Screen-level actions stay in their own overflow menu.
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const AppShell({ const AppShell({
super.key, super.key,
required this.body, required this.body,
@ -45,48 +53,98 @@ class AppShell extends StatelessWidget {
this.appBarBuilder, this.appBarBuilder,
this.floatingActionButton, this.floatingActionButton,
this.drawerContent, this.drawerContent,
this.onDisconnect,
this.onSettings,
this.contactsUnreadCount = 0, this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0, this.channelsUnreadCount = 0,
}); });
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
/// System back, in priority order:
/// 1. an open drawer closes,
/// 2. on a detail screen, pop back to the list it came from,
/// 3. on a primary view, send the app to the background so Android
/// returns to the home screen or the previous app.
///
/// Step 3 must NOT pop, even though the route below can be popped. The
/// primary views sit on top of the scanner, so popping would dump a
/// connected user back onto the radio-connect list. Reaching the scanner is
/// what Disconnect is for, not what Back is for.
///
/// A primary view is one carrying the bottom bar; a detail screen (a channel
/// chat) has no [selectedIndex] and is genuinely pushed.
Future<void> _handleBack() async {
final scaffold = _scaffoldKey.currentState;
if (scaffold?.isDrawerOpen ?? false) {
scaffold!.closeDrawer();
return;
}
final isPrimaryView = widget.selectedIndex != null;
final navigator = Navigator.of(context);
if (!isPrimaryView && navigator.canPop()) {
navigator.pop();
return;
}
// Background, do NOT finish. SystemNavigator.pop() would call finish() on
// the activity, tearing down the Flutter engine and dropping the radio
// connection, so reopening the app would show a disconnected radio.
await AppBackgrounder.moveToBackground();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return PopScope(
builder: (context, constraints) { canPop: false,
final isWide = constraints.maxWidth >= wideBreakpoint; onPopInvokedWithResult: (didPop, _) {
final pinned = if (didPop) return;
isWide && context.watch<UiViewStateService>().navDrawerPinned; _handleBack();
return pinned
? _buildPinned(context)
: _buildTransient(context, isWide);
}, },
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= AppShell.wideBreakpoint;
final pinned =
isWide && context.watch<UiViewStateService>().navDrawerPinned;
return pinned
? _buildPinned(context)
: _buildTransient(context, isWide);
},
),
); );
} }
/// Null on detail screens, which show the panel but no bottom bar. /// Null on detail screens, which show the panel but no bottom bar.
Widget? _bottomBar() { Widget? _bottomBar() {
final index = selectedIndex; final index = widget.selectedIndex;
final onSelected = onDestinationSelected; final onSelected = widget.onDestinationSelected;
if (index == null || onSelected == null) return null; if (index == null || onSelected == null) return null;
return SafeArea( return SafeArea(
top: false, top: false,
child: QuickSwitchBar( child: QuickSwitchBar(
selectedIndex: index, selectedIndex: index,
onDestinationSelected: onSelected, onDestinationSelected: onSelected,
contactsUnreadCount: contactsUnreadCount, contactsUnreadCount: widget.contactsUnreadCount,
channelsUnreadCount: channelsUnreadCount, channelsUnreadCount: widget.channelsUnreadCount,
), ),
); );
} }
PreferredSizeWidget? _appBar(BuildContext context, bool pinned) { PreferredSizeWidget? _appBar(BuildContext context, bool pinned) {
return appBarBuilder?.call(context, pinned) ?? appBar; return widget.appBarBuilder?.call(context, pinned) ?? widget.appBar;
} }
/// Wide + pinned: the panel is laid out beside the body, not overlaid. /// Wide + pinned: the panel is laid out beside the body, not overlaid.
Widget _buildPinned(BuildContext context) { Widget _buildPinned(BuildContext context) {
return Scaffold( return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, true), appBar: _appBar(context, true),
body: SafeArea( body: SafeArea(
top: false, top: false,
@ -94,15 +152,20 @@ class AppShell extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox( SizedBox(
width: _drawerWidth, width: AppShell._drawerWidth,
child: _NavPanel(isWide: true, content: drawerContent), child: _NavPanel(
isWide: true,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
), ),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: body), Expanded(child: widget.body),
], ],
), ),
), ),
floatingActionButton: floatingActionButton, floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(), bottomNavigationBar: _bottomBar(),
); );
} }
@ -111,13 +174,19 @@ class AppShell extends StatelessWidget {
/// the hamburger into the app bar automatically. /// the hamburger into the app bar automatically.
Widget _buildTransient(BuildContext context, bool isWide) { Widget _buildTransient(BuildContext context, bool isWide) {
return Scaffold( return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, false), appBar: _appBar(context, false),
drawer: Drawer( drawer: Drawer(
width: _drawerWidth, width: AppShell._drawerWidth,
child: _NavPanel(isWide: isWide, content: drawerContent), child: _NavPanel(
isWide: isWide,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
), ),
body: body, body: widget.body,
floatingActionButton: floatingActionButton, floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(), bottomNavigationBar: _bottomBar(),
); );
} }
@ -128,8 +197,15 @@ class AppShell extends StatelessWidget {
class _NavPanel extends StatelessWidget { class _NavPanel extends StatelessWidget {
final bool isWide; final bool isWide;
final Widget? content; final Widget? content;
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _NavPanel({required this.isWide, this.content}); const _NavPanel({
required this.isWide,
this.content,
this.onDisconnect,
this.onSettings,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -164,6 +240,51 @@ class _NavPanel extends StatelessWidget {
), ),
if (isWide) const Divider(height: 1), if (isWide) const Divider(height: 1),
Expanded(child: content ?? const SizedBox.shrink()), Expanded(child: content ?? const SizedBox.shrink()),
if (onDisconnect != null || onSettings != null) ...[
const Divider(height: 1),
_Footer(onDisconnect: onDisconnect, onSettings: onSettings),
],
],
),
);
}
}
/// App-level actions pinned to the bottom of the panel.
class _Footer extends StatelessWidget {
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _Footer({this.onDisconnect, this.onSettings});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
child: Row(
children: [
if (onDisconnect != null)
Expanded(
child: TextButton.icon(
// Kept visually distinct: this drops the radio connection,
// and it was a red menu entry before the move.
style: TextButton.styleFrom(foregroundColor: colors.error),
icon: const Icon(Icons.logout, size: 18),
label: Text(l10n.common_disconnect),
onPressed: onDisconnect,
),
),
if (onSettings != null)
Expanded(
child: TextButton.icon(
icon: const Icon(Icons.settings, size: 18),
label: Text(l10n.settings_title),
onPressed: onSettings,
),
),
], ],
), ),
); );

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

@ -62,7 +62,7 @@ dependencies:
url_launcher: ^6.3.0 # Launch URLs in system browser url_launcher: ^6.3.0 # Launch URLs in system browser
flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text flutter_linkify: ^6.0.0 # Auto-detect and linkify URLs in text
gpx: ^2.3.0 gpx: ^2.3.0
path_provider: ^2.1.5 path_provider: ^2.1.6
share_plus: ^12.0.1 share_plus: ^12.0.1
build_pipe: ^0.3.1 build_pipe: ^0.3.1
material_symbols_icons: ^4.2906.0 material_symbols_icons: ^4.2906.0
@ -73,6 +73,13 @@ dependencies:
ml_dataframe: ^1.0.0 ml_dataframe: ^1.0.0
llamadart: '>=0.6.8 <0.7.0' llamadart: '>=0.6.8 <0.7.0'
flutter_langdetect: ^0.0.1 flutter_langdetect: ^0.0.1
# PINNED (#335): web ships version-matched sqlite3.wasm + drift_worker.js
# from the drift release. A caret here would let pub resolve a newer drift
# than the committed assets, breaking web silently. Bump deliberately, then
# re-download both assets. tool/check_drift_assets.dart enforces the match.
drift: 2.34.2
drift_flutter: 0.3.1
path: ^1.9.1
hooks: hooks:
user_defines: user_defines:
@ -95,6 +102,8 @@ dev_dependencies:
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
drift_dev: 2.34.0
build_runner: ^2.15.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

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

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…
Cancel
Save

Powered by TurnKey Linux.