From 0cb1d49804326f7a0482e8f0799efba5c293ee98 Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 5 Mar 2026 11:41:15 +0100 Subject: [PATCH] Fix null check crash in TabBar --- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 2 +- ios/fastlane/report.xml | 8 +- lib/providers/app_provider.dart | 64 ++++-- lib/providers/connection_provider.dart | 148 ++++++++------ .../helpers/raw_session_retransmit.dart | 102 +++++++++ lib/providers/image_provider.dart | 109 ++++------ lib/providers/voice_provider.dart | 63 ++---- lib/screens/home_screen.dart | 193 +++++++++++------- lib/screens/settings_screen.dart | 13 ++ .../messages/image_message_bubble.dart | 27 ++- .../messages/tictactoe_message_bubble.dart | 35 +++- .../messages/voice_message_bubble.dart | 25 ++- .../helpers/raw_session_retransmit_test.dart | 145 +++++++++++++ 14 files changed, 660 insertions(+), 286 deletions(-) create mode 100644 lib/providers/helpers/raw_session_retransmit.dart create mode 100644 test/providers/helpers/raw_session_retransmit_test.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3e11938..9d7850c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 75; + CURRENT_PROJECT_VERSION = 77; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 4cfcf26..8acb499 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 75 + 77 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index b9dd224..db2228f 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index ed50a99..4d296fd 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -42,6 +42,8 @@ class AppProvider with ChangeNotifier { bool _isMapEnabled = true; bool get isMapEnabled => _isMapEnabled; + bool _isContactsEnabled = true; + bool get isContactsEnabled => _isContactsEnabled; bool _isVoiceSilenceTrimmingEnabled = true; bool get isVoiceSilenceTrimmingEnabled => _isVoiceSilenceTrimmingEnabled; @@ -81,6 +83,7 @@ class AppProvider with ChangeNotifier { _initializeLocationTracking(); _loadSimpleMode(); _loadMapEnabled(); + _loadContactsEnabled(); _loadVoiceSilenceTrimmingEnabled(); _loadVoiceBandPassFilterEnabled(); _loadVoiceCompressorEnabled(); @@ -214,6 +217,29 @@ class AppProvider with ChangeNotifier { } } + /// Load contacts enabled setting from shared preferences + Future _loadContactsEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isContactsEnabled = prefs.getBool('contacts_enabled') ?? true; + notifyListeners(); + } catch (e) { + debugPrint('Error loading contacts enabled setting: $e'); + } + } + + /// Toggle contacts tab on/off + Future toggleContactsEnabled(bool enabled) async { + try { + _isContactsEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('contacts_enabled', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving contacts enabled setting: $e'); + } + } + /// Load voice silence trimming setting from shared preferences. Future _loadVoiceSilenceTrimmingEnabled() async { try { @@ -407,24 +433,26 @@ class AppProvider with ChangeNotifier { payload: payload, ); }; - voiceProvider.waitForFragmentAckCallback = ({ - required sessionId, - required index, - timeout = const Duration(seconds: 8), - }) => _waitForVoiceFragmentAck( - sessionId: sessionId, - index: index, - timeout: timeout, - ); - imageProvider.waitForFragmentAckCallback = ({ - required sessionId, - required index, - timeout = const Duration(seconds: 8), - }) => _waitForImageFragmentAck( - sessionId: sessionId, - index: index, - timeout: timeout, - ); + voiceProvider.waitForFragmentAckCallback = + ({ + required sessionId, + required index, + timeout = const Duration(seconds: 8), + }) => _waitForVoiceFragmentAck( + sessionId: sessionId, + index: index, + timeout: timeout, + ); + imageProvider.waitForFragmentAckCallback = + ({ + required sessionId, + required index, + timeout = const Duration(seconds: 8), + }) => _waitForImageFragmentAck( + sessionId: sessionId, + index: index, + timeout: timeout, + ); // When a contact is received from BLE connectionProvider.onContactReceived = (contact) { diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 3f51784..0775ae7 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -65,8 +65,8 @@ class ConnectionProvider with ChangeNotifier { /// Active service โ€” BLE or TCP depending on current mode MeshCoreServiceBase get _activeService => (_connectionMode == ConnectionMode.tcp && _tcpService != null) - ? _tcpService! - : _bleService; + ? _tcpService! + : _bleService; /// Current connection mode ConnectionMode _connectionMode = ConnectionMode.ble; @@ -117,6 +117,8 @@ class ConnectionProvider with ChangeNotifier { bool _noMoreMessages = false; // Prevent overlapping/too-frequent sync requests bool _isSyncingMessages = false; + // If MSG_WAITING arrives while a sync loop is active, queue one more pass. + bool _syncRequestedWhileBusy = false; DateTime? _lastSyncNextRequestedAt; static const Duration _minSyncNextInterval = Duration(milliseconds: 150); @@ -310,7 +312,14 @@ class ConnectionProvider with ChangeNotifier { service.onMessageWaiting = () { debugPrint('๐Ÿ“ฅ [Provider] MSG_WAITING - auto-syncing'); - syncAllMessages(); + if (_isSyncingMessages) { + _syncRequestedWhileBusy = true; + debugPrint( + ' โ†ช๏ธ [Provider] Sync already running; queued follow-up sync', + ); + return; + } + unawaited(syncAllMessages()); }; service.onLoginSuccess = @@ -558,7 +567,9 @@ class ConnectionProvider with ChangeNotifier { final success = await _tcpService!.connect(host, port); if (!success) { - _deviceInfo = _deviceInfo.copyWith(connectionState: ConnectionState.error); + _deviceInfo = _deviceInfo.copyWith( + connectionState: ConnectionState.error, + ); notifyListeners(); } return success; @@ -1169,7 +1180,10 @@ class ConnectionProvider with ChangeNotifier { debugPrint(' Text: $text'); debugPrint(' MessageID: $messageId'); - await _activeService.sendChannelMessage(channelIdx: channelIdx, text: text); + await _activeService.sendChannelMessage( + channelIdx: channelIdx, + text: text, + ); debugPrint('โœ… [ConnectionProvider] BLE send completed'); debugPrint( @@ -1655,6 +1669,7 @@ class ConnectionProvider with ChangeNotifier { Future syncAllMessages() async { if (_isSyncingMessages) { // Already syncing; avoid overlapping loops + _syncRequestedWhileBusy = true; return 0; } @@ -1664,83 +1679,94 @@ class ConnectionProvider with ChangeNotifier { return 0; } - int count = 0; - _noMoreMessages = false; // Reset flag + int totalCount = 0; try { _isSyncingMessages = true; - debugPrint('๐Ÿ”„ [Provider] Starting message sync loop...'); - debugPrint(' Initial _noMoreMessages state: $_noMoreMessages'); + do { + _syncRequestedWhileBusy = false; + _noMoreMessages = false; // Reset flag per pass + int passCount = 0; + debugPrint('๐Ÿ”„ [Provider] Starting message sync loop...'); + debugPrint(' Initial _noMoreMessages state: $_noMoreMessages'); + + // Keep syncing until we get NoMoreMessages response + // The device will send ContactMsgRecv or ChannelMsgRecv responses + // until it sends NoMoreMessages + for (int i = 0; i < 100; i++) { + // Safety limit + // Check flag BEFORE sending (not after) + if (_noMoreMessages) { + debugPrint( + 'โœ… [Provider] Message sync complete - NoMoreMessages flag set after $passCount requests', + ); + break; + } - // Keep syncing until we get NoMoreMessages response - // The device will send ContactMsgRecv or ChannelMsgRecv responses - // until it sends NoMoreMessages - for (int i = 0; i < 100; i++) { - // Safety limit - // Check flag BEFORE sending (not after) - if (_noMoreMessages) { debugPrint( - 'โœ… [Provider] Message sync complete - NoMoreMessages flag set after $count requests', + '๐Ÿ“ค [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE', ); - break; - } - debugPrint( - '๐Ÿ“ค [Provider] Sync iteration ${i + 1}: Sending CMD_SYNC_NEXT_MESSAGE', - ); + // Create new completer for this request + _syncResponseCompleter = Completer(); - // Create new completer for this request - _syncResponseCompleter = Completer(); + // Respect the minimum interval between requests + final now = DateTime.now(); + if (_lastSyncNextRequestedAt != null) { + final elapsed = now.difference(_lastSyncNextRequestedAt!); + if (elapsed < _minSyncNextInterval) { + final remaining = _minSyncNextInterval - elapsed; + await Future.delayed(remaining); + } + } - // Respect the minimum interval between requests - final now = DateTime.now(); - if (_lastSyncNextRequestedAt != null) { - final elapsed = now.difference(_lastSyncNextRequestedAt!); - if (elapsed < _minSyncNextInterval) { - final remaining = _minSyncNextInterval - elapsed; - await Future.delayed(remaining); + await _activeService.syncNextMessage(); + _lastSyncNextRequestedAt = DateTime.now(); + passCount++; + totalCount++; + + // Wait for response (true = message received, false = no more messages) + // Timeout after 2 seconds to prevent hanging + final hasMore = await _syncResponseCompleter!.future.timeout( + const Duration(seconds: 2), + onTimeout: () { + debugPrint('โš ๏ธ [Provider] Sync timeout - no response after 2s'); + return false; + }, + ); + + debugPrint( + ' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages', + ); + + if (!hasMore) { + debugPrint(' โœ… No more messages available, stopping sync'); + break; } } - await _activeService.syncNextMessage(); - _lastSyncNextRequestedAt = DateTime.now(); - count++; - - // Wait for response (true = message received, false = no more messages) - // Timeout after 2 seconds to prevent hanging - final hasMore = await _syncResponseCompleter!.future.timeout( - const Duration(seconds: 2), - onTimeout: () { - debugPrint('โš ๏ธ [Provider] Sync timeout - no response after 2s'); - return false; - }, - ); - - debugPrint( - ' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages', - ); - - if (!hasMore) { - debugPrint(' โœ… No more messages available, stopping sync'); - break; + if (!_noMoreMessages && passCount >= 100) { + debugPrint( + 'โš ๏ธ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages', + ); } - } - if (!_noMoreMessages && count >= 100) { - debugPrint( - 'โš ๏ธ [Provider] Message sync stopped - reached safety limit of 100 requests without NoMoreMessages', - ); - } + if (_syncRequestedWhileBusy) { + debugPrint( + 'โ†ป [Provider] MSG_WAITING received during sync; running another pass', + ); + } + } while (_syncRequestedWhileBusy && _activeService.isConnected); debugPrint( - '๐Ÿ [Provider] Message sync finished: sent $count sync requests, _noMoreMessages=$_noMoreMessages', + '๐Ÿ [Provider] Message sync finished: sent $totalCount sync requests, _noMoreMessages=$_noMoreMessages', ); - return count; + return totalCount; } catch (e) { debugPrint('โŒ [Provider] Failed to sync messages: $e'); _error = 'Failed to sync messages: $e'; notifyListeners(); - return count; + return totalCount; } finally { _isSyncingMessages = false; _syncResponseCompleter = null; diff --git a/lib/providers/helpers/raw_session_retransmit.dart b/lib/providers/helpers/raw_session_retransmit.dart new file mode 100644 index 0000000..fb8f1c5 --- /dev/null +++ b/lib/providers/helpers/raw_session_retransmit.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; + +import '../../models/contact.dart'; + +typedef RawPacketSender = + Future Function({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + }); + +typedef FragmentAckWaiter = + Future Function({ + required String sessionId, + required int index, + Duration timeout, + }); + +Future serveCachedSessionFragments({ + required String providerLabel, + required String sessionId, + required Contact requester, + required List fragments, + required int maxDirectPayloadHops, + required int Function(T fragment) indexOf, + required Uint8List Function(T fragment) encodeBinary, + required RawPacketSender? sendRawPacket, + FragmentAckWaiter? waitForFragmentAck, + Set? requestedIndices, + Duration ackTimeout = const Duration(seconds: 8), +}) async { + if (fragments.isEmpty) { + debugPrint('โš ๏ธ [$providerLabel] No cached fragments for $sessionId'); + return false; + } + if (sendRawPacket == null) { + debugPrint('โš ๏ธ [$providerLabel] sendRawPacketCallback not set'); + return false; + } + if (requester.outPathLen < 0) { + debugPrint('โš ๏ธ [$providerLabel] ${requester.advName} has no direct path'); + return false; + } + if (requester.outPathLen > maxDirectPayloadHops) { + debugPrint( + 'โš ๏ธ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', + ); + return false; + } + if (requester.outPath.isEmpty) { + debugPrint( + 'โš ๏ธ [$providerLabel] ${requester.advName} has empty outPath payload', + ); + return false; + } + + var servedCount = 0; + for (final fragment in fragments) { + final index = indexOf(fragment); + if (index < 0) { + debugPrint('โš ๏ธ [$providerLabel] Invalid fragment index $index'); + continue; + } + if (requestedIndices != null && !requestedIndices.contains(index)) { + continue; + } + try { + final ackFuture = waitForFragmentAck?.call( + sessionId: sessionId, + index: index, + timeout: ackTimeout, + ); + await sendRawPacket( + contactPath: requester.outPath, + contactPathLen: requester.outPathLen, + payload: encodeBinary(fragment), + ); + servedCount++; + if (ackFuture != null) { + final acked = await ackFuture; + if (!acked) { + debugPrint('โš ๏ธ [$providerLabel] ACK timeout for $sessionId#$index'); + return false; + } + } + } catch (e, st) { + debugPrint( + 'โŒ [$providerLabel] Serve error for $sessionId#$index: $e\n$st', + ); + return false; + } + } + + if (servedCount == 0) { + debugPrint( + 'โš ๏ธ [$providerLabel] No fragments matched request for $sessionId', + ); + return false; + } + debugPrint('โœ… [$providerLabel] Served $servedCount fragments for $sessionId'); + return true; +} diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 89a08b6..dc93dfd 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import 'helpers/raw_session_retransmit.dart'; import '../utils/image_message_parser.dart'; /// Reassembly state for one incoming image session. @@ -137,19 +138,8 @@ class ImageProvider with ChangeNotifier { /// Register envelope metadata for a session (called when IE1 is received /// before any binary fragments arrive). void registerEnvelope(ImageEnvelope envelope) { - _sessions.putIfAbsent( - envelope.sessionId, - () => ImageSession( - sessionId: envelope.sessionId, - format: envelope.format, - total: envelope.total, - width: envelope.width, - height: envelope.height, - ), - ); - // Update dimensions if we created the session from a fragment (w/h = 0). - final session = _sessions[envelope.sessionId]!; - if (session.width == 0 || session.height == 0) { + final existing = _sessions[envelope.sessionId]; + if (existing == null) { _sessions[envelope.sessionId] = ImageSession( sessionId: envelope.sessionId, format: envelope.format, @@ -157,12 +147,38 @@ class ImageProvider with ChangeNotifier { width: envelope.width, height: envelope.height, ); - // Copy existing fragments into the new session. - final old = _sessions[envelope.sessionId]!; - for (var i = 0; i < session.fragments.length && i < old.total; i++) { - old.fragments[i] = session.fragments[i]; + unawaited(_persist()); + notifyListeners(); + return; + } + + final needsMerge = + existing.width == 0 || + existing.height == 0 || + existing.total != envelope.total || + existing.format != envelope.format; + if (!needsMerge) { + notifyListeners(); + return; + } + + final merged = ImageSession( + sessionId: envelope.sessionId, + format: envelope.format, + total: envelope.total, + width: envelope.width, + height: envelope.height, + ); + merged.firstFragmentAt = existing.firstFragmentAt; + merged.lastFragmentAt = existing.lastFragmentAt; + for (final fragment in existing.fragments) { + if (fragment == null) continue; + if (fragment.index < merged.total) { + merged.fragments[fragment.index] = fragment; } } + _sessions[envelope.sessionId] = merged; + unawaited(_persist()); notifyListeners(); } @@ -214,55 +230,18 @@ class ImageProvider with ChangeNotifier { debugPrint('โš ๏ธ [ImageProvider] No cached session for $sessionId'); return false; } - if (sendRawPacketCallback == null) { - debugPrint('โš ๏ธ [ImageProvider] sendRawPacketCallback not set'); - return false; - } - if (requester.outPathLen < 0) { - debugPrint('โš ๏ธ [ImageProvider] ${requester.advName} has no direct path'); - return false; - } - if (requester.outPathLen > maxDirectPayloadHops) { - debugPrint( - 'โš ๏ธ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', - ); - return false; - } - - for (final fragment in cached.fragments) { - if (requestedIndices != null && - !requestedIndices.contains(fragment.index)) { - continue; - } - try { - final ackFuture = waitForFragmentAckCallback?.call( - sessionId: sessionId, - index: fragment.index, - timeout: const Duration(seconds: 8), - ); - await sendRawPacketCallback!( - contactPath: requester.outPath, - contactPathLen: requester.outPathLen, - payload: fragment.encodeBinary(), - ); - if (ackFuture != null) { - final acked = await ackFuture; - if (!acked) { - debugPrint( - 'โš ๏ธ [ImageProvider] ACK timeout for $sessionId#${fragment.index}', - ); - return false; - } - } - } catch (e, st) { - debugPrint('โŒ [ImageProvider] Serve error for $sessionId: $e\n$st'); - return false; - } - } - debugPrint( - '๐Ÿ“ท [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId', + return serveCachedSessionFragments( + providerLabel: 'ImageProvider', + sessionId: sessionId, + requester: requester, + fragments: cached.fragments, + maxDirectPayloadHops: maxDirectPayloadHops, + indexOf: (fragment) => fragment.index, + encodeBinary: (fragment) => fragment.encodeBinary(), + sendRawPacket: sendRawPacketCallback, + waitForFragmentAck: waitForFragmentAckCallback, + requestedIndices: requestedIndices, ); - return true; } // โ”€โ”€ Persistence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index f54f88e..33e3d10 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import 'helpers/raw_session_retransmit.dart'; import '../utils/voice_message_parser.dart'; import '../services/voice_codec_service.dart'; import '../services/voice_player_service.dart'; @@ -181,56 +182,18 @@ class VoiceProvider with ChangeNotifier { ); return false; } - if (sendRawPacketCallback == null) { - debugPrint('โš ๏ธ [VoiceProvider] sendRawPacketCallback is not set'); - return false; - } - if (requester.outPathLen < 0) { - debugPrint( - 'โš ๏ธ [VoiceProvider] Requester ${requester.advName} has no direct path', - ); - return false; - } - if (requester.outPathLen > maxDirectPayloadHops) { - debugPrint( - 'โš ๏ธ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)', - ); - return false; - } - - for (final packet in cached.packets) { - if (requestedIndices != null && - !requestedIndices.contains(packet.index)) { - continue; - } - try { - final ackFuture = waitForFragmentAckCallback?.call( - sessionId: sessionId, - index: packet.index, - timeout: const Duration(seconds: 8), - ); - await sendRawPacketCallback!( - contactPath: requester.outPath, - contactPathLen: requester.outPathLen, - payload: packet.encodeBinary(), - ); - if (ackFuture != null) { - final acked = await ackFuture; - if (!acked) { - debugPrint( - 'โš ๏ธ [VoiceProvider] ACK timeout for $sessionId#${packet.index}', - ); - return false; - } - } - } catch (e, st) { - debugPrint( - 'โŒ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st', - ); - return false; - } - } - return true; + return serveCachedSessionFragments( + providerLabel: 'VoiceProvider', + sessionId: sessionId, + requester: requester, + fragments: cached.packets, + maxDirectPayloadHops: maxDirectPayloadHops, + indexOf: (packet) => packet.index, + encodeBinary: (packet) => packet.encodeBinary(), + sendRawPacket: sendRawPacketCallback, + waitForFragmentAck: waitForFragmentAckCallback, + requestedIndices: requestedIndices, + ); } // โ”€โ”€ Playback โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7445e4a..e1463ed 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -23,6 +23,8 @@ import '../widgets/permission_request_dialog.dart'; import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; +enum _HomeTab { messages, contacts, map } + class HomeScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; final Function(Locale?) onLocaleChanged; @@ -50,13 +52,30 @@ class _HomeScreenState extends State bool _isMapFullscreen = false; bool _showRxTxIndicators = true; bool _isMapEnabled = true; + bool _isContactsEnabled = true; + + List<_HomeTab> get _enabledTabs { + return [ + _HomeTab.messages, + if (_isContactsEnabled) _HomeTab.contacts, + if (_isMapEnabled) _HomeTab.map, + ]; + } + + _HomeTab get _currentTab { + final tabs = _enabledTabs; + final safeIndex = _currentIndex < tabs.length + ? _currentIndex + : tabs.length - 1; + return tabs[safeIndex < 0 ? 0 : safeIndex]; + } @override void initState() { super.initState(); // Initialize synchronously so first build always has a valid controller. _initTabController(); - _loadMapEnabledAndInitTabs(); + _loadTabVisibilityAndInitTabs(); _loadRxTxPreference(); // Show permission dialog after the first frame if needed @@ -67,58 +86,75 @@ class _HomeScreenState extends State } } - Future _loadMapEnabledAndInitTabs() async { + Future _loadTabVisibilityAndInitTabs() async { final prefs = await SharedPreferences.getInstance(); final mapEnabled = prefs.getBool('map_enabled') ?? true; + final contactsEnabled = prefs.getBool('contacts_enabled') ?? true; if (!mounted) return; - if (_isMapEnabled != mapEnabled) { - _updateTabController(mapEnabled); + if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) { + _updateTabController( + mapEnabled: mapEnabled, + contactsEnabled: contactsEnabled, + ); } } void _initTabController() { - final tabCount = _isMapEnabled ? 3 : 2; - _tabController = TabController(length: tabCount, vsync: this); + _tabController = TabController(length: _enabledTabs.length, vsync: this); _tabController.addListener(_onTabChanged); } void _onTabChanged() { setState(() { _currentIndex = _tabController.index; - // Exit fullscreen when switching away from map tab (only if map is enabled and is tab 2) - if (_isMapEnabled && _currentIndex != 2) { + if (_currentTab != _HomeTab.map) { _isMapFullscreen = false; } }); } - void _updateTabController(bool mapEnabled) { - if (_isMapEnabled == mapEnabled) return; + void _updateTabController({ + required bool mapEnabled, + required bool contactsEnabled, + }) { + if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) { + return; + } - // Save current index before rebuilding + final oldTabs = _enabledTabs; final oldIndex = _tabController.index; + final oldTab = oldTabs[oldIndex]; - // Remove old listener and dispose - _tabController.removeListener(_onTabChanged); - _tabController.dispose(); + final oldController = _tabController; + oldController.removeListener(_onTabChanged); // Update state _isMapEnabled = mapEnabled; + _isContactsEnabled = contactsEnabled; + + final newTabs = _enabledTabs; + final newIndex = newTabs.indexOf(oldTab); // Create new controller - final tabCount = mapEnabled ? 3 : 2; - _tabController = TabController(length: tabCount, vsync: this); + _tabController = TabController(length: newTabs.length, vsync: this); _tabController.addListener(_onTabChanged); - // Restore index (clamp to valid range) - if (oldIndex < tabCount) { - _tabController.index = oldIndex; - _currentIndex = oldIndex; - } else { - _currentIndex = tabCount - 1; - } + _currentIndex = newIndex >= 0 ? newIndex : 0; + _tabController.index = _currentIndex; setState(() {}); + + // Dispose old controller after widgets have rebound to the new controller. + WidgetsBinding.instance.addPostFrameCallback((_) { + oldController.dispose(); + }); + } + + void _navigateToTab(_HomeTab tab) { + final targetIndex = _enabledTabs.indexOf(tab); + if (targetIndex >= 0 && targetIndex != _tabController.index) { + _tabController.animateTo(targetIndex); + } } Future _loadRxTxPreference() async { @@ -276,18 +312,23 @@ class _HomeScreenState extends State messagesProvider.setLocalizations(localizations); } - // Check if map enabled setting changed and update tab controller + // Check if tab visibility settings changed and update tab controller final appProvider = context.watch(); - if (_isMapEnabled != appProvider.isMapEnabled) { + if (_isMapEnabled != appProvider.isMapEnabled || + _isContactsEnabled != appProvider.isContactsEnabled) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _updateTabController(appProvider.isMapEnabled); + _updateTabController( + mapEnabled: appProvider.isMapEnabled, + contactsEnabled: appProvider.isContactsEnabled, + ); }); } - // Determine if we should hide the UI (only in fullscreen on map tab) - final shouldHideUI = - _isMapEnabled && _isMapFullscreen && _currentIndex == 2; + final enabledTabs = _enabledTabs; + final isMapTabActive = _currentTab == _HomeTab.map; + final shouldHideUI = _isMapEnabled && _isMapFullscreen && isMapTabActive; + final shouldShowTabBar = enabledTabs.length > 1; return Scaffold( appBar: shouldHideUI @@ -374,29 +415,33 @@ class _HomeScreenState extends State ), body: TabBarView( controller: _tabController, - children: [ - MessagesTab( - onNavigateToMap: _isMapEnabled - ? () => _tabController.animateTo(2) - : null, - ), - ContactsTab( - onNavigateToMap: _isMapEnabled - ? () => _tabController.animateTo(2) - : null, - ), - if (_isMapEnabled) - MapTab( - onFullscreenChanged: (isFullscreen) { - setState(() { - _isMapFullscreen = isFullscreen; - }); - }, - onNavigateToMessages: () => _tabController.animateTo(0), - ), - ], + children: enabledTabs.map((tab) { + switch (tab) { + case _HomeTab.messages: + return MessagesTab( + onNavigateToMap: _isMapEnabled + ? () => _navigateToTab(_HomeTab.map) + : null, + ); + case _HomeTab.contacts: + return ContactsTab( + onNavigateToMap: _isMapEnabled + ? () => _navigateToTab(_HomeTab.map) + : null, + ); + case _HomeTab.map: + return MapTab( + onFullscreenChanged: (isFullscreen) { + setState(() { + _isMapFullscreen = isFullscreen; + }); + }, + onNavigateToMessages: () => _navigateToTab(_HomeTab.messages), + ); + } + }).toList(), ), - bottomNavigationBar: shouldHideUI + bottomNavigationBar: shouldHideUI || !shouldShowTabBar ? null : Consumer2( builder: (context, messagesProvider, contactsProvider, child) { @@ -415,27 +460,31 @@ class _HomeScreenState extends State ), child: TabBar( controller: _tabController, - tabs: [ - Tab( - icon: _buildTabIconWithBadge( - Icons.message, - unreadCount, - ), - text: AppLocalizations.of(context)!.messages, - ), - Tab( - icon: _buildTabIconWithBadge( - Icons.contacts, - newContactsCount, - ), - text: AppLocalizations.of(context)!.contacts, - ), - if (_isMapEnabled) - Tab( - icon: const Icon(Icons.map), - text: AppLocalizations.of(context)!.map, - ), - ], + tabs: enabledTabs.map((tab) { + switch (tab) { + case _HomeTab.messages: + return Tab( + icon: _buildTabIconWithBadge( + Icons.message, + unreadCount, + ), + text: AppLocalizations.of(context)!.messages, + ); + case _HomeTab.contacts: + return Tab( + icon: _buildTabIconWithBadge( + Icons.contacts, + newContactsCount, + ), + text: AppLocalizations.of(context)!.contacts, + ); + case _HomeTab.map: + return Tab( + icon: const Icon(Icons.map), + text: AppLocalizations.of(context)!.map, + ); + } + }).toList(), ), ); }, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 813d08e..2225dc4 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -727,6 +727,19 @@ class _SettingsScreenState extends State { }, ), ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.contacts_outlined), + title: const Text('Disable Contacts'), + subtitle: const Text( + 'Hide the contacts tab to simplify navigation', + ), + value: !appProvider.isContactsEnabled, + onChanged: (value) async { + await appProvider.toggleContactsEnabled(!value); + }, + ), + ), ListTile( leading: const Icon(Icons.language), title: Text(AppLocalizations.of(context)!.language), diff --git a/lib/widgets/messages/image_message_bubble.dart b/lib/widgets/messages/image_message_bubble.dart index 0ac6d75..ae11fb0 100644 --- a/lib/widgets/messages/image_message_bubble.dart +++ b/lib/widgets/messages/image_message_bubble.dart @@ -59,6 +59,11 @@ class _ImageMessageBubbleState extends State { return Consumer( builder: (context, imageProvider, _) { final session = imageProvider.session(envelope.sessionId); + final sender = _resolveSender(envelope); + final effectivePathLen = + sender != null && sender.outPathLen >= 0 + ? sender.outPathLen + : widget.message.pathLen; final isComplete = imageProvider.isComplete(envelope.sessionId); final eta = imageProvider.estimateRemainingTransferTime( envelope.sessionId, @@ -98,6 +103,7 @@ class _ImageMessageBubbleState extends State { radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, + pathLen: effectivePathLen, ), ), const SizedBox(height: 4), @@ -109,13 +115,13 @@ class _ImageMessageBubbleState extends State { received: received, total: total, envelope: envelope, - pathLen: widget.message.pathLen, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, error: _errorText, isSentByMe: widget.isSentByMe, eta: eta, + pathLen: effectivePathLen, ), style: TextStyle( fontSize: 11, @@ -143,6 +149,7 @@ class _ImageMessageBubbleState extends State { required int? radioBw, required int? radioSf, required int? radioCr, + required int pathLen, }) { if (isComplete && imageBytes != null) { return AspectRatio( @@ -184,7 +191,7 @@ class _ImageMessageBubbleState extends State { radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, - pathLen: widget.message.pathLen, + pathLen: pathLen, ), icon: const Icon(Icons.download_rounded, size: 40), color: Colors.white70, @@ -298,12 +305,13 @@ class _ImageMessageBubbleState extends State { if (!mounted) return; // Timeout = 2ร— estimated LoRa airtime (min 30s). + final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; final txEstimate = estimateImageTransmitDuration( fragmentCount: missing.isEmpty ? envelope.total : missing.length, sizeBytes: missing.isEmpty ? envelope.sizeBytes : (envelope.sizeBytes * missing.length / envelope.total).round(), - pathLen: pathLen, + pathLen: effectivePathLen, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, @@ -335,6 +343,19 @@ class _ImageMessageBubbleState extends State { ); if (contact != null) return contact; + // For sent direct messages, fetch must target the recipient peer. + final recipientKey = widget.message.recipientPublicKey; + if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) { + final byKey = contactsProvider.findContactByKey(recipientKey); + if (byKey != null) return byKey; + if (recipientKey.length >= 6) { + final byPrefix = contactsProvider.findContactByPrefix( + Uint8List.fromList(recipientKey.sublist(0, 6)), + ); + if (byPrefix != null) return byPrefix; + } + } + final senderName = widget.message.senderName?.trim(); if (senderName != null && senderName.isNotEmpty) { for (final c in contactsProvider.contacts) { diff --git a/lib/widgets/messages/tictactoe_message_bubble.dart b/lib/widgets/messages/tictactoe_message_bubble.dart index 6124732..0b3b6a6 100644 --- a/lib/widgets/messages/tictactoe_message_bubble.dart +++ b/lib/widgets/messages/tictactoe_message_bubble.dart @@ -22,6 +22,7 @@ class TicTacToeMessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; final event = TicTacToeMessageParser.tryParse(message.text); if (event == null) { return const SizedBox.shrink(); @@ -82,6 +83,12 @@ class TicTacToeMessageBubble extends StatelessWidget { final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O'; final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol; + final titleColor = isSentByMe + ? colorScheme.onPrimaryContainer + : colorScheme.onSurface; + final statusColor = isSentByMe + ? colorScheme.onPrimaryContainer.withValues(alpha: 0.85) + : colorScheme.onSurface.withValues(alpha: 0.85); return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 230), @@ -92,12 +99,16 @@ class TicTacToeMessageBubble extends StatelessWidget { 'Tic-Tac-Toe ยท Game ${state.gameId}', style: Theme.of( context, - ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold), + ).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.bold, + color: titleColor, + ), ), const SizedBox(height: 8), _BoardGrid( board: state.board, enabled: isMyTurn, + isSentByMe: isSentByMe, onTapCell: (idx) => _onCellTap( context: context, idx: idx, @@ -110,7 +121,9 @@ class TicTacToeMessageBubble extends StatelessWidget { const SizedBox(height: 8), Text( _statusText(state: state, mySymbol: mySymbol), - style: Theme.of(context).textTheme.labelSmall, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: statusColor), ), ], ), @@ -221,16 +234,26 @@ class TicTacToeMessageBubble extends StatelessWidget { class _BoardGrid extends StatelessWidget { final List board; final bool enabled; + final bool isSentByMe; final ValueChanged onTapCell; const _BoardGrid({ required this.board, required this.enabled, + required this.isSentByMe, required this.onTapCell, }); @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final cellBackground = isSentByMe + ? colorScheme.primaryContainer.withValues(alpha: 0.35) + : colorScheme.surface; + final cellBorder = isSentByMe + ? colorScheme.primary.withValues(alpha: 0.45) + : colorScheme.outline.withValues(alpha: 0.35); + return SizedBox( width: 180, height: 180, @@ -251,12 +274,18 @@ class _BoardGrid extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: cellBackground, + border: Border.all(color: cellBorder), ), child: Text( value ?? '', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, + color: value == 'X' + ? colorScheme.primary + : value == 'O' + ? colorScheme.tertiary + : null, ), ), ), diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart index b73e037..07a2cc3 100644 --- a/lib/widgets/messages/voice_message_bubble.dart +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -57,6 +57,11 @@ class _VoiceMessageBubbleState extends State { builder: (context, voiceProvider, _) { final session = voiceProvider.session(voiceId); final envelope = VoiceEnvelope.tryParseText(widget.message.text); + final sender = _resolveSenderContact(); + final effectivePathLen = + sender != null && sender.outPathLen >= 0 + ? sender.outPathLen + : widget.message.pathLen; final isPlaying = voiceProvider.isPlaying(voiceId); final isComplete = voiceProvider.isComplete(voiceId); @@ -96,7 +101,7 @@ class _VoiceMessageBubbleState extends State { session: session, envelope: envelope, messageText: widget.message.text, - pathLen: widget.message.pathLen, + pathLen: effectivePathLen, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, @@ -123,7 +128,7 @@ class _VoiceMessageBubbleState extends State { radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, - pathLen: widget.message.pathLen, + pathLen: effectivePathLen, ); }, borderRadius: BorderRadius.circular(24), @@ -280,12 +285,13 @@ class _VoiceMessageBubbleState extends State { } // Timeout = 2ร— estimated LoRa airtime (min 30s). + final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen; final txEstimate = envelope != null ? estimateVoiceTransmitDuration( packetCount: envelope.total, mode: envelope.mode, durationMs: envelope.durationMs, - pathLen: pathLen, + pathLen: effectivePathLen, radioBw: radioBw, radioSf: radioSf, radioCr: radioCr, @@ -329,6 +335,19 @@ class _VoiceMessageBubbleState extends State { if (contact != null) return contact; } + // For sent direct messages, fetch must target the recipient peer. + final recipientKey = widget.message.recipientPublicKey; + if (widget.isSentByMe && recipientKey != null && recipientKey.isNotEmpty) { + final byKey = contactsProvider.findContactByKey(recipientKey); + if (byKey != null) return byKey; + if (recipientKey.length >= 6) { + final byPrefix = contactsProvider.findContactByPrefix( + Uint8List.fromList(recipientKey.sublist(0, 6)), + ); + if (byPrefix != null) return byPrefix; + } + } + final senderName = widget.message.senderName?.trim(); if (senderName != null && senderName.isNotEmpty) { for (final contact in contactsProvider.contacts) { diff --git a/test/providers/helpers/raw_session_retransmit_test.dart b/test/providers/helpers/raw_session_retransmit_test.dart new file mode 100644 index 0000000..5f83aa1 --- /dev/null +++ b/test/providers/helpers/raw_session_retransmit_test.dart @@ -0,0 +1,145 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/helpers/raw_session_retransmit.dart'; + +class _Fragment { + final int index; + final Uint8List payload; + + _Fragment(this.index, this.payload); +} + +Contact _buildContact({required int outPathLen}) { + return Contact( + publicKey: Uint8List.fromList(List.generate(32, (i) => i)), + type: ContactType.chat, + flags: 0, + outPathLen: outPathLen, + outPath: Uint8List.fromList(List.generate(8, (i) => i + 1)), + advName: 'Requester', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('serveCachedSessionFragments', () { + test('returns false when sender callback is missing', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: null, + ); + + expect(ok, isFalse); + }); + + test('sends only requested indices and waits for ack', () async { + final sent = []; + final waited = []; + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([10])), + _Fragment(1, Uint8List.fromList([20])), + _Fragment(2, Uint8List.fromList([30])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async { + sent.add(payload); + }, + waitForFragmentAck: + ({ + required sessionId, + required index, + timeout = const Duration(seconds: 8), + }) async { + waited.add(index); + return true; + }, + requestedIndices: {1, 2}, + ); + + expect(ok, isTrue); + expect(sent.length, equals(2)); + expect(sent[0], equals(Uint8List.fromList([20]))); + expect(sent[1], equals(Uint8List.fromList([30]))); + expect(waited, equals([1, 2])); + }); + + test('fails when ack does not arrive', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + waitForFragmentAck: + ({ + required sessionId, + required index, + timeout = const Duration(seconds: 8), + }) async { + return false; + }, + ); + + expect(ok, isFalse); + }); + + test('fails when no requested index matches cached fragments', () async { + final ok = await serveCachedSessionFragments<_Fragment>( + providerLabel: 'TestProvider', + sessionId: 'deadbeef', + requester: _buildContact(outPathLen: 1), + fragments: [ + _Fragment(0, Uint8List.fromList([1])), + ], + maxDirectPayloadHops: 3, + indexOf: (f) => f.index, + encodeBinary: (f) => f.payload, + sendRawPacket: + ({ + required contactPath, + required contactPathLen, + required payload, + }) async {}, + requestedIndices: {99}, + ); + + expect(ok, isFalse); + }); + }); +}