From b6b26044373ed70c1f0242834dbfdd934276b37d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 08:03:46 -0400 Subject: [PATCH 01/10] - Offline upload now shows "GPS required" instead of the misleading "Advert your device on the mesh" when GPS is disabled. The error is caught before hitting the API. Device connection is not required for offline upload, only GPS and network connectivity. --- lib/providers/app_state_provider.dart | 10 +++++++++- lib/screens/settings_screen.dart | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1e8e6a8..31b3f91 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -71,6 +71,8 @@ enum OfflineUploadResult { partialFailure, /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available + gpsRequired, } /// Main application state provider @@ -3626,7 +3628,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Authenticating...'); - // 3. Authenticate with offline_mode: true, skipSessionStore: true + // 3. Check GPS before auth — the server requires current coordinates for geo-auth + if (_currentPosition == null) { + debugError('[OFFLINE] Upload requires GPS - location services not available'); + return OfflineUploadResult.gpsRequired; + } + + // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 89e323a..eeea0bd 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2001,6 +2001,10 @@ class _SettingsScreenState extends State { message = 'Authentication failed - Advert your device on the mesh'; backgroundColor = Colors.red; break; + case OfflineUploadResult.gpsRequired: + message = 'GPS required - enable location services to upload'; + backgroundColor = Colors.red; + break; case OfflineUploadResult.partialFailure: message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; From aeb8db4b3e93af37d9204993b0289662bf0c34b3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 08:13:26 -0400 Subject: [PATCH 02/10] - Improved error handling for malformed mesh packets. Corrupted packets with oversized path lengths previously produced an unclear RangeError. The error message now describes the actual problem. Affected packets were already safely skipped; this only improves diagnostic output. --- lib/services/meshcore/packet_metadata.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index c65a6ba..49e1f0a 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -104,6 +104,9 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; + if (payloadOffset > raw.length) { + throw RangeError('Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + } final Uint8List encryptedPayload = raw.sublist(payloadOffset); debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' From 61eebd33d9cb3212cbf4d0d7fbc39c9934a56270 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 08:51:35 -0400 Subject: [PATCH 03/10] =?UTF-8?q?-=20Client-side=20zone=20transfers=20duri?= =?UTF-8?q?ng=20active=20wardriving.=20When=20you=20cross=20into=20a=20new?= =?UTF-8?q?=20zone=20(e.g.,=20BOS=20=E2=86=92=20PVD),=20the=20app=20now=20?= =?UTF-8?q?handles=20the=20transfer=20directly:=20releases=20the=20old=20s?= =?UTF-8?q?ession,=20acquires=20a=20new=20one=20for=20the=20target=20zone,?= =?UTF-8?q?=20updates=20regional=20channels=20and=20scopes,=20and=20resume?= =?UTF-8?q?s=20auto-ping=20automatically.=20A=20"Changing=20Zone..."=20ove?= =?UTF-8?q?rlay=20shows=20during=20the=20transfer=20with=20a=20cancel=20op?= =?UTF-8?q?tion.=20Previously,=20backend-side=20session=20transfers=20coul?= =?UTF-8?q?d=20intermittently=20fail=20with=20a=20bad=5Fsession=20error,?= =?UTF-8?q?=20forcing=20a=20disconnect=20mid-drive.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/providers/app_state_provider.dart | 328 +++++++++++++++++++++++++- lib/screens/connection_screen.dart | 63 +++++ lib/screens/home_screen.dart | 91 +++++++ 3 files changed, 474 insertions(+), 8 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 31b3f91..5a4b9ba 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -282,6 +282,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { AutoMode _autoModeBeforeGrace = AutoMode.active; static const Duration _zoneGraceTimeout = Duration(minutes: 5); + // Zone transfer state — tracks session zone for zone-to-zone detection + String? _sessionZoneCode; + bool _isZoneTransferInProgress = false; + String? _zoneTransferFrom; + String? _zoneTransferTo; + // Geofence zone check log throttle (while disconnected) DateTime? _lastZoneCheckLogTime; int _zoneCheckSuppressedCount = 0; @@ -503,6 +509,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '${(_zoneGraceSecondsRemaining ~/ 60).toString().padLeft(2, '0')}:' '${(_zoneGraceSecondsRemaining % 60).toString().padLeft(2, '0')}'; + // Zone transfer getters + bool get isZoneTransferInProgress => _isZoneTransferInProgress; + String? get zoneTransferFrom => _zoneTransferFrom; + String? get zoneTransferTo => _zoneTransferTo; + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); @@ -1774,6 +1785,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Connected with limited access'); } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; + // Start periodic zone refresh to keep slot counts current if (!_preferences.offlineMode) { _startZoneRefreshTimer(); @@ -2731,6 +2745,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _regionalChannels = []; _scope = null; + // Clear zone transfer state + _sessionZoneCode = null; + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -3382,6 +3402,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; + debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { @@ -4415,15 +4438,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _inZone = result['in_zone'] == true; if (_inZone!) { - _currentZone = result['zone'] as Map?; + final newZone = result['zone'] as Map?; + final newZoneCode = newZone?['code'] as String? ?? ''; + final newZoneName = newZone?['name'] ?? 'Unknown'; + + // Detect zone-to-zone transition during active session + if (isConnected && + !_preferences.offlineMode && + _sessionZoneCode != null && + newZoneCode.isNotEmpty && + newZoneCode != _sessionZoneCode && + !_isInZoneGracePeriod && + !_isZoneTransferInProgress) { + _currentZone = newZone; + _nearestZone = null; + await _handleZoneTransfer(newZoneCode, newZoneName); + return; + } + + _currentZone = newZone; _nearestZone = null; - final zoneName = _currentZone?['name'] ?? 'Unknown'; - final zoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[GEOFENCE] In zone: $zoneName ($zoneCode)'); + debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); - // Fetch repeaters for this zone - if (zoneCode.isNotEmpty) { - await _fetchRepeatersForZone(zoneCode); + if (newZoneCode.isNotEmpty) { + await _fetchRepeatersForZone(newZoneCode); } } else { _currentZone = null; @@ -4611,7 +4649,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']}'); + final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; + debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + + // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume + if (_sessionZoneCode != null && + reEnteredZoneCode.isNotEmpty && + reEnteredZoneCode != _sessionZoneCode) { + debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + return; + } + await _onZoneGraceReEntry(); } } @@ -4696,6 +4749,265 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _abandonZoneGracePeriod(); } + // ============================================ + // Zone-to-Zone Transfer + // ============================================ + + /// Handle zone-to-zone transfer during active wardriving session. + /// Releases old zone session and acquires new session for target zone. + /// Preserves BLE connection and radio configuration. + Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + if (_isZoneTransferInProgress) { + debugLog('[ZONE] Transfer already in progress, skipping'); + return; + } + + final oldZoneCode = _sessionZoneCode ?? 'unknown'; + _isZoneTransferInProgress = true; + _zoneTransferFrom = oldZoneCode; + _zoneTransferTo = newZoneCode; + debugLog('[ZONE] Starting zone transfer: $oldZoneCode → $newZoneCode'); + notifyListeners(); + + try { + // 1. Save auto-ping state for restoration + final wasAutoPing = _autoPingEnabled; + final previousMode = _autoMode; + + // 2. Pause auto-ping and wardriving activity + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + _cooldownTimer.stop(); + if (_autoPingEnabled) { + _autoPingEnabled = false; + _idleAutoStopReference = null; + debugLog('[ZONE] Auto-ping paused for zone transfer'); + } + + // 3. Disable heartbeat (old session is about to be released) + _apiService.disableHeartbeat(); + + // 4. Stop RX logger (no valid session context during transfer) + _rxLogger?.stopWardriving(trigger: 'zone_transfer'); + + // 5. Stop zone refresh timer (we're handling the zone change now) + _stopZoneRefreshTimer(); + + // 6. Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + + // 7. Clear API queue (items were created for old zone's session) + await _apiQueueService.clearOnDisconnect(); + + // 8. Release old session (best effort) + if (_devicePublicKey != null && _apiService.hasSession) { + debugLog('[ZONE] Releasing old session for zone $oldZoneCode'); + try { + await _apiService.requestAuth( + reason: 'disconnect', + publicKey: _devicePublicKey!, + ); + debugLog('[ZONE] Old session released'); + } catch (e) { + debugError('[ZONE] Failed to release old session: $e'); + } + } + + // 9. Acquire new session for target zone + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + + if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); + await disconnect(); + return; + } + + debugLog('[ZONE] Requesting auth for zone $newZoneCode'); + final result = await _apiService.requestAuth( + reason: 'connect', + publicKey: _devicePublicKey!, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + // 10. Check auth result + if (result == null) { + debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); + logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + await disconnect(); + return; + } + + if (result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + _startMaintenancePolling(); + notifyListeners(); + await disconnect(); + return; + } + + if (result['success'] != true) { + final reason = result['reason'] as String? ?? 'unknown'; + final message = result['message'] as String? ?? 'Auth failed'; + debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + await disconnect(); + return; + } + + // 11. Auth succeeded — update session zone code + _sessionZoneCode = newZoneCode; + debugLog('[ZONE] Auth succeeded for zone $newZoneCode'); + + if (result['type'] != null) { + _authType = result['type'] as String; + } + + _syncZoneCapacityFromAuth(result); + + // 12. Update regional channels from new auth response + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[ZONE] Regional channels updated: $_regionalChannels'); + + // 13. Update PacketValidator with new channel configuration + if (_unifiedRxHandler != null) { + final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, + ); + } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + } + + // 14. Update flood scope from new auth response + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[ZONE] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + } else { + if (_scope != null) { + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[ZONE] Failed to clear flood scope: $e'); + } + } + _scope = null; + debugLog('[ZONE] No regional scope — using unscoped flood'); + } + + // 15. Enforce regional admin policies from new zone + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[ZONE] Hybrid mode force-enabled by new zone admin'); + } + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); + } + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + } + + // 16. Reconfigure path hash mode if new zone requires different hop bytes + await _configurePathHashMode(); + if (_pingService != null) { + _pingService!.hopBytes = effectiveHopBytes; + _pingService!.traceHopBytes = _traceHopBytes; + } + + // 17. Fetch repeaters for the new zone + _repeatersLoaded = false; + _repeatersLoadedForIata = null; + await _fetchRepeatersForZone(newZoneCode); + + // 18. Re-enable heartbeat + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + + // 19. Restart zone refresh timer + _startZoneRefreshTimer(); + + // 20. Prepare API queue for fresh data in new zone + await _apiQueueService.clearBeforeConnect(); + + // 21. Restore auto-ping if it was active + if (wasAutoPing) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + return; + } + if (!_autoPingEnabled) { + toggleAutoPing(previousMode); + debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); + } + }); + } else { + _startIdleDisconnectTimer(); + } + + debugLog('[ZONE] Zone transfer complete: $oldZoneCode → $newZoneCode'); + } catch (e) { + debugError('[ZONE] Zone transfer error: $e'); + logError('Zone transfer failed: $e', severity: ErrorSeverity.error); + await disconnect(); + } finally { + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + notifyListeners(); + } + } + + /// Cancel zone transfer (user-triggered from UI cancel button). + Future cancelZoneTransfer() async { + debugLog('[ZONE] Zone transfer cancelled by user'); + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + await disconnect(); + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ae749bb..2ebb0da 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -109,6 +109,11 @@ class _ConnectionScreenState extends State with WidgetsBinding /// Routes to the correct sub-view (zone bar is already rendered above) Widget _buildStateContent(BuildContext context, AppStateProvider appState) { + // Show zone transfer in progress + if (appState.isZoneTransferInProgress) { + return _buildZoneTransferView(context, appState); + } + // Show zone grace period state if (appState.isInZoneGracePeriod) { return _buildZoneGraceView(context, appState); @@ -341,6 +346,64 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } + Widget _buildZoneTransferView(BuildContext context, AppStateProvider appState) { + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(isLandscape ? 16 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + SizedBox(height: isLandscape ? 12 : 24), + Text( + 'Changing Zone...', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + SizedBox(height: isLandscape ? 8 : 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + SizedBox(height: isLandscape ? 16 : 24), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + } + Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5b8906d..e835e9d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -407,6 +407,17 @@ class _HomeScreenState extends State { ), ), + // Zone transfer overlay (both orientations) + if (appState.isZoneTransferInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildZoneTransferOverlay(appState), + ), + ), + ), + // Portrait: bottom control panel if (!isLandscape) Positioned( @@ -833,6 +844,86 @@ class _HomeScreenState extends State { ); } + /// Zone transfer overlay shown centered over the map during zone-to-zone transfer + Widget _buildZoneTransferOverlay(AppStateProvider appState) { + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + 'Changing Zone...', + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade500, + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade400, + side: BorderSide(color: Colors.orange.shade400), + ), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } + Widget _buildControlPanel() { return Card( margin: const EdgeInsets.all(8), From ca940900eb8bb5651456b94c76cc0a0f300023e9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 09:28:30 -0400 Subject: [PATCH 04/10] - Seeed Xiao nRF52840 boards are now automatically recognized during connection. Previously these devices were not matched in the device database, requiring manual power level selection on every connect. --- assets/device-models.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/device-models.json b/assets/device-models.json index 0da3f2e..49cb9e6 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -243,6 +243,14 @@ "txPower": 22, "notes": "Seeed Tracker T1000" }, + { + "manufacturer": "Seeed Xiao-nrf52", + "shortName": "Seeed Xiao nRF52", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Seeed Xiao nRF52840 bare board, no PA amplifier" + }, { "manufacturer": "Xiao C3", "shortName": "Xiao C3", From b82e10f3fb10758664004bf725e4d4fcdb768ad8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 10:04:14 -0400 Subject: [PATCH 05/10] - Zone check retry loop no longer continues after enabling Offline Mode. Previously, switching to offline mode while retrying (e.g., no internet) left the retry timer running, causing repeated failed API calls in the background. Retry timers and maintenance polling are now cancelled immediately when offline mode is enabled. --- lib/providers/app_state_provider.dart | 28 ++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 5a4b9ba..a147641 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -3141,6 +3141,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Cancel zone check retries — offline mode doesn't need zone validation + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); // Start periodic auto-save to prevent data loss from app kill _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode @@ -3207,6 +3211,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 5b. Start periodic auto-save to prevent data loss from app kill _startOfflineAutoSaveTimer(); + // 5c. Cancel zone check retries and maintenance polling + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -4344,6 +4353,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } + if (_preferences.offlineMode) { + debugLog('[GEOFENCE] Skipping zone check: offline mode enabled'); + return; + } + if (_isCheckingZone) { debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; @@ -5020,11 +5034,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Fetching repeaters for zone: $iata'); try { final fetchedRepeaters = await _apiService.fetchRepeaters(iata); - _repeaters = fetchedRepeaters; - _repeatersLoaded = true; - _repeatersLoadedForIata = iata; - debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + if (fetchedRepeaters.isNotEmpty) { + _repeaters = fetchedRepeaters; + _repeatersLoaded = true; + _repeatersLoadedForIata = iata; + debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); + notifyListeners(); + } else { + debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); } From 33c38fb73f02eceeedd371f5aab5210199f312be Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 11:56:13 -0400 Subject: [PATCH 06/10] - Zone check no longer gets stuck at "Checking Zone..." if the repeater list fails to load. The zone check now completes immediately and repeaters load in the background --- lib/providers/app_state_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a147641..f56db05 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -4475,7 +4475,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); if (newZoneCode.isNotEmpty) { - await _fetchRepeatersForZone(newZoneCode); + _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; From 0df9498852e14b00f05e90d390497ef707aa9cf5 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 12:28:18 -0400 Subject: [PATCH 07/10] - Improved error logging when the API returns unexpected HTML responses (e.g., CDN/proxy error pages during network issues). Now logs a clear diagnostic message instead of a raw FormatException. No behavior change, zone check already handled this gracefully. --- lib/services/api_service.dart | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 69c1fd2..eb8a47d 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -189,15 +189,12 @@ class ApiService { } Map? data; - if (response.statusCode == 200) { + try { data = json.decode(response.body) as Map; - } else if (response.body.isNotEmpty) { - // Try to parse structured error responses (e.g., 403 gps_inaccurate) - try { - data = json.decode(response.body) as Map; - } catch (e) { - debugWarn('[API] Non-JSON response body (HTML error page, etc.): $e'); - } + } on FormatException { + // CDN/proxy can return HTML error pages with HTTP 200 + debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } _logApiCall( From 1d0c03612b78a146944c755225976106dd8f5276 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 14:27:50 -0400 Subject: [PATCH 08/10] - Multi-hop TX echoes are no longer counted in the top-heard display or API payload. Only direct single-hop echoes are tracked, since multi-hop readings reflect the forwarding repeater's link rather than the original repeater's signal. --- lib/services/meshcore/tx_tracker.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 50b1d2c..575536e 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -139,6 +139,15 @@ class TxTracker { reportedRssi = null; } + // Only direct (1-hop) echoes yield a meaningful SNR/RSSI for the repeater + // that heard our TX: the radio reports last-hop link quality, so for any + // multi-hop relay the metrics describe a different link entirely. + if (!carpeaterStripped && metadata.pathHashCount > 1) { + debugLog('[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' + 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); + return false; + } + // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { From d4dadaf77f850da3041a0e9b72c69c79229099a3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Apr 2026 14:54:37 -0400 Subject: [PATCH 09/10] - "Pac-Man" GPS marker renamed to "Chomper" with a new design: cyan color, wider mouth, no eye. Existing users are automatically migrated on next launch. --- lib/models/user_preferences.dart | 9 ++++++++- lib/screens/settings_screen.dart | 26 ++++++++++-------------- lib/widgets/map_widget.dart | 34 +++++++++++++------------------- 3 files changed, 32 insertions(+), 37 deletions(-) diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 5f25a07..5efca8d 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -188,7 +188,7 @@ class UserPreferences { autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, markerStyle: (json['markerStyle'] as String?) ?? 'dot', - gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, @@ -200,6 +200,13 @@ class UserPreferences { ); } + /// Migrate the legacy 'pacman' gps marker id to 'chomper' after the rename. + static String _migrateGpsMarkerStyle(String? value) { + if (value == null) return 'arrow'; + if (value == 'pacman') return 'chomper'; + return value; + } + /// Convert to JSON (for persistence) Map toJson() { return { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index eeea0bd..4df361e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1006,7 +1006,7 @@ class _SettingsScreenState extends State { case 'bike': return 'Bike'; case 'boat': return 'Boat'; case 'walk': return 'Walk'; - case 'pacman': return 'Pac-Man'; + case 'chomper': return 'Chomper'; case 'arrow': default: return 'Arrow'; } @@ -1066,7 +1066,7 @@ class _SettingsScreenState extends State { ('bike', 'Bike', const Icon(Icons.directions_bike)), ('boat', 'Boat', const Icon(Icons.directions_boat)), ('walk', 'Walk', const Icon(Icons.directions_walk)), - ('pacman', 'Pac-Man', const _PacmanIcon()), + ('chomper', 'Chomper', const _ChomperIcon()), ]; showModalBottomSheet( context: context, @@ -2634,24 +2634,24 @@ class _OfflineSessionTile extends StatelessWidget { } } -/// Pac-Man icon widget for the GPS marker selector -class _PacmanIcon extends StatelessWidget { - const _PacmanIcon(); +/// Chomper icon widget for the GPS marker selector +class _ChomperIcon extends StatelessWidget { + const _ChomperIcon(); @override Widget build(BuildContext context) { return CustomPaint( size: const Size(24, 24), - painter: _PacmanIconPainter( + painter: _ChomperIconPainter( color: IconTheme.of(context).color ?? Colors.grey, ), ); } } -class _PacmanIconPainter extends CustomPainter { +class _ChomperIconPainter extends CustomPainter { final Color color; - const _PacmanIconPainter({required this.color}); + const _ChomperIconPainter({required this.color}); @override void paint(Canvas canvas, Size size) { @@ -2659,7 +2659,7 @@ class _PacmanIconPainter extends CustomPainter { final cy = size.height / 2; const radius = 10.0; - const mouthAngle = 45.0 * (math.pi / 180); + const mouthAngle = 70.0 * (math.pi / 180); // Mouth faces right for the settings icon (natural reading direction) const startAngle = mouthAngle / 2; const sweepAngle = 2 * math.pi - mouthAngle; @@ -2678,15 +2678,9 @@ class _PacmanIconPainter extends CustomPainter { ) ..close(); canvas.drawPath(path, paint); - - // Eye - final eyePaint = Paint() - ..color = color == Colors.grey ? Colors.white : Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(cx + 2, cy - 3.5), 1.5, eyePaint); } @override - bool shouldRepaint(covariant _PacmanIconPainter oldDelegate) => + bool shouldRepaint(covariant _ChomperIconPainter oldDelegate) => color != oldDelegate.color; } diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e8c21f9..76d2eda 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -805,10 +805,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (appState.currentPosition != null) MarkerLayer( // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and pacman rotate with heading (handled by Transform.rotate in the painter) + // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) rotate: appState.preferences.gpsMarkerStyle != 'arrow' && appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'pacman', + appState.preferences.gpsMarkerStyle != 'chomper', markers: [ Marker( point: LatLng( @@ -2437,8 +2437,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final headingRadians = heading * (math.pi / 180); final style = context.read().preferences.gpsMarkerStyle; - // Arrow, walk, and pacman rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk' || style == 'pacman'; + // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk' || style == 'chomper'; final CustomPainter painter; switch (style) { @@ -2450,8 +2450,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { painter = const _BoatMarkerPainter(); case 'walk': painter = const _WalkMarkerPainter(); - case 'pacman': - painter = const _PacmanMarkerPainter(); + case 'chomper': + painter = const _ChomperMarkerPainter(); case 'arrow': default: painter = const _ArrowPainter(); @@ -3610,9 +3610,9 @@ class _WalkMarkerPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -/// Paints a Pac-Man for GPS position marker — mouth faces up (direction of travel) -class _PacmanMarkerPainter extends CustomPainter { - const _PacmanMarkerPainter(); +/// Paints a Chomper (cyan wedge) GPS position marker — mouth faces up (direction of travel) +class _ChomperMarkerPainter extends CustomPainter { + const _ChomperMarkerPainter(); @override void paint(Canvas canvas, Size size) { @@ -3620,10 +3620,10 @@ class _PacmanMarkerPainter extends CustomPainter { final cy = size.height / 2; const radius = 10.0; - // Mouth opening angle: 45° total (22.5° each side of the top) + // Mouth opening angle: 70° total (35° each side of the top) // In canvas coordinates, 0° = 3 o'clock, so "up" = -90° = -π/2. - // Arc sweeps clockwise. We start at (-90 - 22.5)° and sweep (360 - 45)°. - const mouthAngle = 45.0 * (math.pi / 180); + // Arc sweeps clockwise. We start at (-90 + 35)° and sweep (360 - 70)°. + const mouthAngle = 70.0 * (math.pi / 180); const startAngle = -math.pi / 2 + mouthAngle / 2; // right edge of mouth const sweepAngle = 2 * math.pi - mouthAngle; @@ -3643,9 +3643,9 @@ class _PacmanMarkerPainter extends CustomPainter { ..close(); canvas.drawPath(outlinePath, outlinePaint); - // Yellow Pac-Man body + // Cyan body final bodyPaint = Paint() - ..color = const Color(0xFFFFEB3B) // Material yellow + ..color = const Color(0xFF00BCD4) ..style = PaintingStyle.fill; final bodyPath = ui.Path() @@ -3658,12 +3658,6 @@ class _PacmanMarkerPainter extends CustomPainter { ) ..close(); canvas.drawPath(bodyPath, bodyPaint); - - // Eye — small black circle, offset toward the mouth side (upper-left of center) - final eyePaint = Paint() - ..color = Colors.black - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(cx - 2.5, cy - 4.5), 1.5, eyePaint); } @override From 08239d0351c4749856ad436978c14197803886b0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 13 Apr 2026 22:29:40 -0400 Subject: [PATCH 10/10] ci: add PR-triggered analyze/test workflow and CODEOWNERS Adds a new CI workflow that runs flutter analyze and flutter test on every PR targeting main or dev, and a CODEOWNERS file routing all review requests to @MrAlders0n. Combined with branch protection updates (require_code_owner_reviews), this enforces owner approval on every incoming PR before merge. The workflow uses least-privilege contents:read permissions and does not inject API_KEY, since analyze/test do not need it and keeping it out of PR runs prevents fork-PR exposure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/CODEOWNERS | 1 + .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/ci.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3d4e8f5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @MrAlders0n diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..951a208 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - run: flutter analyze + - run: flutter test