From f96d8022accb27e7f4ed00bd832874cbe0ce1e2c Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 28 Feb 2026 19:19:18 +0100 Subject: [PATCH] feat: add Codec2 voice messages over LoRa mesh (iOS/macOS only) Push-to-talk voice messaging using the Codec2 ultra-low-bitrate speech codec, transmitted as V: prefixed packets over the existing MeshCore LoRa mesh pipeline. Voice recording UI (long-press send button or + menu) is gated behind Platform.isIOS || Platform.isMacOS since the `record` package only supports microphone capture on those platforms in this build. Key changes: - VoiceRecorderService: streams 8kHz mono PCM chunks via `record` package - VoicePlayerService: decodes Codec2 bytes to WAV and plays via audioplayers - VoiceCodecService: async Codec2 encode/decode in background isolates - VoiceProvider: reassembles multi-packet sessions, drives playback - VoiceMessageBubble: shows packet progress, play/stop controls - MessagesProvider: detects V: prefix, routes to VoiceProvider - MessagesTab: PTT long-press gesture + recording indicator (iOS/macOS) - Message model: isVoice + voiceId fields for session tracking - Auto-selects codec mode from radio bandwidth (700C/1200/1300 bps) - codec2_flutter + meshcore_client switched from path to git deps --- README.md | 4 +- android/app/src/main/AndroidManifest.xml | 3 + ios/Podfile.lock | 19 + ios/Runner/Info.plist | 2 + lib/main.dart | 14 + lib/models/message.dart | 14 + lib/providers/app_provider.dart | 64 +++ lib/providers/connection_provider.dart | 20 + lib/providers/messages_provider.dart | 12 + lib/providers/voice_provider.dart | 121 ++++++ lib/screens/messages_tab.dart | 375 ++++++++++++++++-- lib/services/voice_codec_service.dart | 72 ++++ lib/services/voice_player_service.dart | 98 +++++ lib/services/voice_recorder_service.dart | 119 ++++++ lib/utils/voice_message_parser.dart | 155 ++++++++ lib/widgets/messages/message_bubble.dart | 106 +++-- .../messages/voice_message_bubble.dart | 149 +++++++ linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile.lock | 49 ++- macos/Runner/Info.plist | 2 + pubspec.lock | 135 ++++++- pubspec.yaml | 13 +- test/widget_test.dart | 22 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 27 files changed, 1509 insertions(+), 81 deletions(-) create mode 100644 lib/providers/voice_provider.dart create mode 100644 lib/services/voice_codec_service.dart create mode 100644 lib/services/voice_player_service.dart create mode 100644 lib/services/voice_recorder_service.dart create mode 100644 lib/utils/voice_message_parser.dart create mode 100644 lib/widgets/messages/voice_message_bubble.dart diff --git a/README.md b/README.md index 4c37097..6ea4eac 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ Flutter app for Search and Rescue operations over a [MeshCore](https://github.co ## BLE Protocol -The BLE communication layer lives in a separate reusable package: +MeshCore SAR is powered by the `meshcore_client` package: **[github.com/dz0ny/meshcore_client](https://github.com/dz0ny/meshcore_client)** -It handles connection management, command queueing, and binary frame parsing/building. +The client handles BLE connection management, command queueing, and binary frame parsing/building, and is extracted so you can build custom tools, integrations, and apps on top of MeshCore. ## Requirements diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 426853b..cfb6410 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,9 @@ + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 19ab91e..8fc96d9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,9 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - codec2_flutter (0.1.0): + - Flutter - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.9): @@ -61,6 +66,8 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - record_ios (1.2.0): + - Flutter - SDWebImage (5.21.7): - SDWebImage/Core (= 5.21.7) - SDWebImage/Core (5.21.7) @@ -76,6 +83,8 @@ PODS: - Flutter DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) @@ -89,6 +98,7 @@ DEPENDENCIES: - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -103,6 +113,10 @@ SPEC REPOS: - SwiftyGif EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" + codec2_flutter: + :path: ".symlinks/plugins/codec2_flutter/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -129,6 +143,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -139,6 +155,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/vibration/ios" SPEC CHECKSUMS: + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd + codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 @@ -155,6 +173,7 @@ SPEC CHECKSUMS: package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c8789aa..d490e21 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,6 +66,8 @@ MeshCore SAR needs location access to display team members and SAR markers on the map NSMotionUsageDescription MeshCore SAR needs access to the compass to show your heading direction on the map + NSMicrophoneUsageDescription + MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations NSPhotoLibraryUsageDescription MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations UIApplicationSceneManifest diff --git a/lib/main.dart b/lib/main.dart index 29f601c..eefea45 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,7 +11,10 @@ import 'providers/messages_provider.dart'; import 'providers/map_provider.dart'; import 'providers/drawing_provider.dart'; import 'providers/channels_provider.dart'; +import 'providers/voice_provider.dart'; import 'providers/app_provider.dart'; +import 'services/voice_codec_service.dart'; +import 'services/voice_player_service.dart'; import 'services/tile_cache_service.dart'; import 'services/notification_service.dart'; import 'services/locale_preferences.dart'; @@ -231,10 +234,19 @@ class _MeshCoreSarAppState extends State { ), ChangeNotifierProvider(create: (_) => ChannelsProvider()), + // Voice provider (packet reassembly + playback) + ChangeNotifierProvider( + create: (_) => VoiceProvider( + codec: VoiceCodecService(), + player: VoicePlayerService(), + ), + ), + // Tile cache service Provider(create: (_) => TileCacheService()), // App provider that coordinates everything + // VoiceProvider is read via context.read inside create since it's already registered above ChangeNotifierProxyProvider6< ConnectionProvider, ContactsProvider, @@ -250,6 +262,7 @@ class _MeshCoreSarAppState extends State { messagesProvider: context.read(), drawingProvider: context.read(), channelsProvider: context.read(), + voiceProvider: context.read(), tileCacheService: context.read(), ), update: @@ -270,6 +283,7 @@ class _MeshCoreSarAppState extends State { messagesProvider: messages, drawingProvider: drawings, channelsProvider: channels, + voiceProvider: context.read(), tileCacheService: tileCache, ), ), diff --git a/lib/models/message.dart b/lib/models/message.dart index 5fe1650..faa442b 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -9,6 +9,20 @@ export 'package:meshcore_client/meshcore_client.dart' import 'package:flutter/foundation.dart'; import 'package:meshcore_client/meshcore_client.dart'; import 'sar_marker.dart'; +import '../utils/voice_message_parser.dart'; + +extension MessageVoiceExtension on Message { + /// True when this message is a voice recording (`V:` text or binary voice). + bool get isVoiceMessage => isVoice; + + /// Parses the voice packet mode from the stored [voiceId] session info. + /// Returns null for non-voice messages. + VoicePacketMode? get voicePacketMode { + if (!isVoice || text.isEmpty) return null; + final pkt = VoicePacket.tryParseText(text); + return pkt?.mode; + } +} extension MessageSarExtension on Message { /// Infer the [SarMarkerType] from stored SAR fields. diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 4dccf98..889a475 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -6,11 +6,13 @@ import 'contacts_provider.dart'; import 'messages_provider.dart'; import 'drawing_provider.dart'; import 'channels_provider.dart'; +import 'voice_provider.dart'; import '../services/tile_cache_service.dart'; import '../services/location_tracking_service.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../utils/drawing_message_parser.dart'; +import '../utils/voice_message_parser.dart'; /// Main App Provider - coordinates all other providers class AppProvider with ChangeNotifier { @@ -19,6 +21,7 @@ class AppProvider with ChangeNotifier { final MessagesProvider messagesProvider; final DrawingProvider drawingProvider; final ChannelsProvider channelsProvider; + final VoiceProvider voiceProvider; final TileCacheService tileCacheService; final LocationTrackingService locationTrackingService = LocationTrackingService(); @@ -38,6 +41,7 @@ class AppProvider with ChangeNotifier { required this.messagesProvider, required this.drawingProvider, required this.channelsProvider, + required this.voiceProvider, required this.tileCacheService, }) { _setupCallbacks(); @@ -335,6 +339,19 @@ class AppProvider with ChangeNotifier { return; } + // If it's a text-format voice packet, feed it to VoiceProvider + if (VoicePacket.isVoiceText(enrichedMessage.text)) { + final pkt = VoicePacket.tryParseText(enrichedMessage.text); + if (pkt != null) { + voiceProvider.addPacket(pkt); + // Mark the message with voice metadata before adding to chat + enrichedMessage = enrichedMessage.copyWith( + isVoice: true, + voiceId: pkt.sessionId, + ); + } + } + // Pass contact lookup function to link channel messages with contacts messagesProvider.addMessage( enrichedMessage, @@ -380,6 +397,18 @@ class AppProvider with ChangeNotifier { contactsProvider.updateTelemetry(publicKeyPrefix, responseData); }; + // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) + // Used for direct binary voice packets (VoicePacket binary format, magic 0x56 'V') + connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { + if (!VoicePacket.isVoiceBinary(payload)) return; + final pkt = VoicePacket.tryParseBinary(payload); + if (pkt == null) return; + debugPrint('πŸŽ™οΈ [AppProvider] Binary voice packet received: $pkt'); + final justComplete = voiceProvider.addPacket(pkt); + // Insert or update the placeholder message in the chat list + _handleIncomingVoicePacket(pkt, justComplete: justComplete); + }; + // When a contact's routing path is updated in the mesh network connectionProvider.onPathUpdated = (publicKey) { debugPrint( @@ -693,6 +722,41 @@ class AppProvider with ChangeNotifier { } } + /// Insert or update a voice placeholder message for binary raw-data packets. + /// + /// Binary voice packets arrive without a chat message, so we synthesise one + /// to give the user a playable bubble in the message list. + void _handleIncomingVoicePacket(VoicePacket pkt, {required bool justComplete}) { + final sessionId = pkt.sessionId; + + // Check if a placeholder for this session already exists + final existing = messagesProvider.messages.where( + (m) => m.isVoice && m.voiceId == sessionId, + ).firstOrNull; + + if (existing != null) { + // Already have a placeholder β€” no need to add another + return; + } + + // First packet of a new session: insert placeholder message + final msgId = 'voice_$sessionId'; + final placeholder = Message( + id: msgId, + messageType: MessageType.contact, // direct contact (binary only) + senderPublicKeyPrefix: null, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: '', // no text β€” displayed as VoiceMessageBubble + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.received, + isVoice: true, + voiceId: sessionId, + ); + messagesProvider.addMessage(placeholder, contactLookup: (_) => ''); + } + // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // The ConnectionProvider's onMessageWaiting callback handles automatic message fetching diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index df86158..3e01e12 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -167,6 +167,7 @@ class ConnectionProvider with ChangeNotifier { Function(String messageId, int echoCount, int snrRaw, int rssiDbm)? onMessageEchoDetected; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; + Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; // Track pending send operations for auto-recovery final Map _pendingSendOperations = {}; @@ -498,6 +499,10 @@ class ConnectionProvider with ChangeNotifier { onStatusResponse?.call(publicKeyPrefix, statusData); }; + _bleService.onRawDataReceived = (payload, snrRaw, rssiDbm) { + onRawDataReceived?.call(payload, snrRaw, rssiDbm); + }; + _bleService.onDeviceInfoReceived = (deviceInfo) { debugPrint('πŸ“₯ [Provider] Received DeviceInfo:'); debugPrint(' Firmware Version: ${deviceInfo['firmwareVersion']}'); @@ -1318,6 +1323,21 @@ class ConnectionProvider with ChangeNotifier { } } + /// Send a raw binary voice packet directly to a contact (cmdSendRawData, code 25). + /// Only works for contacts with a known direct route (outPathLen >= 0). + Future sendRawVoicePacket({ + required Uint8List contactPath, + required int contactPathLen, + required Uint8List payload, + }) async { + if (!_bleService.isConnected) return; + await _bleService.sendRawVoicePacket( + contactPathLen: contactPathLen, + contactPath: contactPath, + payload: payload, + ); + } + /// Request telemetry from contact /// /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 779da04..feb3c19 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -8,6 +8,7 @@ import '../services/message_storage_service.dart'; import '../services/notification_service.dart'; import '../utils/sar_message_parser.dart'; import '../utils/drawing_message_parser.dart'; +import '../utils/voice_message_parser.dart'; import '../l10n/app_localizations.dart'; import 'helpers/message_retry_manager.dart'; @@ -277,6 +278,17 @@ class MessagesProvider with ChangeNotifier { ); } + // Check if it's a voice message (V:...) and not already marked + if (VoicePacket.isVoiceText(enhancedMessage.text) && !enhancedMessage.isVoice) { + final pkt = VoicePacket.tryParseText(enhancedMessage.text); + if (pkt != null) { + enhancedMessage = enhancedMessage.copyWith( + isVoice: true, + voiceId: pkt.sessionId, + ); + } + } + // For channel messages with sender name, try to link with contact Message finalMessage = enhancedMessage; if (enhancedMessage.isChannelMessage && diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart new file mode 100644 index 0000000..787aa6d --- /dev/null +++ b/lib/providers/voice_provider.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../utils/voice_message_parser.dart'; +import '../services/voice_codec_service.dart'; +import '../services/voice_player_service.dart'; + +/// Reassembly state for one voice session. +class VoiceSession { + final String sessionId; + final VoicePacketMode mode; + final int total; + final List packets; // indexed by packet.index + + VoiceSession({ + required this.sessionId, + required this.mode, + required this.total, + }) : packets = List.filled(total, null); + + int get receivedCount => packets.where((p) => p != null).length; + bool get isComplete => receivedCount == total; + + /// Total estimated audio duration in seconds (sum of all received packets). + double get estimatedDurationSeconds { + var ms = 0; + for (final p in packets) { + if (p != null) ms += p.durationMs; + } + return ms / 1000.0; + } +} + +/// Manages incoming voice packet sessions and coordinates playback. +class VoiceProvider with ChangeNotifier { + final VoiceCodecService _codec; + final VoicePlayerService _player; + + /// Active sessions keyed by sessionId. + final Map _sessions = {}; + + /// Currently playing session ID, or null. + String? _playingSessionId; + + VoiceProvider({ + required VoiceCodecService codec, + required VoicePlayerService player, + }) : _codec = codec, + _player = player; + + // ── Session accessors ──────────────────────────────────────────────────── + + VoiceSession? session(String sessionId) => _sessions[sessionId]; + bool isComplete(String sessionId) => _sessions[sessionId]?.isComplete ?? false; + bool isPlaying(String sessionId) => + _playingSessionId == sessionId && _player.isPlaying; + + // ── Packet reception ───────────────────────────────────────────────────── + + /// Add an incoming [packet] to its session. Creates the session on first packet. + /// Returns true if the session just became complete. + bool addPacket(VoicePacket packet) { + _sessions.putIfAbsent( + packet.sessionId, + () => VoiceSession( + sessionId: packet.sessionId, + mode: packet.mode, + total: packet.total, + ), + ); + + final session = _sessions[packet.sessionId]!; + if (packet.index < session.total) { + session.packets[packet.index] = packet; + } + + final justComplete = session.isComplete; + notifyListeners(); + return justComplete; + } + + // ── Playback ───────────────────────────────────────────────────────────── + + /// Decode and play the voice session with [sessionId]. + /// Plays whatever packets are available (handles partial reception gracefully). + Future play(String sessionId) async { + final session = _sessions[sessionId]; + if (session == null) { + debugPrint('❌ [VoiceProvider] play($sessionId) β€” session not found, known: ${_sessions.keys.toList()}'); + return; + } + + debugPrint('πŸŽ™οΈ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}'); + + try { + final pcm = await _codec.decodePackets(session.packets, session.mode); + debugPrint('πŸŽ™οΈ [VoiceProvider] decoded ${pcm.length} PCM samples'); + _playingSessionId = sessionId; + notifyListeners(); + await _player.play(pcm); + } catch (e, st) { + debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); + } finally { + if (_playingSessionId == sessionId) { + _playingSessionId = null; + notifyListeners(); + } + } + } + + Future stop() async { + await _player.stop(); + _playingSessionId = null; + notifyListeners(); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index d8aed06..4162803 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -1,4 +1,7 @@ import 'dart:async'; +import 'dart:io' show Platform; +import 'dart:math' as math; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -8,6 +11,7 @@ import '../providers/contacts_provider.dart'; import '../providers/map_provider.dart'; import '../providers/connection_provider.dart'; import '../providers/drawing_provider.dart'; +import '../providers/voice_provider.dart'; import '../providers/app_provider.dart'; import '../models/message.dart'; import '../models/contact.dart'; @@ -15,8 +19,11 @@ import '../widgets/messages/sar_update_sheet.dart'; import '../widgets/messages/recipient_selector_sheet.dart'; import '../widgets/messages/message_bubble.dart'; import '../services/message_destination_preferences.dart'; +import '../services/voice_recorder_service.dart'; +import '../services/voice_codec_service.dart'; import '../utils/toast_logger.dart'; import '../utils/key_comparison.dart'; +import '../utils/voice_message_parser.dart'; import '../l10n/app_localizations.dart'; class MessagesTab extends StatefulWidget { @@ -42,6 +49,17 @@ class _MessagesTabState extends State { MessageDestinationPreferences.destinationTypeChannel; Contact? _selectedRecipient; + // Voice recording state + final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); + bool _isRecording = false; + bool _isSendingVoice = false; + static const int _maxVoicePackets = 10; + bool get _voiceSupported => Platform.isIOS || Platform.isMacOS; + StreamSubscription? _voiceStreamSub; + String? _currentVoiceSessionId; + final List _recordedChunks = []; + VoicePacketMode? _activeVoiceMode; + @override void initState() { super.initState(); @@ -68,6 +86,8 @@ class _MessagesTabState extends State { @override void dispose() { _highlightTimer?.cancel(); + _voiceStreamSub?.cancel(); + _voiceRecorder.dispose(); _textController.dispose(); _focusNode.dispose(); _scrollController.dispose(); @@ -231,7 +251,8 @@ class _MessagesTabState extends State { /// Get tooltip for destination button String _getDestinationTooltip() { if (_destinationType == - MessageDestinationPreferences.destinationTypeChannel && _selectedRecipient != null) { + MessageDestinationPreferences.destinationTypeChannel && + _selectedRecipient != null) { final channelName = _selectedRecipient!.getLocalizedDisplayName(context); return '$channelName (tap to change)'; } else if (_selectedRecipient != null) { @@ -260,8 +281,15 @@ class _MessagesTabState extends State { if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { // Send to selected channel (or public channel if none selected) - final channelIdx = _selectedRecipient?.publicKey[1] ?? 0; // Extract channel index from pseudo public key - await _sendToChannel(text, connectionProvider, messagesProvider, channelIdx); + final channelIdx = + _selectedRecipient?.publicKey[1] ?? + 0; // Extract channel index from pseudo public key + await _sendToChannel( + text, + connectionProvider, + messagesProvider, + channelIdx, + ); } else if (_selectedRecipient != null) { // Send to contact or room await _sendToRecipient( @@ -376,6 +404,218 @@ class _MessagesTabState extends State { } } + // ── Voice recording ──────────────────────────────────────────────────────── + + Future _startVoiceRecording() async { + if (_isSendingVoice || _isRecording) return; + debugPrint('πŸŽ™οΈ [Voice] _startVoiceRecording called'); + final hasPermission = await _voiceRecorder.requestPermission(); + debugPrint('πŸŽ™οΈ [Voice] hasPermission=$hasPermission'); + if (!hasPermission) { + if (!mounted) return; + ToastLogger.error(context, 'Microphone permission required for voice'); + return; + } + + if (!mounted) return; + final connectionProvider = context.read(); + debugPrint( + 'πŸŽ™οΈ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}', + ); + if (!connectionProvider.deviceInfo.isConnected) { + ToastLogger.error(context, 'Not connected to device'); + return; + } + + // Generate a new session ID (4 random bytes β†’ 8 hex chars) + final rng = math.Random.secure(); + _currentVoiceSessionId = List.generate( + 4, + (_) => rng.nextInt(256), + ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + + final radioBwKhz = connectionProvider.deviceInfo.radioBw ?? 125; + _activeVoiceMode = voiceModeForBandwidth(radioBwKhz * 1000); + final packetDuration = Duration( + milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, + ); + + debugPrint( + 'πŸŽ™οΈ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', + ); + + _recordedChunks.clear(); + setState(() => _isRecording = true); + + try { + final stream = _voiceRecorder.startCapture(chunkDuration: packetDuration); + debugPrint('πŸŽ™οΈ [Voice] capture started, listening for chunks...'); + _voiceStreamSub = stream.listen( + (pcmChunk) { + if (!_isRecording) return; + _recordedChunks.add(pcmChunk); + debugPrint( + 'πŸŽ™οΈ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', + ); + setState(() {}); + if (_recordedChunks.length >= _maxVoicePackets) { + debugPrint('πŸŽ™οΈ [Voice] max packets reached, stopping'); + _stopAndSendVoice(); + } + }, + onError: (e) { + debugPrint('❌ [Voice] stream error: $e'); + _stopAndSendVoice(); + }, + onDone: () => debugPrint('πŸŽ™οΈ [Voice] stream done'), + ); + } catch (e) { + debugPrint('❌ [Voice] startCapture threw: $e'); + if (mounted) setState(() => _isRecording = false); + } + } + + Future _stopAndSendVoice() async { + if (!_isRecording) return; + debugPrint( + 'πŸŽ™οΈ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered', + ); + + await _voiceStreamSub?.cancel(); + _voiceStreamSub = null; + await _voiceRecorder.stopCapture(); + + final chunks = List.from(_recordedChunks); + final sessionId = _currentVoiceSessionId; + final mode = _activeVoiceMode; + _recordedChunks.clear(); + + if (mounted) { + setState(() { + _isRecording = false; + _isSendingVoice = chunks.isNotEmpty && sessionId != null; + }); + } + + if (chunks.isEmpty || sessionId == null || mode == null || !mounted) { + if (mounted) setState(() { _isSendingVoice = false; _currentVoiceSessionId = null; }); + return; + } + + try { + await _encodeAndSendAllPackets( + chunks: chunks, + sessionId: sessionId, + mode: mode, + ); + } catch (e, st) { + debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st'); + } finally { + debugPrint('πŸŽ™οΈ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice'); + if (mounted) { + setState(() { + _isSendingVoice = false; + _currentVoiceSessionId = null; + }); + } + } + } + + Future _encodeAndSendAllPackets({ + required List chunks, + required String sessionId, + required VoicePacketMode mode, + }) async { + final total = chunks.length; + final codec = VoiceCodecService(); + final connectionProvider = context.read(); + final messagesProvider = context.read(); + final voiceProvider = context.read(); + + // Insert the chat placeholder before sending (so it appears immediately) + final msgId = 'voice_${sessionId}_sent'; + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + final isChannel = + _destinationType == + MessageDestinationPreferences.destinationTypeChannel; + final sentMsg = Message( + id: msgId, + messageType: (!isChannel && _selectedRecipient != null) + ? MessageType.contact + : MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, + text: '', + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sent, + isVoice: true, + voiceId: sessionId, + channelIdx: isChannel ? (_selectedRecipient?.publicKey[1] ?? 0) : null, + recipientPublicKey: _selectedRecipient?.publicKey, + ); + messagesProvider.addSentMessage(sentMsg); + + debugPrint( + 'πŸŽ™οΈ [Voice] encoding+sending $total packets, mode=${mode.label}, session=$sessionId', + ); + for (var i = 0; i < total; i++) { + if (!mounted) return; + try { + final codec2Data = await codec.encode(chunks[i], mode); + debugPrint( + 'πŸŽ™οΈ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes', + ); + final packet = VoicePacket( + sessionId: sessionId, + mode: mode, + index: i, + total: total, + codec2Data: codec2Data, + ); + + voiceProvider.addPacket(packet); + + if (!isChannel && + _selectedRecipient != null && + _selectedRecipient!.outPathLen >= 0) { + debugPrint( + 'πŸŽ™οΈ [Voice] packet $i β†’ binary (raw data), pathLen=${_selectedRecipient!.outPathLen}', + ); + await connectionProvider.sendRawVoicePacket( + contactPath: _selectedRecipient!.outPath, + contactPathLen: _selectedRecipient!.outPathLen, + payload: packet.encodeBinary(), + ); + } else { + final channelIdx = isChannel + ? (_selectedRecipient?.publicKey[1] ?? 0) + : 0; + final text = packet.encodeText(); + debugPrint( + 'πŸŽ™οΈ [Voice] packet $i β†’ text ch=$channelIdx len=${text.length}: $text', + ); + await connectionProvider.sendChannelMessage( + channelIdx: channelIdx, + text: text, + ); + } + debugPrint('πŸŽ™οΈ [Voice] packet $i sent ok'); + } catch (e, st) { + debugPrint('❌ [Voice] packet $i send error: $e\n$st'); + } + } + debugPrint('πŸŽ™οΈ [Voice] all packets sent for session $sessionId'); + // Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking). + // addSentMessage() forces deliveryStatus.sending; we upgrade it here so the + // bubble shows "Sent" instead of "Sending" once all packets are on the wire. + messagesProvider.markMessageSent(msgId, 0, 0); + } + + // ── SAR dialog ───────────────────────────────────────────────────────────── + void _showSarDialog() { showModalBottomSheet( context: context, @@ -406,6 +646,45 @@ class _MessagesTabState extends State { ); } + void _showComposerActions() { + showModalBottomSheet( + context: context, + builder: (sheetContext) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.add_location_alt), + title: Text(AppLocalizations.of(context)!.sendSarMarker), + onTap: () { + Navigator.pop(sheetContext); + _showSarDialog(); + }, + ), + if (_voiceSupported) + ListTile( + enabled: !_isSendingVoice, + leading: Icon(_isRecording ? Icons.stop : Icons.mic), + title: Text(_isRecording ? 'Stop recording' : 'Record voice'), + onTap: _isSendingVoice + ? null + : () { + Navigator.pop(sheetContext); + if (_isRecording) { + _stopAndSendVoice(); + } else { + _startVoiceRecording(); + } + }, + ), + ], + ), + ); + }, + ); + } + Future _sendSarMessage( String emoji, String name, @@ -426,7 +705,10 @@ class _MessagesTabState extends State { if (!sendToChannel && !sendToAllContacts && roomPublicKey == null) { if (!mounted) return; - ToastLogger.error(context, 'Please select a destination to send SAR marker'); + ToastLogger.error( + context, + 'Please select a destination to send SAR marker', + ); return; } @@ -443,7 +725,10 @@ class _MessagesTabState extends State { if (chatContacts.isEmpty) { if (!mounted) return; - ToastLogger.error(context, AppLocalizations.of(context)!.noContactsAvailable); + ToastLogger.error( + context, + AppLocalizations.of(context)!.noContactsAvailable, + ); return; } @@ -788,9 +1073,12 @@ class _MessagesTabState extends State { widget.onNavigateToMap?.call(); } : widget.onNavigateToMap != null && - message.isDrawing && message.drawingId != null + message.isDrawing && + message.drawingId != null ? () { - debugPrint('πŸ—ΊοΈ [MessagesTab] Drawing tapped! ID: ${message.drawingId}'); + debugPrint( + 'πŸ—ΊοΈ [MessagesTab] Drawing tapped! ID: ${message.drawingId}', + ); final mapProvider = context .read(); final drawingProvider = context @@ -823,18 +1111,20 @@ class _MessagesTabState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - // SAR quick action button + // Quick actions (+) button IconButton( - icon: const Icon(Icons.add_location_alt), - tooltip: AppLocalizations.of(context)!.sendSarMarker, - onPressed: _showSarDialog, + icon: Icon(_isRecording ? Icons.stop : Icons.add), + tooltip: _isRecording ? 'Stop recording' : 'More actions', + onPressed: _isRecording + ? _stopAndSendVoice + : _showComposerActions, style: IconButton.styleFrom( backgroundColor: Theme.of( context, ).colorScheme.primaryContainer, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimaryContainer, + foregroundColor: _isRecording + ? Colors.red + : Theme.of(context).colorScheme.onPrimaryContainer, ), ), const SizedBox(width: 4), @@ -890,18 +1180,52 @@ class _MessagesTabState extends State { ? Colors.orange : Theme.of(context).textTheme.bodySmall?.color, ), - suffixIcon: IconButton( - icon: Icon( - Icons.send_rounded, - size: 22, - color: _textController.text.trim().isEmpty - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.primary, + suffixIcon: GestureDetector( + onLongPressStart: (_voiceSupported && !_isSendingVoice) + ? (_) => _startVoiceRecording() + : null, + onLongPressEnd: (_voiceSupported && _isRecording) + ? (_) => _stopAndSendVoice() + : null, + onLongPressCancel: (_voiceSupported && _isRecording) + ? () => _stopAndSendVoice() + : null, + child: IconButton( + icon: _isSendingVoice + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + _isRecording + ? Icons.mic + : Icons.send_rounded, + size: 22, + color: _isRecording + ? Colors.red + : (_textController.text.trim().isEmpty + ? Theme.of(context).disabledColor + : Theme.of( + context, + ).colorScheme.primary), + ), + onPressed: + _isRecording || + _isSendingVoice || + _textController.text.trim().isEmpty + ? null + : _sendMessage, + tooltip: _isRecording + ? 'Recording... release to send voice' + : (_isSendingVoice + ? 'Sending voice...' + : _voiceSupported + ? 'Send (long press to record voice)' + : 'Send'), ), - onPressed: _textController.text.trim().isEmpty - ? null - : _sendMessage, - tooltip: 'Send', ), ), textInputAction: TextInputAction.send, @@ -917,4 +1241,3 @@ class _MessagesTabState extends State { ); } } - diff --git a/lib/services/voice_codec_service.dart b/lib/services/voice_codec_service.dart new file mode 100644 index 0000000..9e1c1a5 --- /dev/null +++ b/lib/services/voice_codec_service.dart @@ -0,0 +1,72 @@ +import 'dart:typed_data'; +import 'package:codec2_flutter/codec2_flutter.dart'; +import '../utils/voice_message_parser.dart'; + +export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode; + +/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin. +Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { + switch (pktMode) { + case VoicePacketMode.mode700c: return Codec2Mode.mode700c; + case VoicePacketMode.mode1200: return Codec2Mode.mode1200; + case VoicePacketMode.mode1300: return Codec2Mode.mode1300; + case VoicePacketMode.mode2400: return Codec2Mode.mode2400; + } +} + +/// Selects the [VoicePacketMode] best suited for a given LoRa radio bandwidth. +/// +/// Call with [radioBandwidthHz] from the device's radio params +/// (e.g. 125000 for 125 kHz). +VoicePacketMode voiceModeForBandwidth(int radioBandwidthHz) { + if (radioBandwidthHz <= 62500) return VoicePacketMode.mode700c; + if (radioBandwidthHz <= 125000) return VoicePacketMode.mode1200; + return VoicePacketMode.mode1300; +} + +/// High-level codec service that provides async Codec2 encode/decode +/// executed in a background isolate so the UI thread is never blocked. +class VoiceCodecService { + /// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode]. + /// Returns the raw Codec2-encoded bytes. + Future encode(Int16List pcm, VoicePacketMode mode) => + Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); + + /// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode]. + Future decode(Uint8List codec2Bytes, VoicePacketMode mode) => + Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); + + /// Decode and concatenate multiple [packets] into a single PCM Int16List. + /// Packets with null/missing entries are substituted with silence. + Future decodePackets( + List packets, + VoicePacketMode mode, + ) async { + final c2Mode = codec2ModeFor(mode); + final c2 = Codec2.create(c2Mode); + final spf = c2.samplesPerFrame; + c2.destroy(); + + // Estimate total samples (use actual data or silence per missing packet) + final all = []; + for (final pkt in packets) { + if (pkt == null || pkt.codec2Data.isEmpty) { + // Silence for missing packet β€” duration approximated by mode + final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf; + all.add(Int16List(silenceSamples)); + } else { + final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode); + all.add(decoded); + } + } + + final total = all.fold(0, (sum, l) => sum + l.length); + final result = Int16List(total); + var offset = 0; + for (final chunk in all) { + result.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + return result; + } +} diff --git a/lib/services/voice_player_service.dart b/lib/services/voice_player_service.dart new file mode 100644 index 0000000..3347921 --- /dev/null +++ b/lib/services/voice_player_service.dart @@ -0,0 +1,98 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file +/// to the system temp directory and using [AudioPlayer]. +class VoicePlayerService { + final AudioPlayer _player = AudioPlayer(); + bool _isPlaying = false; + + bool get isPlaying => _isPlaying; + + VoicePlayerService() { + _player.onPlayerStateChanged.listen((state) { + debugPrint('πŸ”Š [VoicePlayer] state β†’ $state'); + _isPlaying = state == PlayerState.playing; + }); + _player.onLog.listen((msg) => debugPrint('πŸ”Š [VoicePlayer] log: $msg')); + } + + /// Play [pcmSamples] (Int16, 8000 Hz, mono). + Future play(Int16List pcmSamples) async { + debugPrint('πŸ”Š [VoicePlayer] play() called, ${pcmSamples.length} samples'); + if (_isPlaying) await stop(); + + final wavBytes = _buildWav(pcmSamples, sampleRate: 8000); + final tmpDir = await getTemporaryDirectory(); + final file = File('${tmpDir.path}/vc_voice.wav'); + await file.writeAsBytes(wavBytes); + debugPrint('πŸ”Š [VoicePlayer] WAV written: ${wavBytes.length} bytes β†’ ${file.path}'); + + try { + _isPlaying = true; + await _player.play(DeviceFileSource(file.path)); + debugPrint('πŸ”Š [VoicePlayer] play() returned (audio playing)'); + } catch (e, st) { + debugPrint('❌ [VoicePlayer] play() error: $e\n$st'); + _isPlaying = false; + } + } + + Future stop() async { + debugPrint('πŸ”Š [VoicePlayer] stop()'); + await _player.stop(); + _isPlaying = false; + } + + void dispose() { + _player.dispose(); + } + + // ── WAV file builder ───────────────────────────────────────────────────── + + /// Constructs a minimal WAV (RIFF/PCM) file from Int16 mono samples. + static Uint8List _buildWav(Int16List samples, {required int sampleRate}) { + const int numChannels = 1; + const int bitsPerSample = 16; + const int audioFormat = 1; // PCM + + final dataSize = samples.length * 2; // 2 bytes per Int16 sample + final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8; + final blockAlign = numChannels * bitsPerSample ~/ 8; + final totalSize = 36 + dataSize; + + final buf = ByteData(44 + dataSize); + var offset = 0; + + void writeStr(String s) { + for (final c in s.codeUnits) { buf.setUint8(offset++, c); } + } + void writeU32(int v) { buf.setUint32(offset, v, Endian.little); offset += 4; } + void writeU16(int v) { buf.setUint16(offset, v, Endian.little); offset += 2; } + + writeStr('RIFF'); + writeU32(totalSize); + writeStr('WAVE'); + writeStr('fmt '); + writeU32(16); // subchunk1 size + writeU16(audioFormat); // 1 = PCM + writeU16(numChannels); + writeU32(sampleRate); + writeU32(byteRate); + writeU16(blockAlign); + writeU16(bitsPerSample); + writeStr('data'); + writeU32(dataSize); + + // PCM sample data (little-endian Int16) + for (final s in samples) { + buf.setInt16(offset, s, Endian.little); + offset += 2; + } + + return buf.buffer.asUint8List(); + } +} diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart new file mode 100644 index 0000000..8039a86 --- /dev/null +++ b/lib/services/voice_recorder_service.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:record/record.dart'; + +/// Captures raw PCM audio at 8000 Hz, 16-bit mono. +/// +/// [startCapture] returns a [Stream] that emits chunks of PCM +/// samples every [chunkDuration]. Call [stopCapture] to end recording. +class VoiceRecorderService { + final AudioRecorder _recorder = AudioRecorder(); + StreamSubscription? _sub; + StreamController? _controller; + + bool _isRecording = false; + bool get isRecording => _isRecording; + + /// Request microphone permission. Returns true if granted. + Future requestPermission() async { + return _recorder.hasPermission(); + } + + /// Start capturing PCM audio. + /// + /// [chunkDuration] controls how often samples are emitted (default 1 s). + /// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding. + Stream startCapture({ + Duration chunkDuration = const Duration(seconds: 1), + }) { + if (_isRecording) { + throw StateError('VoiceRecorderService: already recording'); + } + + _controller = StreamController( + onCancel: () => _stopInternal(), + ); + _isRecording = true; + + _startRecording(chunkDuration); + return _controller!.stream; + } + + Future _startRecording(Duration chunkDuration) async { + final config = const RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: 8000, + numChannels: 1, + bitRate: 128000, // ignored for PCM, but required by API + ); + + try { + final stream = await _recorder.startStream(config); + final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000; + final buffer = []; + + _sub = stream.listen( + (data) { + buffer.addAll(data); + while (buffer.length >= chunkBytes) { + final chunk = buffer.sublist(0, chunkBytes); + buffer.removeRange(0, chunkBytes); + _controller?.add(_bytesToInt16(Uint8List.fromList(chunk))); + } + }, + onDone: () { + if (buffer.isNotEmpty) { + final padded = _padToEven(buffer); + _controller?.add(_bytesToInt16(Uint8List.fromList(padded))); + } + _controller?.close(); + }, + onError: (e) { + debugPrint('❌ [VoiceRecorder] Stream error: $e'); + _controller?.addError(e); + _controller?.close(); + }, + cancelOnError: false, + ); + } catch (e) { + debugPrint('❌ [VoiceRecorder] Failed to start: $e'); + _isRecording = false; + _controller?.addError(e); + _controller?.close(); + } + } + + /// Stop recording and flush remaining samples. + Future stopCapture() async { + await _stopInternal(); + } + + Future _stopInternal() async { + if (!_isRecording) return; + _isRecording = false; + await _sub?.cancel(); + _sub = null; + await _recorder.stop(); + } + + void dispose() { + _stopInternal(); + _recorder.dispose(); + } + + /// Convert raw little-endian PCM bytes to Int16List. + static Int16List _bytesToInt16(Uint8List bytes) { + final bd = ByteData.sublistView(bytes); + final samples = Int16List(bytes.length ~/ 2); + for (var i = 0; i < samples.length; i++) { + samples[i] = bd.getInt16(i * 2, Endian.little); + } + return samples; + } + + static List _padToEven(List buf) { + if (buf.length % 2 != 0) buf.add(0); + return buf; + } +} diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart new file mode 100644 index 0000000..5e34043 --- /dev/null +++ b/lib/utils/voice_message_parser.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Identifies which Codec2 mode was used for a voice packet. +/// Matches the modeId byte in the text/binary packet header. +enum VoicePacketMode { + mode700c(0, '700C'), + mode1200(1, '1200'), + mode2400(2, '2400'), + mode1300(3, '1300'); + + const VoicePacketMode(this.id, this.label); + final int id; + final String label; + + static VoicePacketMode fromId(int id) => + VoicePacketMode.values.firstWhere((m) => m.id == id, orElse: () => VoicePacketMode.mode700c); +} + +/// A single Codec2-encoded chunk belonging to a multi-packet voice session. +/// +/// Text format (channels): +/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2} +/// +/// Binary format (direct contacts, received via pushRawData): +/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...] +class VoicePacket { + final String sessionId; // 8 hex chars (4 bytes) + final VoicePacketMode mode; + final int index; // 0-based + final int total; // total packet count + final Uint8List codec2Data; + + const VoicePacket({ + required this.sessionId, + required this.mode, + required this.index, + required this.total, + required this.codec2Data, + }); + + // ── Text (channel) format ──────────────────────────────────────────────── + + static const String _textPrefix = 'V:'; + + static bool isVoiceText(String text) => text.startsWith(_textPrefix); + + /// Parse a text-format voice packet. Returns null on failure. + static VoicePacket? tryParseText(String text) { + if (!text.startsWith(_textPrefix)) return null; + try { + final body = text.substring(_textPrefix.length); + final parts = body.split(':'); + // parts: [sessionId, modeId, 'idx/total', base64data] + if (parts.length != 4) return null; + + final sessionId = parts[0]; + if (sessionId.length != 8) return null; + + final modeId = int.tryParse(parts[1]); + if (modeId == null) return null; + + final indexTotal = parts[2].split('/'); + if (indexTotal.length != 2) return null; + final index = int.tryParse(indexTotal[0]); + final total = int.tryParse(indexTotal[1]); + if (index == null || total == null || total < 1) return null; + + final codec2Data = base64.decode(parts[3]); + + return VoicePacket( + sessionId: sessionId, + mode: VoicePacketMode.fromId(modeId), + index: index, + total: total, + codec2Data: codec2Data, + ); + } catch (_) { + return null; + } + } + + /// Encode as text (channel format). Max ~198 chars for 700C/1200/2400. + String encodeText() { + final b64 = base64.encode(codec2Data); + return 'V:$sessionId:${mode.id}:$index/$total:$b64'; + } + + // ── Binary format ──────────────────────────────────────────────────────── + + static const int _binaryMagic = 0x56; // 'V' + static const int _binaryHeaderLen = 8; // magic(1)+session(4)+mode(1)+idx(1)+total(1) + + static bool isVoiceBinary(Uint8List payload) => + payload.isNotEmpty && payload[0] == _binaryMagic; + + /// Parse binary-format voice packet (from pushRawData payload). + static VoicePacket? tryParseBinary(Uint8List payload) { + if (payload.length < _binaryHeaderLen) return null; + if (payload[0] != _binaryMagic) return null; + try { + final sessionBytes = payload.sublist(1, 5); + final sessionId = sessionBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final modeId = payload[5]; + final index = payload[6]; + final total = payload[7]; + if (total < 1) return null; + final codec2Data = payload.sublist(_binaryHeaderLen); + return VoicePacket( + sessionId: sessionId, + mode: VoicePacketMode.fromId(modeId), + index: index, + total: total, + codec2Data: codec2Data, + ); + } catch (_) { + return null; + } + } + + /// Encode as binary payload (for cmdSendRawData). + Uint8List encodeBinary() { + final sessionBytes = Uint8List(4); + for (var i = 0; i < 4; i++) { + sessionBytes[i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); + } + final out = Uint8List(_binaryHeaderLen + codec2Data.length); + out[0] = _binaryMagic; + out.setRange(1, 5, sessionBytes); + out[5] = mode.id; + out[6] = index; + out[7] = total; + out.setRange(_binaryHeaderLen, out.length, codec2Data); + return out; + } + + // ── Duration helpers ───────────────────────────────────────────────────── + + /// Estimated audio duration of this packet in milliseconds. + int get durationMs { + // bytesPerSecond for each mode + final bps = switch (mode) { + VoicePacketMode.mode700c => 100, + VoicePacketMode.mode1200 => 150, + VoicePacketMode.mode1300 => 175, + VoicePacketMode.mode2400 => 300, + }; + if (bps == 0) return 0; + return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500); + } + + @override + String toString() => + 'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)'; +} diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 4287f89..2add029 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -19,6 +19,7 @@ import '../../utils/sar_message_parser.dart'; import '../../utils/key_comparison.dart'; import '../../l10n/app_localizations.dart'; import '../../utils/message_extensions.dart'; +import 'voice_message_bubble.dart'; /// Reusable message bubble widget that displays messages with various types: /// - Regular text messages (channel or direct) @@ -634,6 +635,7 @@ class _MessageBubbleState extends State { final displayName = isOwnMessage ? AppLocalizations.of(context)!.you : message.getRichDisplayName(senderContact); + final l10n = AppLocalizations.of(context)!; // For sent direct/channel messages, look up destination display label dynamic recipientContact; @@ -667,17 +669,22 @@ class _MessageBubbleState extends State { } } else if (isOwnMessage && message.isChannelMessage) { if (message.channelIdx == 0) { - recipientDisplayName = AppLocalizations.of(context)!.publicChannel; + recipientDisplayName = l10n.publicChannel; } else { final channelContact = contactsProvider.channels.where((c) { return c.publicKey.length > 1 && c.publicKey[1] == message.channelIdx; }).firstOrNull; recipientDisplayName = channelContact?.getLocalizedDisplayName(context) ?? - '${AppLocalizations.of(context)!.channel} ${message.channelIdx}'; + '${l10n.channel} ${message.channelIdx}'; } } + final recipientSubtitle = + isOwnMessage && message.isChannelMessage && recipientDisplayName != null + ? '${l10n.channel}: $recipientDisplayName' + : recipientDisplayName; + return GestureDetector( onTap: widget.onTap, onLongPress: widget.isCompact ? null : () => _showMessageOptions(context), @@ -860,47 +867,63 @@ class _MessageBubbleState extends State { const Icon(Icons.person, size: 16), const SizedBox(width: 4), Expanded( - child: Text( - displayName, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - color: isOwnMessage - ? Theme.of(context).colorScheme.primary - : null, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: isOwnMessage + ? Theme.of(context).colorScheme.primary + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Show destination for sent direct/channel messages on a separate line + if (isOwnMessage && + recipientSubtitle != null && + !widget.isCompact) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.arrow_forward, + size: 12, + color: Theme.of(context) + .textTheme + .labelSmall + ?.color + ?.withValues(alpha: 0.7), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + recipientSubtitle, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Theme.of(context) + .textTheme + .labelSmall + ?.color + ?.withValues(alpha: 0.75), + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ], ), ), - // Show destination for sent direct/channel messages - if (isOwnMessage && - recipientDisplayName != null && - !widget.isCompact) ...[ - const SizedBox(width: 4), - Icon( - Icons.arrow_forward, - size: 14, - color: Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.6), - ), - const SizedBox(width: 4), - Flexible( - child: Text( - recipientDisplayName, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), - fontStyle: FontStyle.italic, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], // Time for regular messages (not shown for SAR/drawing as it's already above) if (!isSarMarker && !message.isDrawing) ...[ + const SizedBox(width: 8), // Hop count indicator for received messages if (!isOwnMessage && message.pathLen < 255) ...[ const SizedBox(width: 4), @@ -1055,6 +1078,11 @@ class _MessageBubbleState extends State { ); }, ) + // Voice message content + else if (message.isVoice && + message.voiceId != null && + !widget.isCompact) + VoiceMessageBubble(message: message, isSentByMe: isOwnMessage) // Regular message content else if (!message.isDrawing || widget.isCompact) Text(message.text, style: Theme.of(context).textTheme.bodyMedium), diff --git a/lib/widgets/messages/voice_message_bubble.dart b/lib/widgets/messages/voice_message_bubble.dart new file mode 100644 index 0000000..3f807bb --- /dev/null +++ b/lib/widgets/messages/voice_message_bubble.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/voice_provider.dart'; +import '../../models/message.dart'; + +/// A message bubble that shows a voice recording with play/stop controls. +class VoiceMessageBubble extends StatelessWidget { + final Message message; + final bool isSentByMe; + + const VoiceMessageBubble({ + super.key, + required this.message, + required this.isSentByMe, + }); + + @override + Widget build(BuildContext context) { + final voiceId = message.voiceId; + if (voiceId == null) return const SizedBox.shrink(); + + return Consumer( + builder: (context, voiceProvider, _) { + final session = voiceProvider.session(voiceId); + final isPlaying = voiceProvider.isPlaying(voiceId); + final isComplete = voiceProvider.isComplete(voiceId); + + final received = session?.receivedCount ?? 0; + final total = session?.total ?? 0; + final durationSec = session?.estimatedDurationSeconds ?? 0.0; + final durationLabel = _formatDuration(durationSec); + final modeLabel = session?.mode.label ?? '?'; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Play / Stop button + InkWell( + onTap: () async { + if (isPlaying) { + await voiceProvider.stop(); + } else { + await voiceProvider.play(voiceId); + } + }, + borderRadius: BorderRadius.circular(24), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSentByMe + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.secondaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + isPlaying ? Icons.stop : Icons.play_arrow, + size: 28, + color: isSentByMe + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Waveform placeholder / progress indicator + if (isPlaying) + SizedBox( + width: 100, + child: LinearProgressIndicator( + backgroundColor: Colors.grey.withValues(alpha: 0.3), + ), + ) + else + _WaveformBar(isComplete: isComplete), + const SizedBox(height: 4), + // Duration + mode + packet progress + Text( + _buildStatusText( + durationLabel: durationLabel, + modeLabel: modeLabel, + received: received, + total: total, + isComplete: isComplete, + ), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ], + ); + }, + ); + } + + static String _formatDuration(double seconds) { + final s = seconds.round(); + if (s < 60) return '${s}s'; + return '${s ~/ 60}m ${s % 60}s'; + } + + static String _buildStatusText({ + required String durationLabel, + required String modeLabel, + required int received, + required int total, + required bool isComplete, + }) { + final progress = total > 0 ? ' ($received/$total)' : ''; + if (!isComplete && total > 0) { + return 'πŸŽ™οΈ $durationLabel Β· $modeLabel$progress'; + } + return 'πŸŽ™οΈ $durationLabel Β· $modeLabel'; + } +} + +/// Simple static waveform bar using a row of rectangles. +class _WaveformBar extends StatelessWidget { + final bool isComplete; + const _WaveformBar({required this.isComplete}); + + @override + Widget build(BuildContext context) { + const heights = [8.0, 14.0, 10.0, 18.0, 12.0, 16.0, 10.0, 14.0, 8.0, 12.0, 16.0, 10.0]; + final color = isComplete + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) + : Colors.grey.withValues(alpha: 0.5); + return Row( + children: heights + .map((h) => Container( + width: 3, + height: h, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + )) + .toList(), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 72830e3..485f549 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,13 +6,21 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin"); objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f869d60..a4f4d51 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux objectbox_flutter_libs + record_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 53e21a8..a909dff 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import device_info_plus import file_picker import flutter_blue_plus_darwin @@ -14,11 +15,13 @@ import nsd_macos import objectbox_flutter_libs import package_info_plus import path_provider_foundation +import record_macos import share_plus import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) @@ -28,6 +31,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 7a2d7fc..5560fdc 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,13 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - codec2_flutter (0.1.0): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS - flutter_blue_plus_darwin (0.0.2): - Flutter - FlutterMacOS @@ -8,6 +17,8 @@ PODS: - geolocator_apple (1.2.0): - Flutter - FlutterMacOS + - nsd_macos (0.0.1): + - FlutterMacOS - ObjectBox (4.4.1) - objectbox_flutter_libs (0.0.1): - FlutterMacOS @@ -17,28 +28,47 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - record_macos (1.2.0): + - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: + - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin`) + - codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`) + - nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`) - objectbox_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) SPEC REPOS: trunk: - ObjectBox EXTERNAL SOURCES: + audioplayers_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin + codec2_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos flutter_blue_plus_darwin: :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin flutter_local_notifications: @@ -47,28 +77,41 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral geolocator_apple: :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin + nsd_macos: + :path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos objectbox_flutter_libs: :path: Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + record_macos: + :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd + codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3 - flutter_local_notifications: 13862b132e32eb858dea558a86d45d08daeacfe7 + flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3 ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 objectbox_flutter_libs: f51d18f6a4b5965c218843373b7dc5ed5e3a2008 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + record_macos: 7f227161b93c49e7e34fe681c5891c8622c8cc8b share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..804f8b8 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -26,6 +26,8 @@ $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu + NSMicrophoneUsageDescription + MeshCore SAR needs microphone access to send voice messages over the mesh radio network during SAR operations NSPrincipalClass NSApplication diff --git a/pubspec.lock b/pubspec.lock index 0ef9a9f..f68cf87 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" bluez: dependency: transitive description: @@ -73,6 +129,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + codec2_flutter: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "0d3009f2626cd64232b7e19741f9ac45b0f01385" + url: "https://github.com/dz0ny/codec2_flutter.git" + source: git + version: "0.1.0" collection: dependency: transitive description: @@ -162,7 +227,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" @@ -593,8 +658,8 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: ca34662abe4ef511bcb6b3b6fd2c5f648fee62fe + ref: HEAD + resolved-ref: b0126d3941117d4ae2948047834f1136324a26fd url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" @@ -870,6 +935,70 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c996235..252be3e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,18 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: main + + # Codec2 ultra-low-bitrate speech codec (FFI plugin) + codec2_flutter: + git: + url: https://github.com/dz0ny/codec2_flutter.git + ffi: ^2.1.0 + + # Voice recording (raw PCM stream at 8000 Hz) + record: ^6.0.0 + + # Voice playback (plays WAV from file) + audioplayers: ^6.0.0 # BLE connectivity flutter_blue_plus: ^2.0.0 diff --git a/test/widget_test.dart b/test/widget_test.dart index dd7b317..3de035b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,21 +5,29 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/main.dart'; +import 'package:meshcore_sar_app/screens/home_screen.dart'; +import 'package:meshcore_sar_app/screens/welcome_wizard_screen.dart'; void main() { testWidgets('App launches smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + // Build app. await tester.pumpWidget(const MeshCoreSarApp()); - // Verify that our app title appears. - expect(find.text('MeshCore SAR'), findsOneWidget); + // During async initialization, loading indicator is shown. + expect(find.byType(CircularProgressIndicator), findsOneWidget); - // Verify the tab bar appears with tabs - expect(find.text('Messages'), findsOneWidget); - expect(find.text('Contacts'), findsOneWidget); - expect(find.text('Map'), findsOneWidget); + // Give async startup a bounded amount of time without requiring full settle + // (the app may keep background animations/timers alive). + await tester.pump(const Duration(seconds: 2)); + expect( + find.byType(CircularProgressIndicator).evaluate().isNotEmpty || + find.byType(HomeScreen).evaluate().isNotEmpty || + find.byType(WelcomeWizardScreen).evaluate().isNotEmpty, + isTrue, + ); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b90c34e..3b4555b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,14 +6,18 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); NsdWindowsPluginCApiRegisterWithRegistrar( @@ -22,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4bb233a..b3f2b49 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,10 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows geolocator_windows nsd_windows objectbox_flutter_libs permission_handler_windows + record_windows share_plus url_launcher_windows )